From e9673551ce43a3801d3d2bd1de96722faa1c63ea Mon Sep 17 00:00:00 2001 From: pascal Date: Mon, 20 Jul 2026 02:06:11 +0200 Subject: [PATCH 01/10] dodanie poradnika --- frontend/src/App.tsx | 707 ++++++++++++++++++++++++++++- frontend/src/styles.css | 984 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1678 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37a5291..e38c3db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const SMS_VERIFICATION_CODE = '123456'; -type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; +type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'guideBuying' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; type MessageThreadListing = { listingId: number; @@ -3413,7 +3413,8 @@ function App() { /> )} {view === 'services' && } - {view === 'guides' && } + {view === 'guides' && } + {view === 'guideBuying' && } {view === 'map' && } {view === 'home' && } @@ -12292,7 +12293,668 @@ function AddListingPage({ onNavigate }: { onNavigate: (view: View) => void }) { ); } -function GuidesPage() { +const buyingProcessSteps = [ + { icon: 'search', title: 'Analiza potrzeb i możliwości', text: 'Określ, po co kupujesz i na co Cię stać.' }, + { icon: 'calculator', title: 'Określenie budżetu', text: 'Policz cenę plus wszystkie koszty dodatkowe.' }, + { icon: 'chart', title: 'Sprawdzenie zdolności', text: 'Zbadaj zdolność kredytową w kilku bankach.' }, + { icon: 'pin', title: 'Wybór i analiza lokalizacji', text: 'Sprawdź okolicę, dojazdy i plany zagospodarowania.' }, + { icon: 'eye', title: 'Oglądanie mieszkań', text: 'Odwiedź kilka ofert i porównaj je na spokojnie.' }, + { icon: 'group', title: 'Negocjacja ceny', text: 'Ustal realną wartość i wynegocjuj warunki.' }, + { icon: 'calendar', title: 'Rezerwacja mieszkania', text: 'Zabezpiecz ofertę umową rezerwacyjną.' }, + { icon: 'document', title: 'Umowa przedwstępna', text: 'Spisz warunki i terminy u notariusza lub cywilnie.' }, + { icon: 'building', title: 'Kredyt hipoteczny', text: 'Złóż wnioski i wybierz najlepszą ofertę.' }, + { icon: 'scale', title: 'Akt notarialny', text: 'Podpisz umowę przenoszącą własność.' }, + { icon: 'key', title: 'Odbiór mieszkania', text: 'Sprawdź stan techniczny i spisz protokół.' }, + { icon: 'house', title: 'Zameldowanie i formalności', text: 'Przepisz media, ubezpiecz i zamelduj się.' }, +]; + +const buyingToc = [ + { id: 'start', label: 'Od czego zacząć?' }, + { id: 'budzet', label: 'Jak określić budżet?' }, + { id: 'gotowka-kredyt', label: 'Gotówka czy kredyt?' }, + { id: 'zdolnosc', label: 'Zdolność kredytowa' }, + { id: 'rynek', label: 'Rynek pierwotny czy wtórny?' }, + { id: 'lokalizacja', label: 'Wybór lokalizacji' }, + { id: 'ogladanie', label: 'Jak oglądać mieszkanie?' }, + { id: 'stan-techniczny', label: 'Stan techniczny' }, + { id: 'ksiega-wieczysta', label: 'Księga wieczysta' }, + { id: 'negocjacje', label: 'Negocjacja ceny' }, + { id: 'umowa-przedwstepna', label: 'Umowa przedwstępna' }, + { id: 'akt-notarialny', label: 'Akt notarialny' }, + { id: 'odbior', label: 'Odbiór mieszkania' }, + { id: 'klucze', label: 'Przekazanie kluczy' }, + { id: 'bledy', label: 'Najczęstsze błędy' }, + { id: 'faq', label: 'FAQ' }, + { id: 'podsumowanie', label: 'Podsumowanie' }, +]; + +const buyingCostRows = [ + { name: 'Wkład własny', who: 'Kupujący', value: 'zwykle 10–20% ceny', note: 'Im wyższy, tym niższa rata i lepsze warunki kredytu.' }, + { name: 'Podatek PCC (rynek wtórny)', who: 'Kupujący', value: '2% wartości', note: 'Zakup pierwszego mieszkania bywa zwolniony z PCC.' }, + { name: 'Taksa notarialna', who: 'Kupujący', value: 'zależna od ceny', note: 'Stawki maksymalne są ustalone rozporządzeniem, do negocjacji.' }, + { name: 'Wpis do księgi wieczystej', who: 'Kupujący', value: '200 zł + 200 zł', note: 'Wpis własności oraz ewentualnej hipoteki.' }, + { name: 'Prowizja pośrednika', who: 'Zależnie od umowy', value: 'zwykle 1–3%', note: 'Tylko jeśli korzystasz z biura nieruchomości.' }, + { name: 'Koszty kredytu', who: 'Kredytobiorca', value: 'prowizja, wycena, ubezpieczenia', note: 'Doliczane do finansowania zakupu z banku.' }, +]; + +const buyingChecklist = [ + 'Przeczytaj aktualny odpis z księgi wieczystej i sprawdź dział III i IV.', + 'Poproś o zaświadczenie o braku zaległości w czynszu i mediach.', + 'Sprawdź, kto figuruje jako właściciel i czy zgadza się ze sprzedającym.', + 'Zapytaj o zaległości i plany remontowe wspólnoty lub spółdzielni.', + 'Obejrzyj mieszkanie za dnia i wieczorem, sprawdź nasłonecznienie.', + 'Skontroluj instalacje: prąd, wodę, ogrzewanie, wentylację i wilgoć.', + 'Ustal, co dokładnie wchodzi w cenę (meble, kuchnia, miejsce postojowe).', + 'Zweryfikuj wysokość czynszu administracyjnego i co obejmuje.', + 'Sprawdź plan zagospodarowania i co może powstać w okolicy.', + 'Policz pełny koszt zakupu z podatkami i kredytem, nie tylko cenę.', +]; + +const buyingMistakes = [ + { title: 'Liczenie tylko ceny mieszkania', text: 'Pomijanie podatków, taksy notarialnej, kosztów kredytu i wykończenia potrafi podnieść budżet o kilkanaście procent.' }, + { title: 'Pominięcie księgi wieczystej', text: 'Brak sprawdzenia działu III i IV grozi zakupem mieszkania z hipoteką, służebnością lub roszczeniem osoby trzeciej.' }, + { title: 'Zakup pod wpływem emocji', text: 'Decyzja po jednym oglądaniu i „bo ktoś inny też jest zainteresowany” to najczęstsza droga do przepłacenia.' }, + { title: 'Brak zapasu w budżecie', text: 'Kredyt na maksimum zdolności nie zostawia poduszki na wzrost rat, remont czy nagłe wydatki.' }, + { title: 'Zaliczka zamiast zadatku', text: 'Przy zaliczce, jeśli druga strona się wycofa, trudniej dochodzić rekompensaty niż przy zadatku.' }, + { title: 'Zignorowanie stanu technicznego', text: 'Ukryta wilgoć, zła instalacja elektryczna czy stare piony to koszty, które ujawniają się dopiero po zakupie.' }, +]; + +const buyingFaq = [ + { + q: 'Ile własnych pieniędzy potrzebuję na start?', + a: 'Do kredytu banki oczekują zwykle wkładu własnego na poziomie 10–20% ceny. Do tego dochodzą koszty transakcyjne: podatek, taksa notarialna, wpisy do księgi wieczystej i ewentualna prowizja pośrednika. Bezpiecznie jest założyć, że oprócz wkładu własnego potrzebujesz jeszcze kilku procent ceny na same formalności.', + }, + { + q: 'Czy lepiej kupić od dewelopera czy z rynku wtórnego?', + a: 'To zależy od Twoich priorytetów. Nowe mieszkanie od dewelopera bywa energooszczędne i objęte gwarancją, ale często wymaga wykończenia i czekania na odbiór. Rynek wtórny to gotowe do zamieszkania lokale w ukształtowanych dzielnicach, za to trzeba dokładniej sprawdzić stan techniczny i stan prawny.', + }, + { + q: 'Czy przy zakupie pierwszego mieszkania zapłacę podatek PCC?', + a: 'Przy zakupie z rynku wtórnego standardowo obowiązuje podatek 2% od wartości. Dla osób kupujących swoje pierwsze mieszkanie lub dom przewidziano jednak zwolnienie z tego podatku. Zawsze potwierdź aktualne warunki u notariusza, bo to on nalicza i odprowadza PCC.', + }, + { + q: 'Jak długo trwa cały proces zakupu?', + a: 'Zakup za gotówkę można sfinalizować nawet w kilka tygodni. Z kredytem hipotecznym realny czas to zwykle 1,5–3 miesiące, bo dochodzi analiza wniosku, wycena nieruchomości i decyzja banku. Warto ten czas uwzględnić w umowie przedwstępnej.', + }, + { + q: 'Co daje umowa przedwstępna?', + a: 'Zabezpiecza obie strony: ustala cenę, termin i warunki finalnej transakcji. Najczęściej towarzyszy jej zadatek. Jeśli od umowy odstąpi kupujący, traci zadatek; jeśli sprzedający, zwraca go w podwójnej wysokości. To silniejsze zabezpieczenie niż zwykła zaliczka.', + }, + { + q: 'Czy muszę korzystać z pośrednika?', + a: 'Nie ma takiego obowiązku. Dobry pośrednik oszczędza czas i pomaga w formalnościach, ale pobiera prowizję. Jeśli kupujesz samodzielnie, tym bardziej zadbaj o weryfikację stanu prawnego i technicznego oraz o dobrze napisane umowy.', + }, +]; + +async function downloadBuyingChecklistPdf() { + const unwrapModuleDefault = (module: T | { default: T }): T => + 'default' in Object(module) ? (module as { default: T }).default : (module as T); + const downloadBlob = (blob: Blob, fileName: string) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + }; + + type PdfMakeRuntime = { + addFontContainer: (fontContainer: unknown) => void; + createPdf: (definition: unknown) => { getBlob: () => Promise }; + }; + + let pdfMake: PdfMakeRuntime; + try { + const pdfMakeModule = await import('pdfmake/build/pdfmake.js'); + const robotoFontModule = await import('pdfmake/build/fonts/Roboto.js'); + pdfMake = unwrapModuleDefault(pdfMakeModule as unknown as PdfMakeRuntime | { default: PdfMakeRuntime }); + pdfMake.addFontContainer(unwrapModuleDefault(robotoFontModule as unknown as { default: unknown })); + } catch (error) { + console.error('Nie udało się załadować generatora PDF', error); + window.alert('Nie udało się przygotować pliku PDF. Odśwież stronę i spróbuj ponownie.'); + return; + } + + const docDefinition = { + pageSize: 'A4', + pageMargins: [48, 48, 48, 48], + defaultStyle: { font: 'Roboto', color: '#14223a', fontSize: 11, lineHeight: 1.35 }, + content: [ + { text: 'PolskaLokalnie', color: '#0b9f5c', fontSize: 12, bold: true, margin: [0, 0, 0, 2] }, + { text: 'Lista kontrolna przy zakupie mieszkania', fontSize: 20, bold: true, margin: [0, 0, 0, 4] }, + { text: '10 rzeczy, które warto sprawdzić przed podpisaniem umowy', color: '#526171', margin: [0, 0, 0, 18] }, + { + ol: buyingChecklist.map((item) => ({ text: item, margin: [0, 0, 0, 6] })), + }, + { + text: 'Materiał informacyjny. Nie stanowi porady prawnej ani podatkowej – w razie wątpliwości skonsultuj się z notariuszem lub doradcą.', + color: '#8793a3', + fontSize: 9, + margin: [0, 22, 0, 0], + }, + ], + }; + + try { + const blob = await pdfMake.createPdf(docDefinition).getBlob(); + downloadBlob(blob, 'lista-kontrolna-zakup-mieszkania.pdf'); + } catch (error) { + console.error('Nie udało się wygenerować PDF', error); + window.alert('Nie udało się wygenerować pliku PDF. Spróbuj ponownie.'); + } +} + +function GuideBuyingPage({ onNavigate }: { onNavigate: (view: View) => void }) { + const scrollToId = (id: string) => { + const target = document.getElementById(id); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + return ( +
+
+ +
+
+ + + + Kupno mieszkania + +

Jak kupić mieszkanie krok po kroku

+

+ Kompletny, uczciwy przewodnik po całym procesie zakupu – od pierwszej decyzji, przez budżet, + kredyt i formalności, aż po odbiór kluczy. Prostym językiem, z listami kontrolnymi i praktycznymi + wskazówkami, które pomogą Ci uniknąć najczęstszych błędów. +

+
+ Aktualna wiedza na 2026 rok + Oparte na sprawdzonych zasadach + Listy kontrolne i PDF + Kalkulatory kosztów +
+
+
+ +
+ + +
+
+
+

Proces zakupu mieszkania w 12 krokach

+

Cała droga w skrócie. Poniżej rozwijamy każdy etap dokładnie.

+
+
+ {buyingProcessSteps.map((step, index) => ( +
+ {index + 1} + + {step.title} + {step.text} +
+ ))} +
+
+ +
+ 01 +

Od czego zacząć zakup mieszkania?

+

+ Zanim zaczniesz przeglądać oferty, odpowiedz sobie na kilka pytań. To one, a nie ładne zdjęcia, + zdecydują o trafności zakupu. Ustal, czy kupujesz dla siebie, dla rodziny, czy pod inwestycję, + jak długo planujesz tu mieszkać i co jest dla Ciebie nienegocjowalne – metraż, liczba pokoi, + piętro, winda, balkon czy miejsce postojowe. +

+

+ Rozdziel potrzeby od zachcianek. Potrzeba to np. druga sypialnia przy powiększającej się rodzinie. + Zachcianka to konkretny kolor kuchni, który i tak zmienisz. Im lepiej rozpoznasz swoje realne potrzeby, + tym mniej ofert będziesz musiał obejrzeć i tym rzadziej dasz się ponieść emocjom. +

+
+ +
+ Wskazówka +

Wypisz na kartce trzy kryteria, których nie odpuścisz, i trzy, które są mile widziane. Wracaj do tej listy przy każdym oglądaniu – to najprostsza ochrona przed impulsywną decyzją.

+
+
+
+ +
+ 02 +

Jak określić budżet?

+

+ Budżet to nie tylko cena mieszkania. To cena powiększona o wszystkie koszty transakcyjne oraz o to, + co wydasz zaraz po zakupie. Zsumuj cenę, podatki, taksę notarialną, wpisy do księgi wieczystej, + ewentualną prowizję pośrednika i koszty kredytu. Dopiero ta kwota pokazuje, na co naprawdę Cię stać. +

+
+ + + + + + + + + + + {buyingCostRows.map((row) => ( + + + + + + + ))} + +
KosztKto ponosiIle (orientacyjnie)Uwagi
{row.name}{row.who}{row.value}{row.note}
+
+
+ +
+ Zostaw zapas +

Nie planuj zakupu na granicy możliwości. Poduszka finansowa na kilka rat, drobny remont i nieprzewidziane wydatki to nie luksus, tylko element bezpiecznego budżetu.

+
+
+
+ +
+ 03 +

Gotówka czy kredyt hipoteczny?

+

+ Zakup za gotówkę jest szybszy, prostszy i pozwala mocniej negocjować, bo sprzedający nie czeka na + decyzję banku. Nie zawsze jednak warto zamrażać wszystkie oszczędności w jednej nieruchomości – + tracisz płynność i poduszkę bezpieczeństwa. +

+

+ Kredyt hipoteczny rozkłada koszt w czasie i pozwala kupić wcześniej, ale oznacza wieloletnie + zobowiązanie, odsetki i zależność od zmian stóp procentowych. Zanim zdecydujesz, porównaj oferty + kilku banków i policz całkowity koszt kredytu, a nie tylko wysokość raty. +

+
+
+

Gotówka

+
    +
  • Szybka i prosta transakcja
  • +
  • Silniejsza pozycja w negocjacjach
  • +
  • Brak odsetek i rat
  • +
  • Uwaga: mniejsza płynność finansowa
  • +
+
+
+

Kredyt hipoteczny

+
    +
  • Kupujesz, nie mając całej kwoty
  • +
  • Oszczędności zostają jako rezerwa
  • +
  • Uwaga: całkowity koszt rośnie o odsetki
  • +
  • Uwaga: wieloletnie zobowiązanie
  • +
+
+
+
+ +
+ 04 +

Jak obliczyć zdolność kredytową?

+

+ Zdolność kredytowa to maksymalna kwota, jaką bank jest gotów Ci pożyczyć. Wpływają na nią głównie: + wysokość i stabilność dochodów, forma zatrudnienia, liczba osób na utrzymaniu, inne zobowiązania + (raty, karty, limity) oraz historia w BIK. Banki liczą też bufor na wypadek wzrostu stóp, dlatego + maksymalna kwota bywa niższa, niż wynikałoby z samej raty. +

+

+ Każdy bank liczy zdolność inaczej, więc warto sprawdzić kilka ofert – różnice bywają znaczne. + Zanim złożysz wnioski, uporządkuj finanse: spłać drobne zobowiązania, zamknij nieużywane karty + i limity, zadbaj o pozytywną historię kredytową. To realnie podnosi zdolność. +

+
+ +
+ Policz orientacyjnie +

Skorzystaj z naszych kalkulatorów po prawej stronie, aby oszacować budżet i porównać koszty. Ostateczną zdolność zawsze potwierdza bank na podstawie dokumentów.

+
+
+
+ +
+ 05 +

Rynek pierwotny czy wtórny?

+

+ Rynek pierwotny to zakup od dewelopera – najczęściej nowe, energooszczędne mieszkanie, często jeszcze + w budowie i do wykończenia. Rynek wtórny to zakup od dotychczasowego właściciela – zwykle mieszkanie + gotowe do zamieszkania, w ukształtowanej okolicy. Oba mają swoje zalety; wybór zależy od tego, czy + wolisz nowe i „pod siebie”, czy gotowe i sprawdzone. +

+
+
+

Rynek pierwotny

+
    +
  • Nowy standard i niższe koszty eksploatacji
  • +
  • Możliwość aranżacji od podstaw
  • +
  • Gwarancja i rękojmia dewelopera
  • +
  • Uwaga: często konieczne wykończenie i czekanie na odbiór
  • +
+
+
+

Rynek wtórny

+
    +
  • Gotowe do zamieszkania od zaraz
  • +
  • Sprawdzona lokalizacja i sąsiedztwo
  • +
  • Możliwość zobaczenia realnego stanu
  • +
  • Uwaga: dokładniej sprawdź stan techniczny i prawny
  • +
+
+
+
+ +
+ 06 +

Jak wybrać lokalizację?

+

+ Lokalizacji nie zmienisz remontem, dlatego to jedno z najważniejszych kryteriów. Sprawdź dojazd do + pracy i szkoły, dostęp do komunikacji, sklepów, przychodni i terenów zielonych. Odwiedź okolicę o + różnych porach dnia – rano, po południu i wieczorem – żeby poznać hałas, ruch i realny klimat miejsca. +

+

+ Zajrzyj do miejscowego planu zagospodarowania przestrzennego, żeby wiedzieć, co może powstać w + sąsiedztwie – park czy ruchliwa droga to duża różnica dla komfortu i przyszłej wartości. Zwróć uwagę + na kierunki świata i nasłonecznienie oraz na to, czy okolica się rozwija, czy raczej podupada. +

+
+ +
+ 07 +

Jak oglądać mieszkanie?

+

+ Nie kupuj po jednym oglądaniu i nie oglądaj tylko przy pełnym słońcu. Przyjdź kilka razy, także + wieczorem, i nie bój się zadawać pytań: o wysokość czynszu i co obejmuje, o koszty ogrzewania, o wiek + instalacji, o plany remontowe budynku i o powód sprzedaży. Rób zdjęcia i notatki, bo po obejrzeniu + kilku ofert łatwo je pomylić. +

+

+ Zwróć uwagę na to, czego nie widać na pierwszy rzut oka: ślady wilgoci w narożnikach i pod oknami, + stan okien i drzwi, ciśnienie wody, działanie wentylacji, wygłuszenie między mieszkaniami. Sprawdź też + części wspólne – klatkę, windę, piwnicę i otoczenie budynku mówią wiele o tym, jak zarządzana jest + nieruchomość. +

+
+ +
+ 08 +

Jak sprawdzić stan techniczny?

+

+ Stan techniczny decyduje o tym, ile dołożysz po zakupie. Najważniejsze punkty to instalacja + elektryczna (czy jest bezpieczna i o odpowiedniej mocy), instalacja wodno-kanalizacyjna i piony, + ogrzewanie, wentylacja oraz izolacja i ślady wilgoci lub grzyba. W starszych budynkach zwróć uwagę na + wiek instalacji i okien – ich wymiana bywa kosztowna. +

+
+ +
+ Rozważ fachowca +

Przy droższym zakupie lub starszym mieszkaniu warto zabrać na oględziny osobę znającą się na budownictwie. Koszt takiej konsultacji jest niewielki wobec potencjalnych ukrytych wad.

+
+
+
+ +
+ 09 +

Jak sprawdzić księgę wieczystą?

+

+ Księga wieczysta to dokument, który pokazuje stan prawny nieruchomości. Odpis możesz sprawdzić online, + znając numer księgi. Zwróć uwagę na cztery działy: oznaczenie nieruchomości, właściciela, prawa i + ograniczenia oraz hipoteki. To podstawowa ochrona przed zakupem mieszkania z niespodziankami. +

+
    +
  • Dział I – co to za nieruchomość: adres, metraż, pomieszczenia przynależne.
  • +
  • Dział II – kto jest właścicielem. Musi zgadzać się z osobą sprzedającą.
  • +
  • Dział III – prawa, ograniczenia, służebności i roszczenia osób trzecich.
  • +
  • Dział IV – hipoteki. Jeśli jest, ustal z bankiem i notariuszem sposób jej wykreślenia.
  • +
+
+ +
+ Nie pomijaj tego kroku +

Wpis obciążający nieruchomość może przejść na Ciebie razem z zakupem. Sprawdzenie księgi to kilka minut, a chroni przed poważnymi problemami.

+
+
+
+ +
+ 10 +

Jak negocjować cenę?

+

+ Negocjacje zacznij od wiedzy. Sprawdź ceny podobnych mieszkań w okolicy, zobacz, jak długo oferta jest + wystawiona i poznaj sytuację sprzedającego. Argumentem nie są emocje, tylko fakty: konieczne remonty, + stan instalacji, wysoki czynsz czy niższe ceny porównywalnych lokali. +

+

+ Bądź konkretny i spokojny. Podaj kwotę, którą jesteś gotów zapłacić, wraz z uzasadnieniem, i miej + wyznaczoną granicę, powyżej której rezygnujesz. Gotowość do szybkiej, pewnej transakcji – zwłaszcza za + gotówkę – to często silniejszy argument niż sama presja na obniżkę. +

+
+ +
+ 11 +

Umowa przedwstępna

+

+ Umowa przedwstępna zabezpiecza ustalenia, zanim dojdzie do finalnej transakcji. Określa cenę, termin + zawarcia umowy właściwej, stan mieszkania i warunki, np. uzyskanie kredytu. Można ją zawrzeć zwykłą + formą pisemną lub u notariusza – forma notarialna daje silniejszą ochronę. +

+
+ +
+ Zadatek a zaliczka +

Zadatek działa dyscyplinująco: jeśli od umowy odstąpi kupujący, traci zadatek; jeśli sprzedający – zwraca go w podwójnej wysokości. Zaliczka jest po prostu zwracana. Dla bezpieczeństwa transakcji częściej stosuje się zadatek.

+
+
+
+ +
+ 12 +

Akt notarialny

+

+ Przeniesienie własności mieszkania musi mieć formę aktu notarialnego. To notariusz sprawdza tożsamość + stron, stan prawny nieruchomości i treść umowy, a następnie składa wniosek o wpis nowego właściciela do + księgi wieczystej. Przy akcie płacisz taksę notarialną, podatek oraz opłaty sądowe. +

+

+ Przed podpisaniem dokładnie przeczytaj projekt aktu – ceny, terminy, sposób i datę wydania mieszkania, + zapisy o zadatku i o wykreśleniu ewentualnej hipoteki. Jeśli czegoś nie rozumiesz, pytaj notariusza. + To jego rola, żeby wyjaśnić Ci każdy zapis. +

+
+ +
+ 13 +

Odbiór mieszkania

+

+ Odbiór to moment sprawdzenia, czy dostajesz mieszkanie w umówionym stanie. Spisz protokół zdawczo-odbiorczy: + stany liczników (prąd, woda, gaz, ciepło), liczbę kluczy i pilotów oraz to, co zostaje w mieszkaniu. + Przy nowym lokalu od dewelopera dokładnie sprawdź jakość wykończenia i zgłoś wszystkie usterki w protokole. +

+
+ +
+ Wszystko na piśmie +

Każdą usterkę i każdy stan licznika zapisz w protokole podpisanym przez obie strony. To Twój dowód na wypadek późniejszych rozliczeń i reklamacji.

+
+
+
+ +
+ 14 +

Przekazanie kluczy i formalności po zakupie

+

+ Po odbiorze zostają formalności, o których łatwo zapomnieć w euforii. Przepisz na siebie umowy na prąd, + gaz, wodę i internet, zgłoś zmianę właściciela do wspólnoty lub spółdzielni, rozważ ubezpieczenie + mieszkania i zamelduj się pod nowym adresem. Zachowaj wszystkie dokumenty zakupu w jednym miejscu. +

+
    +
  • Przepisanie liczników i umów z dostawcami mediów.
  • +
  • Zgłoszenie nowego właściciela do zarządcy budynku.
  • +
  • Ubezpieczenie nieruchomości (a przy kredycie – często wymagane).
  • +
  • Zameldowanie oraz aktualizacja adresu w ważnych instytucjach.
  • +
+
+ +
+
+

Checklista – 10 rzeczy do sprawdzenia przed zakupem

+

Wydrukuj lub pobierz w PDF i miej przy sobie podczas oglądania.

+
+
+ {buyingChecklist.map((item, index) => ( + + ))} +
+ +
+ +
+ 15 +

Najczęstsze błędy kupujących

+

Większości kosztownych pomyłek da się uniknąć, jeśli wie się, gdzie najczęściej się pojawiają.

+
+ {buyingMistakes.map((mistake) => ( +
+ +
+ {mistake.title} +

{mistake.text}

+
+
+ ))} +
+
+ +
+ 16 +

Najczęściej zadawane pytania (FAQ)

+
+ {buyingFaq.map((item) => ( +
+ + {item.q} + + +

{item.a}

+
+ ))} +
+
+ +
+ 17 +

Podsumowanie

+

+ Zakup mieszkania to jedna z ważniejszych decyzji finansowych w życiu, ale rozłożona na etapy przestaje + być przytłaczająca. Zacznij od potrzeb i budżetu, świadomie wybierz sposób finansowania, dokładnie + sprawdź stan techniczny i prawny, a formalności potraktuj poważnie. Najlepszą ochroną są wiedza, + spokój i pisemne ustalenia – emocje zostaw na moment odbioru kluczy. +

+
+ +

Ten poradnik ma charakter informacyjny i nie stanowi porady prawnej ani podatkowej. Szczegóły dotyczące podatków, opłat i umów potwierdzaj u notariusza, doradcy kredytowego lub prawnika.

+
+
+ +
+
+

Gotowy, by znaleźć swoje mieszkanie?

+

Przeglądaj aktualne oferty dopasowane do Twoich potrzeb i budżetu.

+
+ +
+
+ + +
+
+ ); +} + +function GuidesPage({ onNavigate }: { onNavigate: (view: View) => void }) { return (
@@ -12329,16 +12991,35 @@ function GuidesPage() {
- {guideTopics.map((topic) => ( -
- - - -

{topic.title}

-

{topic.text}

- {topic.count} -
- ))} + {guideTopics.map((topic) => { + const isBuying = topic.title === 'Kupno mieszkania'; + return ( +
onNavigate('guideBuying') : undefined} + onKeyDown={ + isBuying + ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onNavigate('guideBuying'); + } + } + : undefined + } + > + + + +

{topic.title}

+

{topic.text}

+ {topic.count} +
+ ); + })}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2f93d5c..70b441f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -24858,3 +24858,987 @@ svg { grid-template-columns: 1fr !important; } } + +/* ===== Poradnik: Kupno mieszkania ===== */ +.guide-article { + background: linear-gradient(180deg, #f8fafc 0%, #f3f7fb 100%); + min-height: calc(100vh - 82px); + padding-bottom: 56px; +} + +.guide-hero { + min-height: 340px; + overflow: hidden; + position: relative; +} + +.guide-hero img { + height: 100%; + inset: 0; + object-fit: cover; + object-position: right center; + position: absolute; + width: 100%; +} + +.guide-hero-shade { + background: + linear-gradient(180deg, rgba(248, 250, 252, 0) 72%, #f8fafc 100%), + linear-gradient(90deg, #f8fafc 0%, rgba(248, 250, 252, 0.97) 40%, rgba(248, 250, 252, 0.6) 62%, rgba(248, 250, 252, 0.06) 96%); + inset: 0; + position: absolute; +} + +.guide-hero-inner { + min-height: 340px; + padding: 30px 48px 46px; + position: relative; + z-index: 1; +} + +.guide-back { + align-items: center; + background: rgba(255, 255, 255, 0.72); + border: 1px solid #dce4ec; + border-radius: 999px; + color: #14223a; + cursor: pointer; + display: inline-flex; + font-size: 12px; + font-weight: 850; + gap: 6px; + padding: 7px 14px 7px 10px; +} + +.guide-back svg { + height: 15px; + transform: rotate(180deg); + width: 15px; +} + +.guide-hero-badge { + align-items: center; + background: #0b9f5c; + border-radius: 999px; + color: #ffffff; + display: inline-flex; + font-size: 12px; + font-weight: 900; + gap: 7px; + margin-top: 20px; + padding: 7px 14px; +} + +.guide-hero-badge svg { + height: 15px; + width: 15px; +} + +.guide-hero h1 { + color: #14223a; + font-size: clamp(38px, 3.8vw, 60px); + line-height: 1.02; + margin: 16px 0 0; + max-width: 720px; +} + +.guide-hero-lead { + color: #4a5766; + font-size: 17px; + font-weight: 650; + line-height: 1.5; + margin: 16px 0 0; + max-width: 680px; +} + +.guide-hero-chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.guide-hero-chips span { + align-items: center; + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + color: #33465c; + display: inline-flex; + font-size: 12px; + font-weight: 800; + gap: 7px; + padding: 8px 12px; +} + +.guide-hero-chips svg { + color: #0b9f5c; + height: 15px; + width: 15px; +} + +.guide-article-layout { + align-items: start; + display: grid; + gap: 28px; + grid-template-columns: 232px minmax(0, 1fr) 312px; + margin-top: 26px; +} + +/* Spis treści */ +.guide-toc { + position: sticky; + top: 20px; +} + +.guide-toc-inner { + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 18px 16px; +} + +.guide-toc-inner h2 { + color: #14223a; + font-size: 15px; + margin: 0 0 12px; +} + +.guide-toc-inner ol { + counter-reset: none; + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; +} + +.guide-toc-inner li button { + align-items: center; + background: transparent; + border: 0; + border-radius: 6px; + color: #46586c; + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 700; + gap: 10px; + padding: 7px 8px; + text-align: left; + width: 100%; +} + +.guide-toc-inner li button span { + align-items: center; + background: #eef3f8; + border-radius: 6px; + color: #0b9f5c; + display: grid; + flex-shrink: 0; + font-size: 11px; + font-weight: 900; + height: 22px; + place-items: center; + width: 22px; +} + +.guide-toc-inner li button:hover { + background: #f2f7fb; + color: #14223a; +} + +/* Blok główny */ +.guide-body { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.guide-block-head h2 { + color: #14223a; + font-size: 22px; + margin: 0; +} + +.guide-block-head p { + color: #607085; + font-size: 14px; + font-weight: 650; + margin: 6px 0 0; +} + +/* Proces 12 kroków */ +.guide-process { + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + padding: 22px; +} + +.guide-process-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-top: 18px; +} + +.guide-process-card { + background: #f8fafc; + border: 1px solid #e6edf3; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 12px; + position: relative; +} + +.guide-process-num { + align-items: center; + background: #0b9f5c; + border-radius: 6px; + color: #ffffff; + display: grid; + font-size: 12px; + font-weight: 900; + height: 24px; + place-items: center; + width: 24px; +} + +.guide-process-icon { + color: #13a360; +} + +.guide-process-icon svg { + height: 22px; + width: 22px; +} + +.guide-process-card strong { + color: #14223a; + font-size: 13px; + font-weight: 900; + line-height: 1.25; +} + +.guide-process-card small { + color: #64748b; + font-size: 11px; + font-weight: 650; + line-height: 1.35; +} + +/* Rozdziały */ +.guide-chapter { + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + padding: 26px 28px; + position: relative; + scroll-margin-top: 20px; +} + +.guide-chapter-index { + color: #d7e6dc; + font-size: 40px; + font-weight: 950; + letter-spacing: -1px; + line-height: 1; +} + +.guide-chapter h2 { + color: #14223a; + font-size: 24px; + margin: 8px 0 14px; +} + +.guide-chapter p { + color: #3f4d5e; + font-size: 15px; + font-weight: 550; + line-height: 1.62; + margin: 0 0 14px; +} + +.guide-chapter p:last-child { + margin-bottom: 0; +} + +.guide-list { + color: #3f4d5e; + display: flex; + flex-direction: column; + gap: 9px; + list-style: none; + margin: 4px 0 14px; + padding: 0; +} + +.guide-list li { + font-size: 15px; + font-weight: 550; + line-height: 1.5; + padding-left: 22px; + position: relative; +} + +.guide-list li::before { + background: #0b9f5c; + border-radius: 50%; + content: ""; + height: 7px; + left: 4px; + position: absolute; + top: 9px; + width: 7px; +} + +.guide-list li strong { + color: #14223a; +} + +/* Callouty */ +.guide-callout { + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: 24px minmax(0, 1fr); + margin-top: 6px; + padding: 16px 18px; +} + +.guide-callout svg { + height: 22px; + margin-top: 2px; + width: 22px; +} + +.guide-callout strong { + display: block; + font-size: 14px; + font-weight: 900; + margin-bottom: 4px; +} + +.guide-callout p { + font-size: 14px !important; + margin: 0 !important; +} + +.guide-callout-tip { + background: #edf8f1; + border: 1px solid #c6e8d4; +} + +.guide-callout-tip svg, +.guide-callout-tip strong { + color: #0b7a47; +} + +.guide-callout-warn { + background: #fef6ec; + border: 1px solid #f6dcb6; +} + +.guide-callout-warn svg, +.guide-callout-warn strong { + color: #b9791b; +} + +.guide-callout-info { + background: #eef4fd; + border: 1px solid #cfe0f7; +} + +.guide-callout-info svg, +.guide-callout-info strong { + color: #2c62c6; +} + +.guide-callout p, +.guide-callout-tip p, +.guide-callout-warn p, +.guide-callout-info p { + color: #3f4d5e; +} + +/* Tabela kosztów */ +.guide-table-wrap { + border: 1px solid #e2e9f0; + border-radius: 8px; + margin: 4px 0 16px; + overflow-x: auto; +} + +.guide-table { + border-collapse: collapse; + min-width: 560px; + width: 100%; +} + +.guide-table th { + background: #f4f8fb; + border-bottom: 1px solid #e2e9f0; + color: #52627a; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.03em; + padding: 11px 14px; + text-align: left; + text-transform: uppercase; +} + +.guide-table td { + border-bottom: 1px solid #eef2f6; + color: #3f4d5e; + font-size: 13px; + font-weight: 600; + padding: 12px 14px; + vertical-align: top; +} + +.guide-table tr:last-child td { + border-bottom: 0; +} + +.guide-table td strong { + color: #14223a; +} + +/* Dwie kolumny (za/przeciw, rynki) */ +.guide-two-col { + display: grid; + gap: 14px; + grid-template-columns: 1fr 1fr; + margin-top: 4px; +} + +.guide-pro, +.guide-con, +.guide-market { + border: 1px solid #e2e9f0; + border-radius: 8px; + padding: 16px 18px; +} + +.guide-pro { + background: #edf8f1; + border-color: #c6e8d4; +} + +.guide-con { + background: #fbfdff; +} + +.guide-market { + background: #f8fafc; +} + +.guide-pro h4, +.guide-con h4, +.guide-market h4 { + align-items: center; + color: #14223a; + display: flex; + font-size: 15px; + gap: 8px; + margin: 0 0 12px; +} + +.guide-pro h4 svg, +.guide-market h4 svg { + color: #0b9f5c; + height: 17px; + width: 17px; +} + +.guide-con h4 svg { + color: #2c62c6; + height: 17px; + width: 17px; +} + +.guide-pro ul, +.guide-con ul, +.guide-market ul { + color: #3f4d5e; + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; +} + +.guide-pro li, +.guide-con li, +.guide-market li { + font-size: 13px; + font-weight: 600; + line-height: 1.45; + padding-left: 18px; + position: relative; +} + +.guide-pro li::before, +.guide-con li::before, +.guide-market li::before { + color: #0b9f5c; + content: "›"; + font-weight: 900; + left: 3px; + position: absolute; + top: 0; +} + +/* Checklista */ +.guide-checklist { + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + padding: 24px 26px; + scroll-margin-top: 20px; +} + +.guide-checklist-grid { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; + margin: 16px 0 18px; +} + +.guide-checklist-item { + align-items: flex-start; + background: #f8fafc; + border: 1px solid #e6edf3; + border-radius: 8px; + color: #33465c; + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 650; + gap: 10px; + line-height: 1.4; + padding: 12px 14px; +} + +.guide-checklist-item input { + display: none; +} + +.guide-check-box { + align-items: center; + background: #ffffff; + border: 1.5px solid #cdd7e1; + border-radius: 5px; + color: transparent; + display: grid; + flex-shrink: 0; + height: 20px; + place-items: center; + width: 20px; +} + +.guide-check-box svg { + height: 13px; + width: 13px; +} + +.guide-checklist-item input:checked ~ .guide-check-box { + background: #0b9f5c; + border-color: #0b9f5c; + color: #ffffff; +} + +.guide-checklist-item input:checked ~ span:last-child { + color: #8a97a6; + text-decoration: line-through; +} + +.guide-download-btn, +.guide-cta button { + align-items: center; + background: #0b9f5c; + border: 0; + border-radius: 8px; + color: #ffffff; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 900; + gap: 8px; + padding: 12px 20px; +} + +.guide-download-btn svg { + height: 17px; + width: 17px; +} + +.guide-download-btn:hover, +.guide-cta button:hover { + background: #0a8b50; +} + +/* Najczęstsze błędy */ +.guide-mistakes { + display: grid; + gap: 12px; + grid-template-columns: 1fr 1fr; + margin-top: 4px; +} + +.guide-mistake { + background: #fef6ec; + border: 1px solid #f6dcb6; + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: 34px minmax(0, 1fr); + padding: 14px 16px; +} + +.guide-mistake-icon { + align-items: center; + background: #fbe4c4; + border-radius: 8px; + color: #b9791b; + display: grid; + height: 34px; + place-items: center; + width: 34px; +} + +.guide-mistake-icon svg { + height: 18px; + width: 18px; +} + +.guide-mistake strong { + color: #14223a; + display: block; + font-size: 14px; + font-weight: 900; + margin-bottom: 4px; +} + +.guide-mistake p { + color: #4a5766 !important; + font-size: 13px !important; + font-weight: 600 !important; + line-height: 1.45 !important; + margin: 0 !important; +} + +/* FAQ */ +.guide-faq { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.guide-faq-item { + background: #f8fafc; + border: 1px solid #e6edf3; + border-radius: 8px; + padding: 0 16px; +} + +.guide-faq-item summary { + align-items: center; + color: #14223a; + cursor: pointer; + display: flex; + font-size: 15px; + font-weight: 850; + gap: 12px; + justify-content: space-between; + list-style: none; + padding: 15px 0; +} + +.guide-faq-item summary::-webkit-details-marker { + display: none; +} + +.guide-faq-item summary svg { + color: #0b9f5c; + flex-shrink: 0; + height: 18px; + transition: transform 0.2s ease; + width: 18px; +} + +.guide-faq-item[open] summary svg { + transform: rotate(180deg); +} + +.guide-faq-item p { + color: #4a5766 !important; + font-size: 14px !important; + font-weight: 550 !important; + line-height: 1.6 !important; + margin: 0 !important; + padding: 0 0 16px !important; +} + +/* Podsumowanie + CTA */ +.guide-summary { + background: #f5faf7; + border-color: #cfe9da; +} + +.guide-summary-note { + align-items: flex-start; + background: #ffffff; + border: 1px solid #e2e9f0; + border-radius: 8px; + display: grid; + gap: 10px; + grid-template-columns: 20px minmax(0, 1fr); + margin-top: 8px; + padding: 14px 16px; +} + +.guide-summary-note svg { + color: #8793a3; + height: 18px; + width: 18px; +} + +.guide-summary-note p { + color: #64748b !important; + font-size: 13px !important; + font-weight: 600 !important; + line-height: 1.5 !important; + margin: 0 !important; +} + +.guide-cta { + align-items: center; + background: linear-gradient(120deg, #0b9f5c 0%, #0c7f4c 100%); + border-radius: 10px; + color: #ffffff; + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: space-between; + padding: 26px 30px; +} + +.guide-cta h2 { + color: #ffffff; + font-size: 22px; + margin: 0; +} + +.guide-cta p { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-weight: 650; + margin: 6px 0 0; +} + +.guide-cta button { + background: #ffffff; + color: #0b7a47; +} + +.guide-cta button:hover { + background: #f0f5f2; +} + +.guide-cta button svg { + height: 16px; + width: 16px; +} + +/* Prawy sidebar */ +.guide-aside { + display: flex; + flex-direction: column; + gap: 16px; + position: sticky; + top: 20px; +} + +.guide-aside-card { + background: #ffffff; + border: 1px solid #dce4ec; + border-radius: 8px; + padding: 18px; +} + +.guide-aside-card h3 { + align-items: center; + color: #14223a; + display: flex; + font-size: 15px; + gap: 8px; + margin: 0 0 14px; +} + +.guide-aside-card h3 svg { + color: #0b9f5c; + height: 17px; + width: 17px; +} + +.guide-aside-list, +.guide-aside-downloads { + display: flex; + flex-direction: column; + gap: 8px; +} + +.guide-aside-list button, +.guide-aside-downloads button { + align-items: center; + background: #f8fafc; + border: 1px solid #e6edf3; + border-radius: 8px; + color: #14223a; + cursor: pointer; + display: flex; + gap: 10px; + padding: 11px 12px; + text-align: left; + width: 100%; +} + +.guide-aside-list button:hover, +.guide-aside-downloads button:hover { + background: #eef4f9; + border-color: #d3e0ea; +} + +.guide-aside-list button > span, +.guide-aside-downloads button > span { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; + min-width: 0; +} + +.guide-aside-list strong, +.guide-aside-downloads strong { + font-size: 13px; + font-weight: 850; +} + +.guide-aside-list small, +.guide-aside-downloads small { + color: #64748b; + font-size: 11px; + font-weight: 650; +} + +.guide-aside-list button > svg { + color: #9aa5b4; + flex-shrink: 0; + height: 16px; + transform: rotate(-90deg); + width: 16px; +} + +.guide-aside-downloads button > svg:first-child { + color: #0b9f5c; + flex-shrink: 0; + height: 20px; + width: 20px; +} + +.guide-aside-downloads button > svg:last-child { + color: #9aa5b4; + flex-shrink: 0; + height: 16px; + width: 16px; +} + +.guide-aside-mistakes ul { + display: flex; + flex-direction: column; + gap: 9px; + list-style: none; + margin: 0; + padding: 0; +} + +.guide-aside-mistakes li { + align-items: center; + color: #46586c; + display: flex; + font-size: 13px; + font-weight: 700; + gap: 9px; + line-height: 1.35; +} + +.guide-aside-mistakes li svg { + color: #d99a3a; + flex-shrink: 0; + height: 15px; + width: 15px; +} + +@media (max-width: 1200px) { + .guide-article-layout { + grid-template-columns: minmax(0, 1fr) 300px; + } + + .guide-toc { + display: none; + } + + .guide-process-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .guide-hero-inner { + padding: 24px 20px 34px; + } + + .guide-article-layout { + grid-template-columns: minmax(0, 1fr); + padding: 0 20px; + } + + .guide-aside { + position: static; + } + + .guide-process-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .guide-two-col, + .guide-checklist-grid, + .guide-mistakes { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .guide-chapter { + padding: 20px 18px; + } + + .guide-process-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .guide-cta { + flex-direction: column; + align-items: flex-start; + } +} From 12fcb60b4c178a53a790fc6e88157416333bdd6f Mon Sep 17 00:00:00 2001 From: pascal Date: Tue, 21 Jul 2026 17:02:25 +0200 Subject: [PATCH 02/10] =?UTF-8?q?Kalkulator=20zdolno=C5=9Bci,=20integracja?= =?UTF-8?q?=20Moich=20ofert=20i=20system=20powiadomie=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kalkulator zdolności kredytowej: nowy widok z live-obliczeniami (rata annuitetowa/malejąca, DTI/DSTI, rekomendacje, donut), wpięty w poradnik, panel i stronę główną; pasek boczny konta dla zalogowanych. - Moje oferty: prawdziwe ogłoszenia (/listings/mine) scalone z listą, realne akcje Wstrzymaj/Wznów (PATCH /status, nowy status PAUSED), Usuń (DELETE) z kontrolą właściciela; realne wyświetlenia (viewsCount) i zapisane z ulubionych. Fix CHECK constraintu enuma w SchemaFixer. - System powiadomień: encja Notification + serwis + kontroler + SSE realtime (token w query param w JwtAuthFilter). Podpięte źródła serwerowe (wiadomości, zmiana statusu ogłoszenia, zdarzenia konta) i klienckie (ulubione, spadek ceny, spotkania, weryfikacja telefonu). Centralny NotificationsProvider; dzwonek i strona bez zmian designu, klikalne z deep-linkami i oznaczaniem jako przeczytane. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../polskalokalnie/admin/AdminController.java | 24 +- .../pl/polskalokalnie/auth/JwtAuthFilter.java | 18 +- .../pl/polskalokalnie/config/SchemaFixer.java | 10 + .../listing/ListingController.java | 18 + .../listing/ListingResponse.java | 2 + .../listing/ListingService.java | 66 +- .../polskalokalnie/listing/ListingStatus.java | 3 +- .../message/MessageService.java | 35 +- .../CreateNotificationRequest.java | 19 + .../notification/Notification.java | 128 +++ .../notification/NotificationController.java | 71 ++ .../notification/NotificationRepository.java | 20 + .../notification/NotificationResponse.java | 27 + .../notification/NotificationService.java | 143 +++ frontend/src/App.tsx | 857 +++++++++++++++--- frontend/src/main.tsx | 5 +- frontend/src/notifications.tsx | 178 ++++ frontend/src/styles.css | 802 ++++++++++++++++ 18 files changed, 2314 insertions(+), 112 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/Notification.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java create mode 100644 frontend/src/notifications.tsx diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 2967327..ee2b6d6 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus; import pl.polskalokalnie.message.MessageResponse; import pl.polskalokalnie.message.MessageService; import pl.polskalokalnie.message.SendMessageRequest; +import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.report.ListingReportResponse; import pl.polskalokalnie.report.ListingReportService; import pl.polskalokalnie.report.ResolveListingReportRequest; @@ -52,13 +53,15 @@ public class AdminController { private final TextModerationService textModerationService; private final AdminStatsService adminStatsService; private final PasswordEncoder passwordEncoder; + private final NotificationService notificationService; public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, MessageService messageService, TextModerationService textModerationService, AdminStatsService adminStatsService, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + NotificationService notificationService) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; @@ -67,6 +70,7 @@ public class AdminController { this.textModerationService = textModerationService; this.adminStatsService = adminStatsService; this.passwordEncoder = passwordEncoder; + this.notificationService = notificationService; } // --- Uzytkownicy --- @@ -93,7 +97,12 @@ public class AdminController { public UserResponse verify(@PathVariable Long id) { AppUser user = requireUser(id); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "check", + "Konto zweryfikowane", + "Twoje konto zostało zweryfikowane przez administratora. Masz dostęp do wszystkich funkcji serwisu.", + "accountSecurity", "account-verified"); + return UserResponse.from(saved); } @PostMapping("/users/{id}/grant-admin") @@ -104,7 +113,12 @@ public class AdminController { } user.setRole(Role.ADMIN); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "shield", + "Nadano uprawnienia administratora", + "Twoje konto otrzymało rolę administratora.", + "account", null); + return UserResponse.from(saved); } @PostMapping("/users/{id}/revoke-admin") @@ -137,6 +151,10 @@ public class AdminController { String temporaryPassword = generateTemporaryPassword(); user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); userRepository.save(user); + notificationService.create(user.getEmail(), "system", "lock", + "Hasło zostało zresetowane", + "Administrator zresetował hasło do Twojego konta. Zaloguj się nowym hasłem i ustaw własne w ustawieniach bezpieczeństwa.", + "accountSecurity", null); return new ResetPasswordResponse(temporaryPassword); } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java index 6bb9446..28a847f 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java @@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { - String header = request.getHeader("Authorization"); - if (header != null && header.startsWith("Bearer ")) { - String token = header.substring(7); + String token = resolveToken(request); + if (token != null) { try { Claims claims = jwtService.parse(token); String email = claims.getSubject(); @@ -48,4 +47,17 @@ public class JwtAuthFilter extends OncePerRequestFilter { } filterChain.doFilter(request, response); } + + // Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token. + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + String param = request.getParameter("access_token"); + if (param != null && !param.isBlank()) { + return param; + } + return null; + } } diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index 705aaa7..e88672b 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -48,6 +48,16 @@ public class SchemaFixer { "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" ); + // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. + // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). + jdbcTemplate.execute( + "ALTER TABLE property_listings DROP CONSTRAINT IF EXISTS property_listings_status_check" + ); + jdbcTemplate.execute( + "ALTER TABLE property_listings ADD CONSTRAINT property_listings_status_check " + + "CHECK (status IN ('PENDING','APPROVED','REJECTED','PAUSED'))" + ); + String dataUrlType = jdbcTemplate.query( "SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'", rs -> rs.next() ? rs.getString(1) : null diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 26c8a4e..020ca46 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -5,7 +5,9 @@ import java.net.URI; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -55,4 +57,20 @@ public class ListingController { .created(URI.create("/api/listings/" + response.id())) .body(response); } + + // Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED). + @PatchMapping("/{id}/status") + public ListingResponse updateStatus( + @PathVariable Long id, + @RequestParam ListingStatus status, + Authentication authentication + ) { + return listingService.setOwnStatus(id, authentication.getName(), status); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, Authentication authentication) { + listingService.deleteOwn(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 7b30a4d..5da8292 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -26,6 +26,7 @@ public record ListingResponse( String coverPhoto, String ownerEmail, ListingStatus status, + Long viewsCount, Instant createdAt ) { public static ListingResponse from(PropertyListing listing) { @@ -48,6 +49,7 @@ public record ListingResponse( listing.getCoverPhoto(), listing.getOwnerEmail(), listing.getStatus(), + listing.getViewsCount(), listing.getCreatedAt() ); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index a8c740f..25b58ee 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.notification.NotificationService; @Service public class ListingService { @@ -18,10 +19,13 @@ public class ListingService { private final ListingRepository listingRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; - public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) { + public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, + NotificationService notificationService) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public List search(String city, OfferType offerType, PropertyType propertyType) { @@ -56,6 +60,39 @@ public class ListingService { .toList(); } + // --- Operacje wlasciciela na wlasnym ogloszeniu --- + + // Wstrzymanie/wznowienie widocznosci wlasnego ogloszenia (tylko miedzy APPROVED i PAUSED). + @Transactional + public ListingResponse setOwnStatus(Long id, String ownerEmail, ListingStatus status) { + if (status != ListingStatus.APPROVED && status != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieobslugiwana zmiana statusu"); + } + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + if (listing.getStatus() != ListingStatus.APPROVED && listing.getStatus() != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic"); + } + listing.setStatus(status); + return ListingResponse.from(listingRepository.save(listing)); + } + + @Transactional + public void deleteOwn(Long id, String ownerEmail) { + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + listingRepository.delete(listing); + } + + private void requireOwner(PropertyListing listing, String ownerEmail) { + if (ownerEmail == null || listing.getOwnerEmail() == null + || !ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak uprawnien do tego ogloszenia"); + } + } + @Transactional public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) { textModerationService.validateOrThrow( @@ -123,7 +160,32 @@ public class ListingService { PropertyListing listing = listingRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); listing.setStatus(status); - return ListingResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + notifyOwnerStatusChange(saved, status); + return ListingResponse.from(saved); + } + + // Powiadomienie dla wlasciciela po decyzji moderacji (approve/reject). + private void notifyOwnerStatusChange(PropertyListing listing, ListingStatus status) { + if (listing.getOwnerEmail() == null) { + return; + } + String title; + String body; + String icon; + if (status == ListingStatus.APPROVED) { + title = "Ogłoszenie opublikowane"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało zatwierdzone i jest już widoczne w serwisie."; + icon = "check"; + } else if (status == ListingStatus.REJECTED) { + title = "Ogłoszenie odrzucone"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało odrzucone przez moderację."; + icon = "warning"; + } else { + return; + } + notificationService.create(listing.getOwnerEmail(), "listing", icon, title, body, + "listing:" + listing.getId(), "status-" + listing.getId() + "-" + status); } public void delete(Long id) { diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java index 8a0c0d5..5e03db3 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java @@ -3,5 +3,6 @@ package pl.polskalokalnie.listing; public enum ListingStatus { PENDING, APPROVED, - REJECTED + REJECTED, + PAUSED } diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java index 1faefe8..f3de50c 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.UserRepository; @@ -15,15 +16,18 @@ public class MessageService { private final MessageRepository messageRepository; private final UserRepository userRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; public MessageService( MessageRepository messageRepository, UserRepository userRepository, - TextModerationService textModerationService + TextModerationService textModerationService, + NotificationService notificationService ) { this.messageRepository = messageRepository; this.userRepository = userRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public AppUser resolveAdmin() { @@ -46,9 +50,38 @@ public class MessageService { message.setRecipientId(recipientId); message.setContent(content.trim()); Message saved = messageRepository.save(message); + + notifyRecipient(senderId, recipientId, saved.getContent()); + return MessageResponse.from(saved, senderId); } + // Powiadomienie dla odbiorcy kazdej wiadomosci: admin->user ("Wiadomosc od obslugi"), + // user->admin traktujemy jako zapytanie/wiadomosc do obslugi. + private void notifyRecipient(Long senderId, Long recipientId, String content) { + AppUser recipient = userRepository.findById(recipientId).orElse(null); + if (recipient == null) { + return; + } + AppUser sender = userRepository.findById(senderId).orElse(null); + String senderName = sender != null && sender.getFullName() != null && !sender.getFullName().isBlank() + ? sender.getFullName() + : "użytkownika"; + String preview = content.length() > 90 ? content.substring(0, 90) + "…" : content; + + if (recipient.getRole() == Role.ADMIN) { + notificationService.create(recipient.getEmail(), "messages", "message", + "Nowa wiadomość od użytkownika", + senderName + ": " + preview, + "messages", null); + } else { + notificationService.create(recipient.getEmail(), "messages", "mail", + "Wiadomość od obsługi Mieszko", + preview, + "messages:admin", null); + } + } + public long countUnreadFrom(Long recipientId, Long senderId) { return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); } diff --git a/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java new file mode 100644 index 0000000..b068e9c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Zadanie utworzenia powiadomienia dla zalogowanego uzytkownika (zdarzenia liczone po stronie klienta: + * alerty cenowe, spotkania, ulubione, weryfikacja telefonu, dopasowane oferty). + * userEmail NIE jest przyjmowany z ciala - serwer ustawia go z tokenu. + */ +public record CreateNotificationRequest( + @NotBlank @Size(max = 20) String category, + @Size(max = 40) String icon, + @NotBlank @Size(max = 200) String title, + @Size(max = 600) String body, + @Size(max = 200) String link, + @Size(max = 160) String dedupeKey +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/Notification.java b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java new file mode 100644 index 0000000..5c1c028 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java @@ -0,0 +1,128 @@ +package pl.polskalokalnie.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notifications_user", columnList = "userEmail"), + @Index(name = "idx_notifications_user_dedupe", columnList = "userEmail,dedupeKey") +}) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userEmail; + + // Kategoria zgodna z frontendem: listing | messages | system. + @Column(nullable = false, length = 20) + private String category; + + @Column(nullable = false, length = 40) + private String icon; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 600) + private String body; + + // Deskryptor deep-linku, np. "listing:12", "messages:admin", "priceAlerts", "meetings". + @Column(length = 200) + private String link; + + // Klucz idempotencji - blokuje duplikaty tego samego zdarzenia (np. jeden spadek ceny). + @Column(length = 160) + private String dedupeKey; + + @Column(nullable = false) + private boolean read = false; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + public Long getId() { + return id; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getDedupeKey() { + return dedupeKey; + } + + public void setDedupeKey(String dedupeKey) { + this.dedupeKey = dedupeKey; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java new file mode 100644 index 0000000..709ae73 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java @@ -0,0 +1,71 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + public NotificationController(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @GetMapping + public List list(Authentication authentication) { + return notificationService.listOwn(authentication.getName()); + } + + @GetMapping("/unread-count") + public UnreadCountResponse unreadCount(Authentication authentication) { + return new UnreadCountResponse(notificationService.unreadCount(authentication.getName())); + } + + @PostMapping("/{id}/read") + public ResponseEntity markRead(@PathVariable Long id, Authentication authentication) { + notificationService.markRead(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/read-all") + public ResponseEntity markAllRead(Authentication authentication) { + notificationService.markAllRead(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 + public NotificationResponse create(@Valid @RequestBody CreateNotificationRequest request, Authentication authentication) { + Notification created = notificationService.create( + authentication.getName(), + request.category(), + request.icon(), + request.title(), + request.body(), + request.link(), + request.dedupeKey() + ); + return created == null ? null : NotificationResponse.from(created); + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(Authentication authentication) { + return notificationService.subscribe(authentication.getName()); + } + + public record UnreadCountResponse(long count) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java new file mode 100644 index 0000000..018a293 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java @@ -0,0 +1,20 @@ +package pl.polskalokalnie.notification; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationRepository extends JpaRepository { + + List findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail); + + long countByUserEmailIgnoreCaseAndReadFalse(String userEmail); + + boolean existsByUserEmailIgnoreCaseAndDedupeKey(String userEmail, String dedupeKey); + + @Modifying + @Query("update Notification n set n.read = true where lower(n.userEmail) = lower(:email) and n.read = false") + int markAllRead(@Param("email") String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java new file mode 100644 index 0000000..03cefc6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java @@ -0,0 +1,27 @@ +package pl.polskalokalnie.notification; + +import java.time.Instant; + +public record NotificationResponse( + Long id, + String category, + String icon, + String title, + String body, + String link, + boolean read, + Instant createdAt +) { + public static NotificationResponse from(Notification n) { + return new NotificationResponse( + n.getId(), + n.getCategory(), + n.getIcon(), + n.getTitle(), + n.getBody(), + n.getLink(), + n.isRead(), + n.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java new file mode 100644 index 0000000..87615b6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java @@ -0,0 +1,143 @@ +package pl.polskalokalnie.notification; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Centralny serwis powiadomien. Zapisuje powiadomienia per uzytkownik i wypycha je w czasie + * rzeczywistym przez SSE. Inne moduly (wiadomosci, ogloszenia, administracja) wolaja {@link #create}. + */ +@Service +public class NotificationService { + + private static final long SSE_TIMEOUT_MS = 30L * 60L * 1000L; + + private final NotificationRepository repository; + + // email uzytkownika -> otwarte polaczenia SSE (moze byc kilka kart/urzadzen). + private final Map> emitters = new ConcurrentHashMap<>(); + + public NotificationService(NotificationRepository repository) { + this.repository = repository; + } + + @Transactional + public Notification create(String userEmail, String category, String icon, String title, + String body, String link, String dedupeKey) { + if (userEmail == null || userEmail.isBlank() || title == null || title.isBlank()) { + return null; + } + if (dedupeKey != null && !dedupeKey.isBlank() + && repository.existsByUserEmailIgnoreCaseAndDedupeKey(userEmail, dedupeKey)) { + return null; + } + Notification notification = new Notification(); + notification.setUserEmail(userEmail); + notification.setCategory(normalizeCategory(category)); + notification.setIcon(icon == null || icon.isBlank() ? defaultIcon(category) : icon); + notification.setTitle(title.trim()); + notification.setBody(body == null ? null : body.trim()); + notification.setLink(link == null || link.isBlank() ? null : link.trim()); + notification.setDedupeKey(dedupeKey == null || dedupeKey.isBlank() ? null : dedupeKey.trim()); + + Notification saved = repository.save(notification); + push(userEmail, NotificationResponse.from(saved)); + return saved; + } + + public List listOwn(String email) { + return repository.findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(email).stream() + .map(NotificationResponse::from) + .toList(); + } + + public long unreadCount(String email) { + return repository.countByUserEmailIgnoreCaseAndReadFalse(email); + } + + @Transactional + public void markRead(Long id, String email) { + Notification notification = repository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Powiadomienie nie istnieje")); + if (email == null || !email.equalsIgnoreCase(notification.getUserEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do tego powiadomienia"); + } + if (!notification.isRead()) { + notification.setRead(true); + repository.save(notification); + } + } + + @Transactional + public void markAllRead(String email) { + repository.markAllRead(email); + } + + // --- SSE --- + + public SseEmitter subscribe(String email) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + CopyOnWriteArrayList list = emitters.computeIfAbsent(email, key -> new CopyOnWriteArrayList<>()); + list.add(emitter); + + emitter.onCompletion(() -> remove(email, emitter)); + emitter.onTimeout(() -> remove(email, emitter)); + emitter.onError(error -> remove(email, emitter)); + + try { + emitter.send(SseEmitter.event().name("ready").data("ok")); + } catch (IOException ex) { + remove(email, emitter); + } + return emitter; + } + + private void push(String email, NotificationResponse payload) { + CopyOnWriteArrayList list = emitters.get(email); + if (list == null) { + return; + } + for (SseEmitter emitter : list) { + try { + emitter.send(SseEmitter.event().name("notification").data(payload)); + } catch (Exception ex) { + remove(email, emitter); + } + } + } + + private void remove(String email, SseEmitter emitter) { + CopyOnWriteArrayList list = emitters.get(email); + if (list != null) { + list.remove(emitter); + if (list.isEmpty()) { + emitters.remove(email, list); + } + } + } + + private String normalizeCategory(String category) { + if ("listing".equals(category) || "messages".equals(category) || "system".equals(category)) { + return category; + } + return "system"; + } + + private String defaultIcon(String category) { + if ("messages".equals(category)) { + return "message"; + } + if ("listing".equals(category)) { + return "house"; + } + return "bell"; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e38c3db..05528d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,8 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as import './styles.css'; import { apiFetch, useAuth } from './auth'; import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; +import { useNotifications, toDisplayNotifications } from './notifications'; +import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; @@ -9,7 +11,7 @@ const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const SMS_VERIFICATION_CODE = '123456'; -type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'guideBuying' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; +type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'guideBuying' | 'creditCalculator' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; type MessageThreadListing = { listingId: number; @@ -33,7 +35,7 @@ type MessageThreadTarget = { export type ApiOfferType = 'SALE' | 'RENT'; export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; -export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; +export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED'; // Lekka reprezentacja z list (Kupuje/Wynajmuje, moje ogloszenia) - bez pelnej galerii. export type ApiListingSummary = { @@ -55,6 +57,7 @@ export type ApiListingSummary = { coverPhoto: string | null; ownerEmail: string | null; status: ApiListingStatus; + viewsCount: number; createdAt: string; }; @@ -2646,6 +2649,7 @@ function App() { const translationTimerRef = useRef(null); const translationInFlightRef = useRef(false); const filters = useFilterState(); + const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); const clearLoginHash = () => { @@ -2685,6 +2689,17 @@ function App() { clearLoginHash(); }; + // Z kalkulatora zdolności: ustaw maksymalną cenę ofert na obliczony budżet i przejdź do wyszukiwarki. + const handleSearchWithBudget = (maxPrice: number) => { + if (maxPrice > 0) { + filters.setCity(''); + filters.setCityInput(''); + filters.setPriceMin(''); + filters.setPriceMax(String(Math.round(maxPrice))); + } + navigate('buy'); + }; + useEffect(() => { const onHashChange = () => { if (window.location.hash === '#login') { @@ -3038,6 +3053,48 @@ function App() { navigate('messages'); }; + // Klik w powiadomienie: oznacz jako przeczytane (znika z dzwonka, licznik -1) i przejdź do właściwego miejsca. + const handleOpenNotification = (notification: ServerNotification | DisplayNotification) => { + notificationsCenter.markRead(notification.id); + const link = notification.link; + if (!link) { + navigate('notifications'); + return; + } + if (link.startsWith('listing:')) { + const id = Number(link.slice('listing:'.length)); + if (Number.isFinite(id) && id > 0) { + openListing(id); + return; + } + } + if (link.startsWith('messages')) { + navigate('messages'); + return; + } + if (link === 'priceAlerts') { + navigate('accountPriceAlerts'); + return; + } + if (link === 'meetings') { + navigate('accountMeetings'); + return; + } + if (link === 'savedSearches') { + navigate('accountSearches'); + return; + } + if (link === 'favorites') { + navigate('favorites'); + return; + } + if (link === 'accountSecurity' || link === 'account' || link === 'accountListings') { + navigate(link as View); + return; + } + navigate('notifications'); + }; + const toggleSavedSearch = (mode: SavedSearchMode) => { if (!user) { navigate('login'); @@ -3079,6 +3136,17 @@ function App() { const toggleListingFavorite = (listing: ApiListingDetail) => { const listingKey = favoriteListingKey(listing.id); + const alreadyFavorite = favoriteListingsState.some((item) => item.id === listingKey); + if (!alreadyFavorite) { + notificationsCenter.notify({ + category: 'listing', + icon: 'heart', + title: 'Ogłoszenie zapisane', + body: `„${listing.title}” zostało zapisane do ulubionych.`, + link: `listing:${listing.id}`, + dedupeKey: `favorite-${listing.id}`, + }); + } setFavoriteListingsState((current) => { const exists = current.some((item) => item.id === listingKey); if (exists) { @@ -3292,6 +3360,21 @@ function App() { const existingEvents = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); const merged = mergePriceAlertEvents(incomingEvents, existingEvents); writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged); + + // Powiadomienie tylko o spadku ceny obserwowanego ogloszenia. + incomingEvents.forEach((event) => { + if (event.currentPrice < event.previousPrice) { + const diff = event.previousPrice - event.currentPrice; + notificationsCenter.notify({ + category: 'listing', + icon: 'coin', + title: 'Zmiana ceny', + body: `Cena „${event.title}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + link: `listing:${event.listingId}`, + dedupeKey: `price-${event.listingId}-${event.currentPrice}`, + }); + } + }); } if (refreshedFavorites.size > 0) { @@ -3323,7 +3406,7 @@ function App() { return (
-
0} /> +
0} onOpenNotification={handleOpenNotification} />
{view === 'login' && } {view === 'admin' && } @@ -3346,7 +3429,7 @@ function App() { {view === 'accountProfileEdit' && } {view === 'accountPublicProfile' && } {view === 'accountHelpContact' && } - {view === 'notifications' && } + {view === 'notifications' && } {view === 'messages' && ( } {view === 'valuation' && } + {view === 'creditCalculator' && } {view === 'priceHistory' && } {view === 'districtRanking' && } {view === 'negotiation' && ( @@ -5343,6 +5427,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = const isPriceHistoryView = activeView === 'priceHistory'; const isNegotiationView = activeView === 'negotiation'; const isValuationView = activeView === 'valuation'; + const isCreditCalculatorView = activeView === 'creditCalculator'; const isDistrictRankingView = activeView === 'districtRanking'; const isComparisonView = activeView === 'comparison'; const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); @@ -5435,6 +5520,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =

Moje narzędzia

+ @@ -5497,6 +5583,7 @@ function AccountDashboardPage({ const [activeAlerts, setActiveAlerts] = useState(() => alerts.map(() => true)); const tools = [ + { icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', view: 'creditCalculator' as View }, { icon: 'chart', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny mieszkań w czasie', view: 'priceHistory' as View }, { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', view: 'negotiation' as View }, { icon: 'calculator', title: 'Wycena mieszkania', text: 'Darmowa wycena Twojego mieszkania online', view: 'valuation' as View }, @@ -7352,6 +7439,7 @@ function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNa } function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { + const { notify } = useNotifications(); type MeetingType = 'buy' | 'rent' | 'own'; type MeetingStatus = 'planned' | 'completed' | 'cancelled'; type MeetingItem = { @@ -7613,7 +7701,13 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi if (selectedDateIsPast) { return; } + const target = meetings.find((meeting) => meeting.id === id); setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting))); + if (target && status === 'cancelled') { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` }); + } else if (target && status === 'completed') { + notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` }); + } }; const removeMeeting = (id: string) => { @@ -7696,6 +7790,10 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi ]); } + if (!editingMeetingId) { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” — ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' }); + } + setFormError(''); setSelectedDateKey(newMeeting.dateKey); const selectedDate = new Date(newMeeting.dateKey); @@ -7967,6 +8065,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi status: ListingStatus; pausedUntil?: string | null; promoted: boolean; + isApi?: boolean; + apiId?: number; + apiStatus?: ApiListingStatus; }; type ListingTab = 'all' | 'sale' | 'rent' | 'archived'; @@ -8226,6 +8327,73 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi window.localStorage.setItem(listingsStorageKey, JSON.stringify(listings)); }, [listings]); + // Prawdziwe ogloszenia uzytkownika z backendu (/listings/mine) - scalane z makietami ponizej. + const [apiListings, setApiListings] = useState([]); + useEffect(() => { + let cancelled = false; + apiFetch('/listings/mine') + .then((data) => { if (!cancelled) setApiListings(data); }) + .catch(() => { if (!cancelled) setApiListings([]); }); + return () => { cancelled = true; }; + }, []); + + const isApiId = (id: string) => id.startsWith('api-'); + const apiIdNum = (id: string) => Number(id.slice(4)); + + // Ulubione uzytkownika (localStorage) - do statystyki "zapisanych" dla realnych ogloszen. + const { user } = useAuth(); + const favoriteListingIds = useMemo( + () => new Set(readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).map((fav) => fav.listingId)), + [user], + ); + + const apiRows: ListingItem[] = apiListings.map((summary) => ({ + id: `api-${summary.id}`, + title: summary.title, + address: [summary.address, summary.district, summary.city].filter(Boolean).join(', ') || summary.city, + area: summary.area, + rooms: summary.rooms, + floor: summary.floor ? (parseInt(summary.floor, 10) || 0) : 0, + price: summary.price, + monthlyFees: 0, + views: summary.viewsCount ?? 0, + saves: favoriteListingIds.has(summary.id) ? 1 : 0, + addedDate: (summary.createdAt || '').slice(0, 10) || '2025-01-01', + image: summary.coverPhoto || heroImage, + imagePosition: 'center', + category: summary.offerType === 'RENT' ? 'rent' : 'sale', + status: summary.status === 'PAUSED' ? 'paused' : 'active', + pausedUntil: null, + promoted: false, + isApi: true, + apiId: summary.id, + apiStatus: summary.status, + })); + + // Prawdziwe ogloszenia na gorze listy, pod nimi makiety. + const allRows: ListingItem[] = [...apiRows, ...listings]; + + const apiSetStatus = (id: string, status: ApiListingStatus) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}/status?status=${status}`, { method: 'PATCH' }) + .then((updated) => { + setApiListings((current) => current.map((item) => (item.id === numericId ? { ...item, status: updated.status } : item))); + setFeedback(status === 'PAUSED' ? 'Ogłoszenie zostało wstrzymane. Wznowisz je ręcznie.' : 'Ogłoszenie zostało wznowione i jest znów widoczne.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się zmienić statusu ogłoszenia.')); + }; + + const apiDeleteListing = (id: string) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}`, { method: 'DELETE' }) + .then(() => { + setApiListings((current) => current.filter((item) => item.id !== numericId)); + setFeedback('Ogłoszenie zostało trwale usunięte z serwisu.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się usunąć ogłoszenia.')) + .finally(() => closeDeleteModal()); + }; + useEffect(() => { const resumePausedListings = () => { const nowMs = Date.now(); @@ -8291,13 +8459,13 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const tabCounts = { - all: listings.filter((item) => item.status !== 'archived').length, - sale: listings.filter((item) => item.category === 'sale' && item.status === 'active').length, - rent: listings.filter((item) => item.category === 'rent' && item.status === 'active').length, - archived: listings.filter((item) => item.status === 'paused').length, + all: allRows.filter((item) => item.status !== 'archived').length, + sale: allRows.filter((item) => item.category === 'sale' && item.status === 'active').length, + rent: allRows.filter((item) => item.category === 'rent' && item.status === 'active').length, + archived: allRows.filter((item) => item.status === 'paused').length, }; - const visibleListings = listings + const visibleListings = allRows .filter((item) => { if (activeTab === 'archived') { return item.status === 'paused'; @@ -8363,6 +8531,14 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const togglePause = (id: string) => { + if (isApiId(id)) { + const row = apiRows.find((item) => item.id === id); + if (!row) { + return; + } + apiSetStatus(id, row.status === 'paused' ? 'APPROVED' : 'PAUSED'); + return; + } const listing = listings.find((item) => item.id === id); if (!listing || listing.status === 'archived') { return; @@ -8433,6 +8609,10 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const removeListing = (id: string) => { + if (isApiId(id)) { + apiDeleteListing(id); + return; + } setListings((current) => current.filter((item) => item.id !== id)); const selectedReason = deleteReasons.find((reason) => reason.id === deleteReason); setFeedback(selectedReason ? `Ogłoszenie zostało usunięte (${selectedReason.title.toLowerCase()}).` : 'Ogłoszenie zostało usunięte.'); @@ -8474,8 +8654,6 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi

Zarządzaj swoimi ogłoszeniami. Edytuj, promuj lub wstrzymuj oferty.

- -
@@ -8501,7 +8679,13 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi {visibleListings.map((item) => (
-
+
onOpenListing(item.apiId as number) : undefined} + role={item.isApi ? 'button' : undefined} + aria-label={item.isApi ? `Podgląd ogłoszenia ${item.title}` : undefined} + />

{item.title}

@@ -8518,7 +8702,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
- {item.status === 'paused' ? ( + {item.isApi && (item.apiStatus === 'PENDING' || item.apiStatus === 'REJECTED') ? ( + LISTING_STATUS_LABELS[item.apiStatus] + ) : item.status === 'paused' ? ( <> Wstrzymane
@@ -8527,6 +8713,7 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi ) : statusLabel(item.status)}
Dodane: {formatDate(item.addedDate)} + {item.isApi && W serwisie} {item.promoted && Promowane}
@@ -8542,9 +8729,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
{item.status !== 'archived' && } - {item.status !== 'archived' && } + {(item.isApi ? (item.apiStatus === 'APPROVED' || item.apiStatus === 'PAUSED') : item.status !== 'archived') && } - + {!item.isApi && }
))} @@ -8934,6 +9121,7 @@ function AccountSecurityPage({ onNavigate, activeView }: { onNavigate: (view: Vi function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { const { user, updateProfile } = useAuth(); + const { notify } = useNotifications(); type UploadTarget = 'avatar' | 'cover'; type MessageTone = 'success' | 'error' | 'info'; @@ -9385,6 +9573,7 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: setVerificationCodeError(''); setIsPhoneVerified(true); setPhoneVerificationStep(3); + notify({ category: 'system', icon: 'check', title: 'Numer telefonu zweryfikowany', body: 'Twój numer telefonu został pomyślnie zweryfikowany.', link: 'accountSecurity', dedupeKey: 'phone-verified' }); return; } setVerificationCodeError('Kod jest nieprawidłowy. Spróbuj ponownie.'); @@ -12907,7 +13096,7 @@ function GuideBuyingPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Kalkulatory

- @@ -14108,6 +14297,528 @@ async function downloadValuationReportPdf(form: ValuationForm, valuation: Valuat } } +// ---- Kalkulator zdolności kredytowej ------------------------------------- + +type CreditMaritalStatus = 'Panna / kawaler' | 'W związku' | 'Małżeństwo' | 'Rozwiedziony/a' | 'Wdowiec/wdowa'; +type CreditIncomeSource = 'Umowa o pracę' | 'Umowa zlecenie' | 'Działalność gospodarcza' | 'B2B' | 'Emerytura' | 'Renta' | 'Dochód z najmu'; +type CreditSeniority = 'do 3 miesięcy' | '3-12 miesięcy' | '1-3 lata' | 'powyżej 3 lat'; +type CreditRepayment = 'równe' | 'malejące'; + +type CreditForm = { + age: string; + maritalStatus: CreditMaritalStatus; + adults: number; + children: number; + income: string; + partnerIncome: string; + incomeSource: CreditIncomeSource; + seniority: CreditSeniority; + loanInstallments: string; + leasing: string; + alimony: string; + otherObligations: string; + creditCardLimit: string; + accountLimit: string; + propertyPrice: string; + ownContribution: string; + loanYears: number; + interestRate: string; + repayment: CreditRepayment; +}; + +const CREDIT_MARITAL_OPTIONS: CreditMaritalStatus[] = ['Panna / kawaler', 'W związku', 'Małżeństwo', 'Rozwiedziony/a', 'Wdowiec/wdowa']; +const CREDIT_INCOME_SOURCES: CreditIncomeSource[] = ['Umowa o pracę', 'Umowa zlecenie', 'Działalność gospodarcza', 'B2B', 'Emerytura', 'Renta', 'Dochód z najmu']; +const CREDIT_SENIORITY_OPTIONS: CreditSeniority[] = ['do 3 miesięcy', '3-12 miesięcy', '1-3 lata', 'powyżej 3 lat']; + +const INITIAL_CREDIT_FORM: CreditForm = { + age: '', + maritalStatus: 'W związku', + adults: 1, + children: 0, + income: '', + partnerIncome: '', + incomeSource: 'Umowa o pracę', + seniority: '1-3 lata', + loanInstallments: '', + leasing: '', + alimony: '', + otherObligations: '', + creditCardLimit: '', + accountLimit: '', + propertyPrice: '', + ownContribution: '', + loanYears: 25, + interestRate: '7,2', + repayment: 'równe', +}; + +const LIVING_COST_FIRST_ADULT = 1500; +const LIVING_COST_NEXT_ADULT = 1000; +const LIVING_COST_CHILD = 800; +const CREDIT_MAX_DTI = 0.4; +const LIMIT_MONTHLY_CHARGE = 0.05; +const CREDIT_MIN_YEARS = 5; +const CREDIT_MAX_YEARS = 35; + +function creditNum(value: string): number { + const parsed = parseNumber(value); + return parsed == null || parsed < 0 ? 0 : parsed; +} + +// Rata annuitetowa (równa) dla danego kapitału. +function annuityInstallmentForPrincipal(principal: number, monthlyRate: number, months: number): number { + if (principal <= 0 || months <= 0) return 0; + if (monthlyRate <= 0) return principal / months; + return (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -months)); +} + +// Pierwsza (najwyższa) rata malejąca dla danego kapitału. +function decreasingFirstInstallmentForPrincipal(principal: number, monthlyRate: number, months: number): number { + if (principal <= 0 || months <= 0) return 0; + return principal / months + principal * monthlyRate; +} + +// Odwrotność: maksymalny kapitał, jaki uniesie dana rata. +function principalForInstallment(installment: number, monthlyRate: number, months: number, decreasing: boolean): number { + if (installment <= 0 || months <= 0) return 0; + if (decreasing) { + return installment / (1 / months + monthlyRate); + } + if (monthlyRate <= 0) return installment * months; + return (installment * (1 - Math.pow(1 + monthlyRate, -months))) / monthlyRate; +} + +type CreditResult = ReturnType; + +function calculateCreditworthiness(form: CreditForm) { + const income = creditNum(form.income); + const partnerIncome = creditNum(form.partnerIncome); + const totalIncome = income + partnerIncome; + const adults = Math.max(1, Math.round(form.adults)); + const children = Math.max(0, Math.round(form.children)); + const livingCosts = LIVING_COST_FIRST_ADULT + Math.max(0, adults - 1) * LIVING_COST_NEXT_ADULT + children * LIVING_COST_CHILD; + const cardCharge = creditNum(form.creditCardLimit) * LIMIT_MONTHLY_CHARGE; + const accountCharge = creditNum(form.accountLimit) * LIMIT_MONTHLY_CHARGE; + const declaredObligations = creditNum(form.loanInstallments) + creditNum(form.leasing) + creditNum(form.alimony) + creditNum(form.otherObligations); + const totalObligations = declaredObligations + cardCharge + accountCharge; + const disposable = totalIncome - livingCosts - totalObligations; + const maxRateByDti = totalIncome * CREDIT_MAX_DTI; + const maxInstallment = Math.max(0, Math.min(maxRateByDti, disposable)); + + const years = Math.min(CREDIT_MAX_YEARS, Math.max(CREDIT_MIN_YEARS, Math.round(form.loanYears))); + const months = years * 12; + const monthlyRate = creditNum(form.interestRate) / 100 / 12; + const decreasing = form.repayment === 'malejące'; + + const maxLoan = principalForInstallment(maxInstallment, monthlyRate, months, decreasing); + const ownContribution = creditNum(form.ownContribution); + const maxPropertyPrice = maxLoan + ownContribution; + const recommendedOwnContribution = Math.round(maxPropertyPrice * 0.2); + + const propertyPrice = creditNum(form.propertyPrice); + const loanForPrice = Math.max(0, propertyPrice - ownContribution); + const projectedInstallment = decreasing + ? decreasingFirstInstallmentForPrincipal(loanForPrice, monthlyRate, months) + : annuityInstallmentForPrincipal(loanForPrice, monthlyRate, months); + const leftoverAfterInstallment = totalIncome - livingCosts - totalObligations - projectedInstallment; + const dsti = totalIncome > 0 ? projectedInstallment / totalIncome : 0; + const dti = totalIncome > 0 ? totalObligations / totalIncome : 0; + + const hasIncome = totalIncome > 0; + const ready = hasIncome && maxInstallment > 0; + const priceExceedsCapacity = propertyPrice > 0 && (loanForPrice > maxLoan + 1 || leftoverAfterInstallment < 0); + + let ratingKey: 'verysafe' | 'safe' | 'moderate' | 'high' | 'none' | 'empty'; + if (!hasIncome) { + ratingKey = 'empty'; + } else if (disposable <= 0) { + ratingKey = 'none'; + } else if (propertyPrice > 0) { + if (priceExceedsCapacity) { + ratingKey = 'high'; + } else if (dsti <= 0.2) { + ratingKey = 'verysafe'; + } else if (dsti <= 0.3) { + ratingKey = 'safe'; + } else if (dsti <= 0.4) { + ratingKey = 'moderate'; + } else { + ratingKey = 'high'; + } + } else { + const ratio = totalIncome > 0 ? maxInstallment / totalIncome : 0; + ratingKey = ratio <= 0.2 ? 'verysafe' : ratio <= 0.3 ? 'safe' : 'moderate'; + } + + return { + income, partnerIncome, totalIncome, adults, children, livingCosts, + cardCharge, accountCharge, declaredObligations, totalObligations, + disposable, maxInstallment, years, months, monthlyRate, decreasing, + maxLoan, ownContribution, maxPropertyPrice, recommendedOwnContribution, + propertyPrice, loanForPrice, projectedInstallment, leftoverAfterInstallment, + dsti, dti, hasIncome, ready, priceExceedsCapacity, ratingKey, + }; +} + +const CREDIT_RATING_META: Record = { + verysafe: { label: 'Bardzo bezpieczny poziom', tone: 'verysafe', stars: 5, note: 'Rata i koszty życia zostawiają duży zapas w budżecie.' }, + safe: { label: 'Bezpieczny poziom', tone: 'safe', stars: 4, note: 'Obciążenie mieści się w bezpiecznych granicach.' }, + moderate: { label: 'Umiarkowane obciążenie', tone: 'moderate', stars: 3, note: 'Rata jest odczuwalna – warto zachować poduszkę finansową.' }, + high: { label: 'Wysokie obciążenie', tone: 'high', stars: 2, note: 'Rata pochłania zbyt dużą część dochodu przy tej cenie.' }, + none: { label: 'Brak zdolności', tone: 'none', stars: 0, note: 'Koszty i zobowiązania przekraczają dochód – zdolność wynosi 0 zł.' }, + empty: { label: 'Uzupełnij dane', tone: 'empty', stars: 0, note: 'Podaj miesięczny dochód netto, aby obliczyć zdolność.' }, +}; + +function creditMaxPropertyPrice(form: CreditForm): number { + return calculateCreditworthiness(form).maxPropertyPrice; +} + +type CreditRecommendation = { icon: string; label: string; detail: string; delta: number }; + +// Każda rekomendacja liczona jest ponownie na danych użytkownika (żadnych kwot na sztywno). +function buildCreditRecommendations(form: CreditForm, base: number): CreditRecommendation[] { + const recs: CreditRecommendation[] = []; + const add = (icon: string, label: string, detail: string, next: CreditForm) => { + const delta = Math.round(creditMaxPropertyPrice(next) - base); + if (delta > 0) recs.push({ icon, label, detail, delta }); + }; + if (creditNum(form.creditCardLimit) > 0) { + add('credit-card', 'Zamknij kartę kredytową', 'Rezygnacja z limitu karty odciąża miesięczny budżet.', { ...form, creditCardLimit: '0' }); + } + if (creditNum(form.accountLimit) > 0) { + add('scale', 'Zlikwiduj limit w koncie', 'Limit w koncie obniża zdolność, nawet jeśli go nie używasz.', { ...form, accountLimit: '0' }); + } + if (creditNum(form.loanInstallments) > 0) { + add('check', 'Spłać bieżące kredyty', 'Zamknięcie rat uwalnia miejsce na ratę kredytu hipotecznego.', { ...form, loanInstallments: '0' }); + } + if (Math.round(form.loanYears) < CREDIT_MAX_YEARS) { + add('clock', `Wydłuż okres kredytu do ${CREDIT_MAX_YEARS} lat`, 'Dłuższy okres obniża ratę i podnosi maksymalną kwotę kredytu.', { ...form, loanYears: CREDIT_MAX_YEARS }); + } + if (creditNum(form.partnerIncome) === 0 && creditNum(form.income) > 0) { + add('group', 'Dodaj dochód partnera', 'Drugi dochód w gospodarstwie mocno zwiększa zdolność (symulacja: partner z dochodem jak Twój).', { ...form, partnerIncome: form.income }); + } + add('coin', 'Zwiększ wkład własny o 50 000 zł', 'Wyższy wkład własny wprost podnosi dostępną cenę nieruchomości.', { ...form, ownContribution: String(creditNum(form.ownContribution) + 50000) }); + return recs.sort((a, b) => b.delta - a.delta); +} + +function formatPercent(value: number): string { + return `${(value * 100).toLocaleString('pl-PL', { maximumFractionDigits: 1 })}%`; +} + +function CreditCalculatorPage({ onNavigate, onSearchWithBudget }: { onNavigate: (view: View) => void; onSearchWithBudget: (maxPrice: number) => void }) { + const { user } = useAuth(); + const [form, setForm] = useState(INITIAL_CREDIT_FORM); + const [highlight, setHighlight] = useState(false); + const resultRef = useRef(null); + const result = calculateCreditworthiness(form); + const rating = CREDIT_RATING_META[result.ratingKey]; + const recommendations = buildCreditRecommendations(form, result.maxPropertyPrice); + const fmtPln = (value: number) => formatPln(Math.round(value)); + + const update = (field: Key, value: CreditForm[Key]) => { + setForm((current) => ({ ...current, [field]: value })); + }; + const money = (field: keyof CreditForm) => (event: React.ChangeEvent) => + update(field, event.target.value.replace(/[^\d]/g, '') as CreditForm[typeof field]); + const stepAdults = (delta: number) => update('adults', Math.min(12, Math.max(1, form.adults + delta))); + const stepChildren = (delta: number) => update('children', Math.min(12, Math.max(0, form.children + delta))); + const reset = () => { setForm(INITIAL_CREDIT_FORM); }; + const handleCalculate = () => { + setHighlight(true); + window.setTimeout(() => setHighlight(false), 900); + resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + // Donut struktury dochodów. + const donutRadius = 52; + const donutCirc = 2 * Math.PI * donutRadius; + const yourLen = result.totalIncome > 0 ? (result.income / result.totalIncome) * donutCirc : 0; + const partnerLen = result.totalIncome > 0 ? (result.partnerIncome / result.totalIncome) * donutCirc : 0; + + const detailRows: { label: string; value: string; tone?: string }[] = [ + { label: 'Łączne dochody netto', value: fmtPln(result.totalIncome) }, + { label: 'Koszty utrzymania gospodarstwa', value: fmtPln(result.livingCosts) }, + { label: 'Łączne zobowiązania', value: fmtPln(result.totalObligations) }, + { label: 'Dochód po odliczeniach', value: fmtPln(Math.max(0, result.disposable)) }, + { label: 'Zalecana maksymalna rata', value: fmtPln(result.maxInstallment) }, + { label: 'Maksymalna kwota kredytu', value: fmtPln(result.maxLoan) }, + { label: 'Maksymalna cena nieruchomości', value: fmtPln(result.maxPropertyPrice) }, + { label: 'Przewidywana rata dla wpisanej ceny', value: fmtPln(result.projectedInstallment) }, + { label: 'Kwota po opłaceniu raty', value: fmtPln(result.leftoverAfterInstallment), tone: result.leftoverAfterInstallment < 0 ? 'negative' : 'positive' }, + { label: 'Wskaźnik DTI (zobowiązania / dochód)', value: formatPercent(result.dti) }, + { label: 'Wskaźnik DSTI (rata / dochód)', value: formatPercent(result.dsti) }, + ]; + + const pageBody = ( + <> +
+ +
+

Kalkulator zdolności kredytowej

+

Sprawdź, jaką kwotę kredytu hipotecznego możesz uzyskać i jakie będą orientacyjne warunki. Wynik przelicza się na bieżąco przy każdej zmianie.

+
+
+ +
+
+
+

1 Twoje dane

+
+ + +
+ Liczba osób w gospodarstwie +
+ + {form.adults} + +
+
+
+ Liczba dzieci +
+ + {form.children} + +
+
+
+
+ +
+

2 Dochody miesięczne (netto)

+
+ + + + +
+
+ +
+

3 Zobowiązania miesięczne

+
+ + + + + + +
+
+ +
+

4 Informacje o kredycie

+
+ + +
+ Okres kredytu: {result.years} lat + update('loanYears', Number(e.target.value))} className="credit-range" /> +
{CREDIT_MIN_YEARS} lat{CREDIT_MAX_YEARS} lat
+
+ +
+ Rodzaj rat +
+ + +
+
+
+
+ +
+ + +
+
+ +
+
+
+ Twoja szacunkowa zdolność kredytowa + {rating.label} +
+ {result.hasIncome ? fmtPln(result.maxLoan) : '— zł'} +
+ {[1, 2, 3, 4, 5].map((n) => )} +
+

{rating.note}

+
+ +
+
+ Maksymalna rata + {fmtPln(result.maxInstallment)} + {formatPercent(CREDIT_MAX_DTI)} Twoich dochodów +
+
+ Maksymalny kredyt + {fmtPln(result.maxLoan)} + {result.decreasing ? 'raty malejące' : 'raty równe'} · {result.years} lat +
+
+ Zalecany wkład własny + {fmtPln(result.recommendedOwnContribution)} + 20% wartości nieruchomości +
+
+ +
+
+

Szczegóły symulacji

+
+ + + {result.totalIncome > 0 && ( + + )} + {result.partnerIncome > 0 && ( + + )} + {Math.round(result.totalIncome / 1000)} tys. + łącznie + +
    +
  • Twój dochód {fmtPln(result.income)}
  • +
  • Dochód partnera {fmtPln(result.partnerIncome)}
  • +
+
+
+
+ {detailRows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ +
+
+ Znajdź nieruchomości w zasięgu Twojej zdolności + Pokażemy oferty do {fmtPln(result.maxPropertyPrice)} (kredyt + wkład własny). +
+ +
+ + {recommendations.length > 0 && ( +
+

Jak zwiększyć zdolność?

+
+ {recommendations.map((reco) => ( +
+ +
+ {reco.label} + {reco.detail} +
+ +{formatPln(reco.delta)} +
+ ))} +
+
+ )} + +

+ Wynik ma charakter orientacyjny i nie stanowi oferty ani decyzji banku. Rzeczywista zdolność zależy od zasad banku, historii kredytowej i indywidualnej sytuacji klienta. Nie zapisujemy Twoich danych finansowych bez Twojej zgody. +

+
+
+ + ); + + if (user) { + return ( +
+
+ +
+ {pageBody} +
+
+
+ ); + } + + return ( +
+
+
+ + + +
+ {pageBody} +
+
+ ); +} + function ValuationPage({ onNavigate }: { onNavigate: (view: View) => void }) { const [step, setStep] = useState(0); const [valuationForm, setValuationForm] = useState(INITIAL_VALUATION_FORM); @@ -17689,61 +18400,26 @@ function Header({ onNavigate, onLogout, hasUnreadFavorites, + onOpenNotification, }: { activeView: View; onNavigate: (view: View) => void; onLogout: () => void; hasUnreadFavorites: boolean; + onOpenNotification: (notification: DisplayNotification) => void; }) { const { user } = useAuth(); + const { notifications: serverNotifications, unreadCount } = useNotifications(); const isLoggedIn = Boolean(user); const isAdmin = user?.role === 'ADMIN'; const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const notificationPopoverRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); - const [adminUnreadCount, setAdminUnreadCount] = useState(0); - useEffect(() => { - if (!user || user.role === 'ADMIN') { - setAdminUnreadCount(0); - return; - } - - let cancelled = false; - apiFetch<{ count: number }>('/messages/unread-count') - .then((data) => { - if (!cancelled) { - setAdminUnreadCount(data.count); - } - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [user]); - - const allNotifications = useMemo(() => { - const base = getAllNotifications(); - if (adminUnreadCount <= 0) { - return base; - } - const adminNotification: UserNotification = { - id: 'admin-message-live', - title: 'Nowa wiadomość od administracji', - description: `Masz ${adminUnreadCount} ${adminUnreadCount === 1 ? 'nieprzeczytaną wiadomość' : 'nieprzeczytane wiadomości'} od zespołu Mieszko.`, - timeLabel: 'Teraz', - dayLabel: 'Dzisiaj', - category: 'messages', - icon: 'message', - unread: true, - action: 'arrow', - }; - return [adminNotification, ...base]; - }, [adminUnreadCount]); - const unreadCount = allNotifications.filter((item) => item.unread).length; - const previewNotifications = allNotifications.slice(0, 3); + // Dane powiadomień z centralnego providera. W dzwonku pokazujemy nieprzeczytane (znikają po kliknięciu). + const displayNotifications = toDisplayNotifications(serverNotifications); + const previewNotifications = displayNotifications.filter((item) => item.unread).slice(0, 6); useEffect(() => { if (!isNotificationsOpen) { @@ -17879,8 +18555,17 @@ function Header({
+ {previewNotifications.length === 0 && ( +

Brak nowych powiadomień.

+ )} {previewNotifications.map((item) => ( -
+
{ setIsNotificationsOpen(false); onOpenNotification(item); }} + > @@ -17961,51 +18646,14 @@ function Header({ ); } -function NotificationsPage() { - const { user } = useAuth(); +function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notification: DisplayNotification) => void }) { const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>('all'); - const [adminUnreadCount, setAdminUnreadCount] = useState(0); + const { notifications: serverNotifications } = useNotifications(); - useEffect(() => { - if (!user || user.role === 'ADMIN') { - setAdminUnreadCount(0); - return; - } - - let cancelled = false; - apiFetch<{ count: number }>('/messages/unread-count') - .then((data) => { - if (!cancelled) { - setAdminUnreadCount(data.count); - } - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [user]); - - const allNotifications = useMemo(() => { - const base = getAllNotifications(); - if (adminUnreadCount <= 0) { - return base; - } - const adminNotification: UserNotification = { - id: 'admin-message-live', - title: 'Nowa wiadomość od administracji', - description: `Masz ${adminUnreadCount} ${adminUnreadCount === 1 ? 'nieprzeczytaną wiadomość' : 'nieprzeczytane wiadomości'} od zespołu Mieszko.`, - timeLabel: 'Teraz', - dayLabel: 'Dzisiaj', - category: 'messages', - icon: 'message', - unread: true, - action: 'arrow', - }; - return [adminNotification, ...base]; - }, [adminUnreadCount]); + // Cała historia z backendu (przeczytane pozostają na liście); mapowanie do kształtu widoku bez zmiany designu. + const allNotifications = toDisplayNotifications(serverNotifications); const filteredNotifications = allNotifications.filter((item) => activeTab === 'all' || item.category === activeTab); - const groupedNotifications = filteredNotifications.reduce>((groups, item) => { + const groupedNotifications = filteredNotifications.reduce>((groups, item) => { groups[item.dayLabel].push(item); return groups; }, { Dzisiaj: [], Wczoraj: [] }); @@ -18034,7 +18682,13 @@ function NotificationsPage() {

{groupName}

{groupedNotifications[groupName].map((item) => ( -
+
onOpenNotification(item)} + > @@ -19830,6 +20484,7 @@ const LISTING_STATUS_LABELS: Record = { PENDING: 'Oczekuje na moderację', APPROVED: 'Opublikowane', REJECTED: 'Odrzucone', + PAUSED: 'Wstrzymane', }; function listingRoomsText(rooms: number): string { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9f89fe5..cc6c8d1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,11 +2,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { AuthProvider } from './auth'; +import { NotificationsProvider } from './notifications'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/frontend/src/notifications.tsx b/frontend/src/notifications.tsx new file mode 100644 index 0000000..81ebdef --- /dev/null +++ b/frontend/src/notifications.tsx @@ -0,0 +1,178 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { apiFetch, getToken, useAuth } from './auth'; + +export type NotificationCategoryKey = 'listing' | 'messages' | 'system'; + +// Powiadomienie w formie z backendu. +export type ServerNotification = { + id: number; + category: NotificationCategoryKey; + icon: string; + title: string; + body: string | null; + link: string | null; + read: boolean; + createdAt: string; +}; + +// Zdarzenia liczone po stronie klienta (alerty cen, spotkania, ulubione, telefon, dopasowania). +export type NotifyInput = { + category: NotificationCategoryKey; + icon?: string; + title: string; + body?: string; + link?: string; + dedupeKey?: string; +}; + +// Kształt do renderu (zgodny z istniejącym UI: title/description/timeLabel/dayLabel/category/icon/unread/action). +export type DisplayNotification = ServerNotification & { + description: string; + timeLabel: string; + dayLabel: 'Dzisiaj' | 'Wczoraj'; + unread: boolean; + action?: 'arrow'; +}; + +type NotificationsContextValue = { + notifications: ServerNotification[]; + unreadCount: number; + markRead: (id: number) => void; + markAllRead: () => void; + notify: (input: NotifyInput) => void; + refresh: () => void; +}; + +const NotificationsContext = createContext(null); + +function pad(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +// Mapowanie listy z backendu na kształt oczekiwany przez istniejący widok (bez zmiany designu). +export function toDisplayNotifications(list: ServerNotification[]): DisplayNotification[] { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + + return list.map((item) => { + const created = new Date(item.createdAt); + const createdMs = created.getTime(); + const time = `${pad(created.getHours())}:${pad(created.getMinutes())}`; + const isToday = createdMs >= startOfToday; + const isYesterday = createdMs >= startOfYesterday && createdMs < startOfToday; + + let timeLabel: string; + if (isToday) { + timeLabel = time; + } else if (isYesterday) { + timeLabel = `Wczoraj, ${time}`; + } else { + timeLabel = `${pad(created.getDate())}.${pad(created.getMonth() + 1)}, ${time}`; + } + + const unread = !item.read; + return { + ...item, + description: item.body ?? '', + timeLabel, + // Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" — starsze trafiają do "Wczoraj". + dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj', + unread, + action: !unread && item.link ? 'arrow' : undefined, + }; + }); +} + +export function NotificationsProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const sentDedupeKeys = useRef>(new Set()); + + const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0); + + const refresh = useCallback(() => { + if (!getToken()) { + setNotifications([]); + return; + } + apiFetch('/notifications') + .then((data) => setNotifications(Array.isArray(data) ? data : [])) + .catch(() => { /* cicho - brak sieci/tokenu */ }); + }, []); + + // Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. Token w query param (EventSource nie ustawia nagłówków). + useEffect(() => { + const token = getToken(); + if (!user || !token) { + setNotifications([]); + sentDedupeKeys.current = new Set(); + return; + } + + refresh(); + + const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`); + source.addEventListener('notification', (event) => { + try { + const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification; + setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current])); + } catch { + /* ignoruj nieparsowalne zdarzenie */ + } + }); + // onerror: EventSource sam ponawia połączenie. + + // Zapasowy polling na wypadek zerwanego SSE. + const pollId = window.setInterval(refresh, 60000); + + return () => { + source.close(); + window.clearInterval(pollId); + }; + }, [user, refresh]); + + const markRead = useCallback((id: number) => { + setNotifications((current) => current.map((item) => (item.id === id ? { ...item, read: true } : item))); + apiFetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {}); + }, []); + + const markAllRead = useCallback(() => { + setNotifications((current) => current.map((item) => ({ ...item, read: true }))); + apiFetch('/notifications/read-all', { method: 'POST' }).catch(() => {}); + }, []); + + const notify = useCallback((input: NotifyInput) => { + if (!getToken() || !input.title) { + return; + } + if (input.dedupeKey) { + if (sentDedupeKeys.current.has(input.dedupeKey)) { + return; + } + sentDedupeKeys.current.add(input.dedupeKey); + } + apiFetch('/notifications', { method: 'POST', body: JSON.stringify(input) }) + .then((created) => { + if (created && created.id) { + setNotifications((current) => (current.some((item) => item.id === created.id) ? current : [created, ...current])); + } + }) + .catch(() => {}); + }, []); + + return ( + + {children} + + ); +} + +export function useNotifications(): NotificationsContextValue { + const context = useContext(NotificationsContext); + if (!context) { + throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider'); + } + return context; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 70b441f..5f2daca 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -25842,3 +25842,805 @@ svg { align-items: flex-start; } } + +/* ---- Kalkulator zdolności kredytowej ---- */ +.credit-page { + background: #f4f7fb; + padding: 34px 20px 70px; +} + +.credit-shell { + margin: 0 auto; + max-width: 1240px; +} + +.credit-breadcrumb { + margin-bottom: 18px; +} + +.credit-title { + align-items: center; + display: flex; + gap: 18px; + margin-bottom: 26px; +} + +.credit-title-icon { + align-items: center; + background: #e6f6ee; + border-radius: 14px; + color: #0b9f5c; + display: flex; + flex: 0 0 auto; + font-size: 26px; + height: 56px; + justify-content: center; + width: 56px; +} + +.credit-title h1 { + color: #14223a; + font-size: 30px; + font-weight: 950; + margin: 0 0 6px; +} + +.credit-title p { + color: #526171; + font-size: 14px; + font-weight: 700; + line-height: 1.55; + margin: 0; + max-width: 780px; +} + +.credit-layout { + align-items: start; + display: grid; + gap: 22px; + grid-template-columns: minmax(320px, 430px) minmax(0, 1fr); +} + +.credit-form-column, +.credit-result-column { + display: grid; + gap: 18px; + min-width: 0; +} + +.credit-card { + background: #ffffff; + border: 1px solid #dfe7ef; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(24, 35, 57, 0.05); + padding: 22px 22px 20px; +} + +.credit-card h2 { + align-items: center; + color: #14223a; + display: flex; + font-size: 16px; + font-weight: 950; + gap: 12px; + margin: 0 0 18px; +} + +.credit-step-badge { + align-items: center; + background: #0b9f5c; + border-radius: 50%; + color: #ffffff; + display: inline-flex; + font-size: 13px; + font-weight: 950; + height: 26px; + justify-content: center; + width: 26px; +} + +.credit-grid { + display: grid; + gap: 16px; +} + +.credit-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.credit-field { + display: grid; + gap: 8px; + min-width: 0; +} + +.credit-field-wide { + grid-column: 1 / -1; +} + +.credit-field > span { + color: #27364d; + font-size: 13px; + font-weight: 900; +} + +.credit-field small { + color: #7a8798; + font-size: 11.5px; + font-weight: 800; +} + +.credit-field input, +.credit-field select { + background: #ffffff; + border: 1px solid #d5dee8; + border-radius: 8px; + color: #14223a; + font-size: 14px; + font-weight: 800; + min-height: 48px; + padding: 0 13px; + width: 100%; +} + +.credit-field input:focus, +.credit-field select:focus { + border-color: #0b9f5c; + box-shadow: 0 0 0 3px rgba(11, 159, 92, 0.14); + outline: none; +} + +.credit-input-suffix { + align-items: center; + background: #ffffff; + border: 1px solid #d5dee8; + border-radius: 8px; + display: flex; + min-height: 48px; + overflow: hidden; + padding-right: 12px; +} + +.credit-input-suffix:focus-within { + border-color: #0b9f5c; + box-shadow: 0 0 0 3px rgba(11, 159, 92, 0.14); +} + +.credit-input-suffix input { + border: none; + box-shadow: none; + min-height: 46px; +} + +.credit-input-suffix input:focus { + border: none; + box-shadow: none; +} + +.credit-input-suffix em { + color: #7a8798; + font-size: 13px; + font-style: normal; + font-weight: 900; +} + +.credit-stepper { + align-items: center; + border: 1px solid #d5dee8; + border-radius: 8px; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 48px; + min-height: 48px; + overflow: hidden; +} + +.credit-stepper button { + background: #f4f7fb; + border: none; + color: #0b9f5c; + font-size: 22px; + font-weight: 900; + height: 100%; +} + +.credit-stepper button:hover { + background: #e6f6ee; +} + +.credit-stepper strong { + color: #14223a; + font-size: 16px; + font-weight: 950; + text-align: center; +} + +.credit-segmented { + background: #eef2f7; + border-radius: 8px; + display: grid; + gap: 4px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 4px; +} + +.credit-segmented button { + background: transparent; + border: none; + border-radius: 6px; + color: #526171; + font-size: 13px; + font-weight: 900; + padding: 11px 8px; +} + +.credit-segmented button.active { + background: #ffffff; + box-shadow: 0 2px 8px rgba(24, 35, 57, 0.12); + color: #0b9f5c; +} + +.credit-range-value { + color: #0b9f5c; + font-weight: 950; +} + +.credit-range { + -webkit-appearance: none; + appearance: none; + background: linear-gradient(#d5dee8, #d5dee8) no-repeat; + background-size: 100% 6px; + background-position: 0 center; + border-radius: 6px; + height: 24px; + width: 100%; +} + +.credit-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #0b9f5c; + border: 3px solid #ffffff; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(11, 159, 92, 0.4); + height: 22px; + width: 22px; +} + +.credit-range::-moz-range-thumb { + background: #0b9f5c; + border: 3px solid #ffffff; + border-radius: 50%; + height: 22px; + width: 22px; +} + +.credit-range-scale { + color: #97a3b2; + display: flex; + font-size: 11px; + font-weight: 800; + justify-content: space-between; +} + +.credit-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.credit-btn-primary, +.credit-btn-ghost { + align-items: center; + border-radius: 10px; + display: inline-flex; + font-size: 14px; + font-weight: 900; + gap: 9px; + justify-content: center; + min-height: 50px; + padding: 0 22px; +} + +.credit-btn-primary { + background: #0b9f5c; + border: none; + color: #ffffff; + flex: 1 1 auto; +} + +.credit-btn-primary:hover { + background: #0a8b50; +} + +.credit-btn-ghost { + background: #ffffff; + border: 1px solid #d5dee8; + color: #27364d; +} + +.credit-btn-ghost:hover { + border-color: #b7c2cf; +} + +/* Wynik */ +.credit-result-hero { + border-radius: 14px; + color: #ffffff; + overflow: hidden; + padding: 26px 28px 24px; + position: relative; +} + +.credit-tone-verysafe, +.credit-tone-safe { + background: linear-gradient(135deg, #0b9f5c, #0a8b50); +} + +.credit-tone-moderate { + background: linear-gradient(135deg, #d98a13, #c2790a); +} + +.credit-tone-high { + background: linear-gradient(135deg, #e0592f, #c8461f); +} + +.credit-tone-none, +.credit-tone-empty { + background: linear-gradient(135deg, #4a5a70, #35435a); +} + +.credit-result-hero.pulse { + animation: creditPulse 0.9s ease; +} + +@keyframes creditPulse { + 0% { transform: scale(1); } + 40% { transform: scale(1.014); } + 100% { transform: scale(1); } +} + +.credit-hero-top { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.credit-hero-top > span:first-child { + font-size: 14px; + font-weight: 850; + opacity: 0.92; +} + +.credit-badge { + background: rgba(255, 255, 255, 0.22); + border-radius: 999px; + font-size: 12px; + font-weight: 900; + padding: 6px 12px; + white-space: nowrap; +} + +.credit-result-hero strong { + display: block; + font-size: 44px; + font-weight: 950; + letter-spacing: -0.5px; + line-height: 1; + margin: 18px 0 12px; +} + +.credit-stars { + display: flex; + gap: 3px; +} + +.credit-stars span { + color: rgba(255, 255, 255, 0.4); + font-size: 17px; +} + +.credit-stars span.on { + color: #ffd873; +} + +.credit-stars span svg { + fill: currentColor; + stroke: none; +} + +.credit-result-hero p { + font-size: 13px; + font-weight: 750; + line-height: 1.5; + margin: 14px 0 0; + opacity: 0.94; +} + +.credit-tiles { + display: grid; + gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.credit-tile { + background: #ffffff; + border: 1px solid #dfe7ef; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(24, 35, 57, 0.05); + display: grid; + gap: 6px; + padding: 16px 16px 15px; +} + +.credit-tile small { + color: #7a8798; + font-size: 12px; + font-weight: 850; +} + +.credit-tile strong { + color: #14223a; + font-size: 20px; + font-weight: 950; +} + +.credit-tile em { + color: #0b9f5c; + font-size: 11.5px; + font-style: normal; + font-weight: 850; +} + +.credit-details-head { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr) 250px; + margin-bottom: 18px; +} + +.credit-details h3 { + color: #14223a; + font-size: 16px; + font-weight: 950; + margin: 0; +} + +.credit-donut-wrap { + align-items: center; + display: grid; + gap: 6px; + justify-items: center; +} + +.credit-donut { + height: 128px; + width: 128px; +} + +.credit-donut circle { + fill: none; + stroke-width: 13; +} + +.credit-donut-track { + stroke: #eef2f7; +} + +.credit-donut-you { + stroke: #0b9f5c; + stroke-linecap: round; +} + +.credit-donut-partner { + stroke: #3f8bff; + stroke-linecap: round; +} + +.credit-donut-value { + dominant-baseline: middle; + fill: #14223a; + font-size: 20px; + font-weight: 900; + stroke: none; + text-anchor: middle; +} + +.credit-donut-label { + dominant-baseline: middle; + fill: #7a8798; + font-size: 10px; + font-weight: 800; + stroke: none; + text-anchor: middle; +} + +.credit-donut-legend { + display: grid; + gap: 6px; + list-style: none; + margin: 4px 0 0; + padding: 0; + width: 100%; +} + +.credit-donut-legend li { + align-items: center; + color: #526171; + display: flex; + font-size: 12px; + font-weight: 800; + gap: 7px; +} + +.credit-donut-legend b { + color: #14223a; + margin-left: auto; +} + +.credit-donut-legend i { + border-radius: 3px; + flex: 0 0 auto; + height: 11px; + width: 11px; +} + +.credit-dot-you { + background: #0b9f5c; +} + +.credit-dot-partner { + background: #3f8bff; +} + +.credit-detail-rows { + display: grid; + gap: 0; + margin: 0; +} + +.credit-detail-row { + align-items: center; + border-top: 1px solid #eef2f7; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 11px 0; +} + +.credit-detail-row dt { + color: #526171; + font-size: 13px; + font-weight: 750; +} + +.credit-detail-row dd { + color: #14223a; + font-size: 14px; + font-weight: 950; + margin: 0; + text-align: right; +} + +.credit-detail-positive dd { + color: #0b9f5c; +} + +.credit-detail-negative dd { + color: #d64545; +} + +.credit-cta-band { + align-items: center; + background: linear-gradient(135deg, #e6f6ee, #f0f9f4); + border: 1px solid #bfe6d2; + border-radius: 12px; + display: flex; + gap: 16px; + justify-content: space-between; + padding: 20px 22px; +} + +.credit-cta-band strong { + color: #14223a; + display: block; + font-size: 15px; + font-weight: 950; +} + +.credit-cta-band span { + color: #4b6a58; + display: block; + font-size: 13px; + font-weight: 750; + margin-top: 4px; +} + +.credit-cta-band button { + align-items: center; + background: #0b9f5c; + border: none; + border-radius: 10px; + color: #ffffff; + display: inline-flex; + flex: 0 0 auto; + font-size: 14px; + font-weight: 900; + gap: 8px; + min-height: 48px; + padding: 0 20px; +} + +.credit-cta-band button:hover:not(:disabled) { + background: #0a8b50; +} + +.credit-cta-band button:disabled { + background: #b7c2cf; + cursor: not-allowed; +} + +.credit-recos h3 { + align-items: center; + color: #14223a; + display: flex; + font-size: 16px; + font-weight: 950; + gap: 9px; + margin: 0 0 16px; +} + +.credit-reco-list { + display: grid; + gap: 10px; +} + +.credit-reco { + align-items: center; + background: #f8fafc; + border: 1px solid #e7edf3; + border-radius: 10px; + display: flex; + gap: 13px; + padding: 13px 15px; +} + +.credit-reco-icon { + align-items: center; + background: #e6f6ee; + border-radius: 9px; + color: #0b9f5c; + display: flex; + flex: 0 0 auto; + font-size: 17px; + height: 40px; + justify-content: center; + width: 40px; +} + +.credit-reco-body { + display: grid; + gap: 3px; + min-width: 0; +} + +.credit-reco-body strong { + color: #14223a; + font-size: 13.5px; + font-weight: 900; +} + +.credit-reco-body small { + color: #7a8798; + font-size: 12px; + font-weight: 700; + line-height: 1.4; +} + +.credit-reco-delta { + color: #0b9f5c; + flex: 0 0 auto; + font-size: 14px; + font-weight: 950; + margin-left: auto; + white-space: nowrap; +} + +.credit-disclaimer { + align-items: flex-start; + color: #7a8798; + display: flex; + font-size: 12px; + font-weight: 700; + gap: 9px; + line-height: 1.55; + margin: 4px 0 0; +} + +.credit-disclaimer svg { + color: #0b9f5c; + flex: 0 0 auto; + font-size: 16px; + margin-top: 1px; +} + +@media (max-width: 1080px) { + .credit-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .credit-grid-2 { + grid-template-columns: minmax(0, 1fr); + } + + .credit-tiles { + grid-template-columns: minmax(0, 1fr); + } + + .credit-details-head { + grid-template-columns: minmax(0, 1fr); + } + + .credit-cta-band { + align-items: flex-start; + flex-direction: column; + } + + .credit-title h1 { + font-size: 24px; + } + + .credit-result-hero strong { + font-size: 36px; + } +} + +/* Prawdziwe ogloszenie z serwisu w liscie "Moje oferty" */ +.listing-photo.clickable { + cursor: pointer; +} + +.listing-status em.listing-live-tag { + background: #e6f6ee; + border-radius: 999px; + color: #0b9f5c; + font-size: 10.5px; + font-style: normal; + font-weight: 900; + letter-spacing: 0.02em; + padding: 2px 8px; + text-transform: uppercase; +} + +/* Kalkulator osadzony w panelu konta (z paskiem bocznym) */ +.credit-account-main { + min-width: 0; +} + +.credit-account-main .credit-title { + margin-top: 0; +} + +@media (max-width: 1280px) { + .credit-account-main .credit-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Powiadomienia - klikalnosc (logika, bez zmiany wygladu) */ +.notification-row, +.notification-popover-item[role="button"] { + cursor: pointer; +} + +.notification-popover-empty { + color: #7a8798; + font-size: 13px; + font-weight: 700; + margin: 0; + padding: 18px 4px; + text-align: center; +} From a6b156695ff277d2cd14fa4da60b2d1eb52c80d4 Mon Sep 17 00:00:00 2001 From: pascal Date: Thu, 23 Jul 2026 23:15:15 +0200 Subject: [PATCH 03/10] sms & SMTP --- backend/pom.xml | 4 + .../PolskaLokalnieApplication.java | 3 + .../pl/polskalokalnie/auth/AuthService.java | 15 +- .../polskalokalnie/auth/SecurityConfig.java | 1 + .../pl/polskalokalnie/campaign/Campaign.java | 171 +++ .../campaign/CampaignController.java | 72 ++ .../campaign/CampaignRecipient.java | 147 +++ .../campaign/CampaignRecipientRepository.java | 16 + .../campaign/CampaignRepository.java | 9 + .../campaign/CampaignRequest.java | 17 + .../campaign/CampaignResponse.java | 39 + .../campaign/CampaignService.java | 213 ++++ .../campaign/CampaignStatsResponse.java | 15 + .../campaign/CampaignStatus.java | 14 + .../pl/polskalokalnie/campaign/Channel.java | 6 + .../campaign/RecipientStatus.java | 15 + .../config/LeadBackfillRunner.java | 39 + .../lead/ImportResultResponse.java | 13 + .../java/pl/polskalokalnie/lead/Lead.java | 211 ++++ .../polskalokalnie/lead/LeadController.java | 61 ++ .../lead/LeadCreateRequest.java | 19 + .../lead/LeadImportRequest.java | 14 + .../polskalokalnie/lead/LeadQueryService.java | 77 ++ .../polskalokalnie/lead/LeadRepository.java | 15 + .../pl/polskalokalnie/lead/LeadResponse.java | 42 + .../pl/polskalokalnie/lead/LeadService.java | 228 ++++ .../pl/polskalokalnie/lead/LeadSource.java | 12 + .../pl/polskalokalnie/lead/LeadStatus.java | 12 + .../polskalokalnie/lead/LeadSyncService.java | 63 ++ .../lead/LeadUpdateRequest.java | 22 + .../lead/MarketingConsentController.java | 74 ++ .../pl/polskalokalnie/lead/TargetGroup.java | 165 +++ .../lead/TargetGroupController.java | 56 + .../lead/TargetGroupPreviewResponse.java | 12 + .../lead/TargetGroupRepository.java | 6 + .../lead/TargetGroupRequest.java | 18 + .../lead/TargetGroupResponse.java | 39 + .../lead/TargetGroupService.java | 88 ++ .../mailconfig/MailConfigController.java | 80 ++ .../mailconfig/MailConfigService.java | 91 ++ .../mailconfig/SmsConfigRequest.java | 13 + .../mailconfig/SmsConfigResponse.java | 23 + .../mailconfig/SmsGatewayConfig.java | 81 ++ .../SmsGatewayConfigRepository.java | 6 + .../polskalokalnie/mailconfig/SmtpConfig.java | 114 ++ .../mailconfig/SmtpConfigRepository.java | 6 + .../mailconfig/SmtpConfigRequest.java | 17 + .../mailconfig/SmtpConfigResponse.java | 29 + .../send/CampaignDispatcher.java | 140 +++ .../pl/polskalokalnie/send/EmailSender.java | 83 ++ .../polskalokalnie/send/MessageComposer.java | 69 ++ .../pl/polskalokalnie/send/SendResult.java | 18 + .../pl/polskalokalnie/send/SmsSender.java | 73 ++ .../unsubscribe/UnsubscribeController.java | 44 + .../unsubscribe/UnsubscribeService.java | 112 ++ backend/src/main/resources/application.yml | 2 + frontend/src/App.tsx | 51 +- frontend/src/admin/marketing.tsx | 974 ++++++++++++++++++ frontend/src/styles.css | 105 ++ 59 files changed, 4169 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/Channel.java create mode 100644 backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java create mode 100644 backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/Lead.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java create mode 100644 backend/src/main/java/pl/polskalokalnie/send/EmailSender.java create mode 100644 backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java create mode 100644 backend/src/main/java/pl/polskalokalnie/send/SendResult.java create mode 100644 backend/src/main/java/pl/polskalokalnie/send/SmsSender.java create mode 100644 backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java create mode 100644 frontend/src/admin/marketing.tsx diff --git a/backend/pom.xml b/backend/pom.xml index c0bc397..5e07ec0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-mail + io.jsonwebtoken jjwt-api diff --git a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java index 39c5476..c48c380 100644 --- a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java +++ b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java @@ -3,9 +3,12 @@ package pl.polskalokalnie; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; // Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego // uzytkownika Spring Security (i mylacy log "Using generated security password"). +// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu. +@EnableScheduling @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) public class PolskaLokalnieApplication { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 69341f0..8f9bddd 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.LeadSyncService; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.LoginRequest; @@ -31,19 +32,22 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final TextModerationService textModerationService; + private final LeadSyncService leadSyncService; public AuthService( UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, PasswordEncoder passwordEncoder, JwtService jwtService, - TextModerationService textModerationService + TextModerationService textModerationService, + LeadSyncService leadSyncService ) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.passwordEncoder = passwordEncoder; this.jwtService = jwtService; this.textModerationService = textModerationService; + this.leadSyncService = leadSyncService; } public AuthResponse register(RegisterRequest request) { @@ -69,7 +73,9 @@ public class AuthService { // Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora. user.setVerified(false); - return buildAuthResponse(userRepository.save(user)); + AppUser saved = userRepository.save(user); + leadSyncService.syncUser(saved); + return buildAuthResponse(saved); } public AuthResponse login(LoginRequest request) { @@ -121,6 +127,7 @@ public class AuthService { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane"); } + leadSyncService.syncUser(user); return buildAuthResponse(user); } @@ -151,7 +158,9 @@ public class AuthService { user.setBirthDate(null); } - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + leadSyncService.syncUser(saved); + return UserResponse.from(saved); } private AuthResponse buildAuthResponse(AppUser user) { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 12f9e3b..6769d69 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -37,6 +37,7 @@ public class SecurityConfig { .requestMatchers("/error").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll() .requestMatchers("/api/i18n/translate").permitAll() + .requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll() .requestMatchers("/api/auth/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated() .requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll() diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java new file mode 100644 index 0000000..c272deb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java @@ -0,0 +1,171 @@ +package pl.polskalokalnie.campaign; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; +import org.hibernate.annotations.ColumnDefault; + +/** + * Kampania marketingowa wysylana jednym kanalem (e-mail lub SMS) do wskazanej grupy docelowej. + * Outbox (pozycje {@link CampaignRecipient}) budowany jest przy starcie wysylki; worker + * przetwarza go z dziennym limitem. Tresc zawiera znaczniki personalizacji i stopke z rezygnacja. + */ +@Entity +@Table(name = "campaigns") +public class Campaign { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 160) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Channel channel; + + // Temat dotyczy tylko kanalu e-mail. + @Column(length = 200) + private String subject; + + @Column(nullable = false, length = 4000) + private String body; + + @Column(nullable = false) + private Long targetGroupId; + + private Instant scheduledAt; + + @Column(nullable = false) + @ColumnDefault("200") + private Integer dailyLimit = 200; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'DRAFT'") + private CampaignStatus status = CampaignStatus.DRAFT; + + // Ile wyslano dzisiaj (do egzekwowania dziennego limitu) i data resetu licznika. + @Column(nullable = false) + @ColumnDefault("0") + private Integer sentToday = 0; + + private LocalDate sentTodayDate; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @Column(length = 180) + private String createdBy; + + @jakarta.persistence.PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Channel getChannel() { + return channel; + } + + public void setChannel(Channel channel) { + this.channel = channel; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public Long getTargetGroupId() { + return targetGroupId; + } + + public void setTargetGroupId(Long targetGroupId) { + this.targetGroupId = targetGroupId; + } + + public Instant getScheduledAt() { + return scheduledAt; + } + + public void setScheduledAt(Instant scheduledAt) { + this.scheduledAt = scheduledAt; + } + + public Integer getDailyLimit() { + return dailyLimit; + } + + public void setDailyLimit(Integer dailyLimit) { + this.dailyLimit = dailyLimit; + } + + public CampaignStatus getStatus() { + return status; + } + + public void setStatus(CampaignStatus status) { + this.status = status; + } + + public Integer getSentToday() { + return sentToday; + } + + public void setSentToday(Integer sentToday) { + this.sentToday = sentToday; + } + + public LocalDate getSentTodayDate() { + return sentTodayDate; + } + + public void setSentTodayDate(LocalDate sentTodayDate) { + this.sentTodayDate = sentTodayDate; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java new file mode 100644 index 0000000..6ce6e6c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java @@ -0,0 +1,72 @@ +package pl.polskalokalnie.campaign; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Kampanie marketingowe w panelu administratora. + */ +@RestController +@RequestMapping("/api/admin/campaigns") +public class CampaignController { + + private final CampaignService campaignService; + + public CampaignController(CampaignService campaignService) { + this.campaignService = campaignService; + } + + @GetMapping + public List list() { + return campaignService.list(); + } + + @GetMapping("/{id}") + public CampaignResponse get(@PathVariable Long id) { + return campaignService.get(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CampaignResponse create(@Valid @RequestBody CampaignRequest request, Authentication authentication) { + return campaignService.create(request, authentication.getName()); + } + + @PutMapping("/{id}") + public CampaignResponse update(@PathVariable Long id, @Valid @RequestBody CampaignRequest request) { + return campaignService.update(id, request); + } + + @PostMapping("/{id}/schedule") + public CampaignResponse schedule(@PathVariable Long id) { + return campaignService.schedule(id); + } + + @PostMapping("/{id}/pause") + public CampaignResponse pause(@PathVariable Long id) { + return campaignService.pause(id); + } + + @GetMapping("/{id}/stats") + public CampaignStatsResponse stats(@PathVariable Long id) { + return campaignService.stats(id); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + campaignService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java new file mode 100644 index 0000000..5a3f495 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java @@ -0,0 +1,147 @@ +package pl.polskalokalnie.campaign; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Pojedyncza pozycja outboxu kampanii - jeden odbiorca. Adres (e-mail/telefon) jest zapisany jako + * migawka w chwili budowy outboxu, zeby pozniejsze zmiany leada nie zmienialy juz zakolejkowanej + * wysylki. + */ +@Entity +@Table(name = "campaign_recipients", indexes = { + @Index(name = "idx_campaign_recipients_campaign", columnList = "campaignId"), + @Index(name = "idx_campaign_recipients_status", columnList = "status") +}) +public class CampaignRecipient { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long campaignId; + + private Long leadId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Channel channel; + + @Column(nullable = false, length = 200) + private String address; + + @Column(length = 160) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private RecipientStatus status = RecipientStatus.QUEUED; + + @Column(length = 500) + private String error; + + @Column(length = 200) + private String providerMessageId; + + private Instant sentAt; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public Long getCampaignId() { + return campaignId; + } + + public void setCampaignId(Long campaignId) { + this.campaignId = campaignId; + } + + public Long getLeadId() { + return leadId; + } + + public void setLeadId(Long leadId) { + this.leadId = leadId; + } + + public Channel getChannel() { + return channel; + } + + public void setChannel(Channel channel) { + this.channel = channel; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public RecipientStatus getStatus() { + return status; + } + + public void setStatus(RecipientStatus status) { + this.status = status; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public void setProviderMessageId(String providerMessageId) { + this.providerMessageId = providerMessageId; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java new file mode 100644 index 0000000..b3a8647 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.campaign; + +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CampaignRecipientRepository extends JpaRepository { + + List findByCampaignId(Long campaignId); + + long countByCampaignIdAndStatus(Long campaignId, RecipientStatus status); + + List findByCampaignIdAndStatusOrderByIdAsc(Long campaignId, RecipientStatus status, Limit limit); + + boolean existsByCampaignId(Long campaignId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java new file mode 100644 index 0000000..e84620c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.campaign; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CampaignRepository extends JpaRepository { + + List findByStatus(CampaignStatus status); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java new file mode 100644 index 0000000..7ebf1d6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java @@ -0,0 +1,17 @@ +package pl.polskalokalnie.campaign; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +public record CampaignRequest( + @NotBlank String name, + @NotNull Channel channel, + String subject, + @NotBlank String body, + @NotNull Long targetGroupId, + Instant scheduledAt, + @NotNull @Min(1) Integer dailyLimit +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java new file mode 100644 index 0000000..822f49a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.campaign; + +import java.time.Instant; + +public record CampaignResponse( + Long id, + String name, + Channel channel, + String subject, + String body, + Long targetGroupId, + Instant scheduledAt, + Integer dailyLimit, + CampaignStatus status, + Integer sentToday, + Instant createdAt, + String createdBy, + long totalRecipients, + long sentCount +) { + public static CampaignResponse from(Campaign campaign, long totalRecipients, long sentCount) { + return new CampaignResponse( + campaign.getId(), + campaign.getName(), + campaign.getChannel(), + campaign.getSubject(), + campaign.getBody(), + campaign.getTargetGroupId(), + campaign.getScheduledAt(), + campaign.getDailyLimit(), + campaign.getStatus(), + campaign.getSentToday(), + campaign.getCreatedAt(), + campaign.getCreatedBy(), + totalRecipients, + sentCount + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java new file mode 100644 index 0000000..081d78c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java @@ -0,0 +1,213 @@ +package pl.polskalokalnie.campaign; + +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.Lead; +import pl.polskalokalnie.lead.LeadQueryService; +import pl.polskalokalnie.lead.TargetGroup; +import pl.polskalokalnie.lead.TargetGroupRepository; + +/** + * Logika kampanii marketingowych: CRUD, budowa outboxu z grupy docelowej oraz sterowanie + * wysylka (harmonogram/pauza). Outbox respektuje zgody marketingowe i trwale rezygnacje - + * odbiorcy bez zgody lub po opt-oucie nie trafiaja do kolejki. Sama wysylka odbywa sie w workerze. + */ +@Service +public class CampaignService { + + private final CampaignRepository campaignRepository; + private final CampaignRecipientRepository recipientRepository; + private final TargetGroupRepository targetGroupRepository; + private final LeadQueryService leadQueryService; + + public CampaignService(CampaignRepository campaignRepository, + CampaignRecipientRepository recipientRepository, + TargetGroupRepository targetGroupRepository, + LeadQueryService leadQueryService) { + this.campaignRepository = campaignRepository; + this.recipientRepository = recipientRepository; + this.targetGroupRepository = targetGroupRepository; + this.leadQueryService = leadQueryService; + } + + public List list() { + return campaignRepository.findAll().stream() + .sorted(Comparator.comparing(Campaign::getCreatedAt).reversed()) + .map(this::toResponse) + .toList(); + } + + public CampaignResponse get(Long id) { + return toResponse(requireCampaign(id)); + } + + @Transactional + public CampaignResponse create(CampaignRequest request, String createdBy) { + requireGroup(request.targetGroupId()); + if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu"); + } + Campaign campaign = new Campaign(); + apply(campaign, request); + campaign.setStatus(CampaignStatus.DRAFT); + campaign.setCreatedBy(createdBy); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public CampaignResponse update(Long id, CampaignRequest request) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() != CampaignStatus.DRAFT) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Edytować można tylko kampanię w wersji roboczej"); + } + requireGroup(request.targetGroupId()); + if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu"); + } + apply(campaign, request); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public void delete(Long id) { + Campaign campaign = requireCampaign(id); + recipientRepository.deleteAll(recipientRepository.findByCampaignId(campaign.getId())); + campaignRepository.delete(campaign); + } + + /** + * Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania + * z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera + * wysylajacego dodajemy w kolejnej fazie. + */ + @Transactional + public CampaignResponse schedule(Long id) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() == CampaignStatus.COMPLETED || campaign.getStatus() == CampaignStatus.CANCELLED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Kampania jest już zakończona"); + } + if (!recipientRepository.existsByCampaignId(campaign.getId())) { + int built = buildOutbox(campaign); + if (built == 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Brak odbiorców spełniających kryteria (uwzględniono zgody i rezygnacje)"); + } + } + boolean future = campaign.getScheduledAt() != null && campaign.getScheduledAt().isAfter(Instant.now()); + campaign.setStatus(future ? CampaignStatus.SCHEDULED : CampaignStatus.SENDING); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public CampaignResponse pause(Long id) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() != CampaignStatus.SENDING && campaign.getStatus() != CampaignStatus.SCHEDULED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Wstrzymać można tylko kampanię w toku lub zaplanowaną"); + } + campaign.setStatus(CampaignStatus.PAUSED); + return toResponse(campaignRepository.save(campaign)); + } + + public CampaignStatsResponse stats(Long id) { + Campaign campaign = requireCampaign(id); + Long cid = campaign.getId(); + return new CampaignStatsResponse( + recipientRepository.findByCampaignId(cid).size(), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.QUEUED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.SENT), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.DELIVERED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.FAILED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.BOUNCED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.OPTED_OUT) + ); + } + + // Buduje pozycje outboxu dla leadow z grupy, z poszanowaniem zgod, rezygnacji i obecnosci adresu. + private int buildOutbox(Campaign campaign) { + TargetGroup group = requireGroup(campaign.getTargetGroupId()); + List members = leadQueryService.resolve(group); + Set seenAddresses = new LinkedHashSet<>(); + int built = 0; + for (Lead lead : members) { + String address = eligibleAddress(lead, campaign.getChannel()); + if (address == null) { + continue; + } + if (!seenAddresses.add(address.toLowerCase(Locale.ROOT))) { + continue; + } + CampaignRecipient recipient = new CampaignRecipient(); + recipient.setCampaignId(campaign.getId()); + recipient.setLeadId(lead.getId()); + recipient.setChannel(campaign.getChannel()); + recipient.setAddress(address); + recipient.setName(lead.getName()); + recipient.setStatus(RecipientStatus.QUEUED); + recipientRepository.save(recipient); + built++; + } + return built; + } + + // Zwraca adres kwalifikujacy leada do wysylki na danym kanale albo null, gdy nie kwalifikuje sie + // (brak adresu, brak zgody marketingowej lub trwala rezygnacja). + private static String eligibleAddress(Lead lead, Channel channel) { + if (channel == Channel.EMAIL) { + if (hasText(lead.getEmail()) && lead.isEmailConsent() && !lead.isEmailOptOut()) { + return lead.getEmail().trim(); + } + return null; + } + if (hasText(lead.getPhone()) && lead.isSmsConsent() && !lead.isSmsOptOut()) { + return lead.getPhone().trim(); + } + return null; + } + + private void apply(Campaign campaign, CampaignRequest request) { + campaign.setName(request.name().trim()); + campaign.setChannel(request.channel()); + campaign.setSubject(trimToNull(request.subject())); + campaign.setBody(request.body()); + campaign.setTargetGroupId(request.targetGroupId()); + campaign.setScheduledAt(request.scheduledAt()); + campaign.setDailyLimit(request.dailyLimit()); + } + + private Campaign requireCampaign(Long id) { + return campaignRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kampania nie istnieje")); + } + + private TargetGroup requireGroup(Long id) { + return targetGroupRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wskazana grupa docelowa nie istnieje")); + } + + private CampaignResponse toResponse(Campaign campaign) { + long total = recipientRepository.findByCampaignId(campaign.getId()).size(); + long sent = recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.SENT) + + recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.DELIVERED); + return CampaignResponse.from(campaign, total, sent); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java new file mode 100644 index 0000000..1e258f4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.campaign; + +/** + * Statystyki kampanii - agregat licznosci pozycji outboxu wg statusu. + */ +public record CampaignStatsResponse( + long total, + long queued, + long sent, + long delivered, + long failed, + long bounced, + long optedOut +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java new file mode 100644 index 0000000..bf1d074 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java @@ -0,0 +1,14 @@ +package pl.polskalokalnie.campaign; + +/** + * Cykl zycia kampanii. SENDING oznacza, ze outbox jest przetwarzany przez workera z limitem + * dziennym; PAUSED wstrzymuje wysylke bez utraty postepu; COMPLETED - caly outbox rozliczony. + */ +public enum CampaignStatus { + DRAFT, + SCHEDULED, + SENDING, + PAUSED, + COMPLETED, + CANCELLED +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java new file mode 100644 index 0000000..cc5aa1e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.campaign; + +public enum Channel { + EMAIL, + SMS +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java new file mode 100644 index 0000000..d1b0c50 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.campaign; + +/** + * Status pojedynczej pozycji outboxu kampanii. QUEUED czeka na wysylke; SENT wyslane do + * dostawcy; DELIVERED potwierdzone; FAILED/BOUNCED bledy; OPTED_OUT - odbiorca zrezygnowal + * przed wysylka i zostal pominiety. + */ +public enum RecipientStatus { + QUEUED, + SENT, + DELIVERED, + FAILED, + BOUNCED, + OPTED_OUT +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java new file mode 100644 index 0000000..0c1eebd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.config; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.ApplicationArguments; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.lead.LeadRepository; +import pl.polskalokalnie.lead.LeadSyncService; +import pl.polskalokalnie.user.UserRepository; + +/** + * Jednorazowy backfill: zakłada leady REGISTERED_USER dla istniejących kont, które ich jeszcze + * nie mają. Kolejne rejestracje/aktualizacje profilu utrzymują synchronizację na bieżąco. + * Uruchamia się po SchemaFixer, żeby tabele leadów były już gotowe. + */ +@Component +@Order(100) +public class LeadBackfillRunner implements ApplicationRunner { + + private final UserRepository userRepository; + private final LeadRepository leadRepository; + private final LeadSyncService leadSyncService; + + public LeadBackfillRunner(UserRepository userRepository, LeadRepository leadRepository, + LeadSyncService leadSyncService) { + this.userRepository = userRepository; + this.leadRepository = leadRepository; + this.leadSyncService = leadSyncService; + } + + @Override + public void run(ApplicationArguments args) { + userRepository.findAll().forEach(user -> { + if (leadRepository.findByUserId(user.getId()).isEmpty()) { + leadSyncService.syncUser(user); + } + }); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java new file mode 100644 index 0000000..dd79db3 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Podsumowanie importu: ile dodano, ile pominieto (duplikaty/braki) oraz komunikaty diagnostyczne. + */ +public record ImportResultResponse( + int imported, + int skipped, + List messages +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/Lead.java b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java new file mode 100644 index 0000000..8f9ce06 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java @@ -0,0 +1,211 @@ +package pl.polskalokalnie.lead; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.annotations.ColumnDefault; + +/** + * Kontakt marketingowy (lead) - jedno zrodlo prawdy dla odbiorcow kampanii. Zasilany z trzech + * zrodel: zarejestrowani uzytkownicy (synchronizacja), reczne dodanie/import oraz formularze. + * Zgody ({@code emailConsent}/{@code smsConsent}) i trwale rezygnacje ({@code emailOptOut}/ + * {@code smsOptOut}) decyduja o kwalifikacji leada do wysylki na danym kanale. + */ +@Entity +@Table(name = "leads", indexes = { + @Index(name = "idx_leads_email", columnList = "email"), + @Index(name = "idx_leads_phone", columnList = "phone"), + @Index(name = "idx_leads_user_id", columnList = "userId") +}) +public class Lead { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 160) + private String name; + + @Column(length = 180) + private String email; + + @Column(length = 30) + private String phone; + + @Column(length = 120) + private String city; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LeadSource source = LeadSource.MANUAL; + + // Powiazanie z kontem uzytkownika, gdy source = REGISTERED_USER. + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'NEW'") + private LeadStatus status = LeadStatus.NEW; + + // EAGER: tagi sa male i zawsze potrzebne w filtrach segmentow oraz odpowiedziach DTO + // (mapowanie odbywa sie poza sesja, bo open-in-view=false). + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable(name = "lead_tags", joinColumns = @JoinColumn(name = "lead_id")) + @Column(name = "tag", length = 60) + private List tags = new ArrayList<>(); + + @Column(nullable = false) + @ColumnDefault("true") + private boolean emailConsent = true; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean smsConsent = true; + + // Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje. + @Column(nullable = false) + @ColumnDefault("false") + private boolean emailOptOut = false; + + @Column(nullable = false) + @ColumnDefault("false") + private boolean smsOptOut = false; + + @Column(length = 2000) + private String notes; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public LeadSource getSource() { + return source; + } + + public void setSource(LeadSource source) { + this.source = source; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public LeadStatus getStatus() { + return status; + } + + public void setStatus(LeadStatus status) { + this.status = status; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public boolean isEmailConsent() { + return emailConsent; + } + + public void setEmailConsent(boolean emailConsent) { + this.emailConsent = emailConsent; + } + + public boolean isSmsConsent() { + return smsConsent; + } + + public void setSmsConsent(boolean smsConsent) { + this.smsConsent = smsConsent; + } + + public boolean isEmailOptOut() { + return emailOptOut; + } + + public void setEmailOptOut(boolean emailOptOut) { + this.emailOptOut = emailOptOut; + } + + public boolean isSmsOptOut() { + return smsOptOut; + } + + public void setSmsOptOut(boolean smsOptOut) { + this.smsOptOut = smsOptOut; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java new file mode 100644 index 0000000..dfa9c87 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java @@ -0,0 +1,61 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Zarzadzanie baza leadow z panelu administratora. Ochrona przez regule /api/admin/** w + * SecurityConfig (rola ADMIN). + */ +@RestController +@RequestMapping("/api/admin/leads") +public class LeadController { + + private final LeadService leadService; + + public LeadController(LeadService leadService) { + this.leadService = leadService; + } + + @GetMapping + public List list( + @RequestParam(required = false) LeadSource source, + @RequestParam(required = false) String search + ) { + return leadService.list(source, search); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public LeadResponse create(@Valid @RequestBody LeadCreateRequest request) { + return leadService.create(request); + } + + @PostMapping("/import") + public ImportResultResponse importCsv(@Valid @RequestBody LeadImportRequest request) { + return leadService.importCsv(request); + } + + @PatchMapping("/{id}") + public LeadResponse update(@PathVariable Long id, @Valid @RequestBody LeadUpdateRequest request) { + return leadService.update(id, request); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + leadService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java new file mode 100644 index 0000000..35bc08b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Reczne dodanie leada. Wymagany jest przynajmniej e-mail lub telefon - walidacja w serwisie, + * bo zalezy od obu pol naraz. + */ +public record LeadCreateRequest( + String name, + String email, + String phone, + String city, + List tags, + Boolean emailConsent, + Boolean smsConsent, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java new file mode 100644 index 0000000..b744cf6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java @@ -0,0 +1,14 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotBlank; + +/** + * Import listy kontaktow. Klient przesyla surowa tresc CSV (lub liste wierszy oddzielonych + * srednikami/przecinkami). Parsowanie i deduplikacja odbywa sie w serwisie. + * Oczekiwane kolumny: name;email;phone;city (naglowek opcjonalny). + */ +public record LeadImportRequest( + @NotBlank String csv, + String defaultTag +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java new file mode 100644 index 0000000..ea310c1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java @@ -0,0 +1,77 @@ +package pl.polskalokalnie.lead; + +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Wylicza czlonkow grupy docelowej (dynamicznego segmentu) na podstawie zapisanych kryteriow. + * Puste kryterium = brak ograniczenia. Nie wyklucza tu rezygnacji (opt-out) ani braku adresu - + * to robi budowa kampanii, bo zalezy od kanalu (e-mail vs SMS). + */ +@Service +public class LeadQueryService { + + private final LeadRepository leadRepository; + + public LeadQueryService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + public List resolve(TargetGroup group) { + return leadRepository.findAll().stream() + .filter(lead -> matches(lead, group)) + .toList(); + } + + public long count(TargetGroup group) { + return leadRepository.findAll().stream() + .filter(lead -> matches(lead, group)) + .count(); + } + + private boolean matches(Lead lead, TargetGroup group) { + if (group.getSources() != null && !group.getSources().isEmpty() + && !group.getSources().contains(lead.getSource())) { + return false; + } + if (hasText(group.getCity()) + && (lead.getCity() == null || !lead.getCity().trim().equalsIgnoreCase(group.getCity().trim()))) { + return false; + } + if (hasText(group.getTag()) && !containsTagIgnoreCase(lead.getTags(), group.getTag().trim())) { + return false; + } + if (group.getStatus() != null && lead.getStatus() != group.getStatus()) { + return false; + } + if (group.isRequireEmail() && !hasText(lead.getEmail())) { + return false; + } + if (group.isRequirePhone() && !hasText(lead.getPhone())) { + return false; + } + if (group.isRequireEmailConsent() && !lead.isEmailConsent()) { + return false; + } + if (group.isRequireSmsConsent() && !lead.isSmsConsent()) { + return false; + } + return true; + } + + private static boolean containsTagIgnoreCase(List tags, String tag) { + if (tags == null) { + return false; + } + for (String candidate : tags) { + if (candidate != null && candidate.trim().equalsIgnoreCase(tag)) { + return true; + } + } + return false; + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java new file mode 100644 index 0000000..41353a4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.lead; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LeadRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + Optional findFirstByEmailIgnoreCase(String email); + + Optional findFirstByPhone(String phone); + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java new file mode 100644 index 0000000..45b0576 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java @@ -0,0 +1,42 @@ +package pl.polskalokalnie.lead; + +import java.time.Instant; +import java.util.List; + +public record LeadResponse( + Long id, + String name, + String email, + String phone, + String city, + LeadSource source, + Long userId, + LeadStatus status, + List tags, + boolean emailConsent, + boolean smsConsent, + boolean emailOptOut, + boolean smsOptOut, + String notes, + Instant createdAt +) { + public static LeadResponse from(Lead lead) { + return new LeadResponse( + lead.getId(), + lead.getName(), + lead.getEmail(), + lead.getPhone(), + lead.getCity(), + lead.getSource(), + lead.getUserId(), + lead.getStatus(), + List.copyOf(lead.getTags()), + lead.isEmailConsent(), + lead.isSmsConsent(), + lead.isEmailOptOut(), + lead.isSmsOptOut(), + lead.getNotes(), + lead.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java new file mode 100644 index 0000000..c43add4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java @@ -0,0 +1,228 @@ +package pl.polskalokalnie.lead; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +/** + * Logika bazy leadow: listowanie z filtrami, reczne dodawanie, import listy, edycja i usuwanie. + * Deduplikacja po e-mailu i telefonie, dzieki czemu import i synchronizacja userow nie tworza + * podwojnych kontaktow. + */ +@Service +public class LeadService { + + private final LeadRepository leadRepository; + + public LeadService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + public List list(LeadSource source, String search) { + String needle = search == null ? "" : search.trim().toLowerCase(Locale.ROOT); + return leadRepository.findAll().stream() + .filter(lead -> source == null || lead.getSource() == source) + .filter(lead -> needle.isEmpty() || matchesSearch(lead, needle)) + .sorted(Comparator.comparing(Lead::getCreatedAt).reversed()) + .map(LeadResponse::from) + .toList(); + } + + @Transactional + public LeadResponse create(LeadCreateRequest request) { + String email = normalizeEmail(request.email()); + String phone = normalizePhone(request.phone()); + if (email == null && phone == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj co najmniej e-mail lub telefon"); + } + if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym adresem e-mail już istnieje"); + } + if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym numerem telefonu już istnieje"); + } + + Lead lead = new Lead(); + lead.setSource(LeadSource.MANUAL); + lead.setName(trimToNull(request.name())); + lead.setEmail(email); + lead.setPhone(phone); + lead.setCity(trimToNull(request.city())); + lead.setTags(cleanTags(request.tags())); + lead.setEmailConsent(request.emailConsent() == null || request.emailConsent()); + lead.setSmsConsent(request.smsConsent() == null || request.smsConsent()); + lead.setNotes(trimToNull(request.notes())); + return LeadResponse.from(leadRepository.save(lead)); + } + + @Transactional + public LeadResponse update(Long id, LeadUpdateRequest request) { + Lead lead = requireLead(id); + if (request.name() != null) { + lead.setName(trimToNull(request.name())); + } + if (request.email() != null) { + String email = normalizeEmail(request.email()); + if (email != null) { + leadRepository.findFirstByEmailIgnoreCase(email) + .filter(other -> !other.getId().equals(lead.getId())) + .ifPresent(other -> { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inny lead ma już ten e-mail"); + }); + } + lead.setEmail(email); + } + if (request.phone() != null) { + lead.setPhone(normalizePhone(request.phone())); + } + if (request.city() != null) { + lead.setCity(trimToNull(request.city())); + } + if (request.status() != null) { + lead.setStatus(request.status()); + } + if (request.tags() != null) { + lead.setTags(cleanTags(request.tags())); + } + if (request.emailConsent() != null) { + lead.setEmailConsent(request.emailConsent()); + } + if (request.smsConsent() != null) { + lead.setSmsConsent(request.smsConsent()); + } + // Opt-out to trwala rezygnacja - ustawiamy wylacznie na true, nie cofamy z panelu. + if (Boolean.TRUE.equals(request.emailOptOut())) { + lead.setEmailOptOut(true); + } + if (Boolean.TRUE.equals(request.smsOptOut())) { + lead.setSmsOptOut(true); + } + if (request.notes() != null) { + lead.setNotes(trimToNull(request.notes())); + } + return LeadResponse.from(leadRepository.save(lead)); + } + + @Transactional + public void delete(Long id) { + Lead lead = requireLead(id); + leadRepository.delete(lead); + } + + @Transactional + public ImportResultResponse importCsv(LeadImportRequest request) { + List messages = new ArrayList<>(); + int imported = 0; + int skipped = 0; + String defaultTag = trimToNull(request.defaultTag()); + + String[] lines = request.csv().replace("\r\n", "\n").replace("\r", "\n").split("\n"); + int lineNumber = 0; + for (String rawLine : lines) { + lineNumber++; + String line = rawLine.trim(); + if (line.isEmpty()) { + continue; + } + String[] parts = line.split("[;,\t]", -1); + String header = parts.length > 0 ? parts[0].trim().toLowerCase(Locale.ROOT) : ""; + // Pomijamy wiersz naglowka, jesli wyglada na etykiety kolumn. + if (lineNumber == 1 && (header.equals("name") || header.equals("imie") || header.equals("imię") + || header.equals("nazwa") || header.contains("email") || header.contains("e-mail"))) { + continue; + } + + String name = parts.length > 0 ? trimToNull(parts[0]) : null; + String email = normalizeEmail(parts.length > 1 ? parts[1] : null); + String phone = normalizePhone(parts.length > 2 ? parts[2] : null); + String city = parts.length > 3 ? trimToNull(parts[3]) : null; + + if (email == null && phone == null) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (brak e-maila i telefonu)"); + continue; + } + if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (e-mail już istnieje)"); + continue; + } + if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (telefon już istnieje)"); + continue; + } + + Lead lead = new Lead(); + lead.setSource(LeadSource.IMPORT); + lead.setName(name); + lead.setEmail(email); + lead.setPhone(phone); + lead.setCity(city); + if (defaultTag != null) { + lead.setTags(new ArrayList<>(List.of(defaultTag))); + } + leadRepository.save(lead); + imported++; + } + return new ImportResultResponse(imported, skipped, messages); + } + + private Lead requireLead(Long id) { + return leadRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nie istnieje")); + } + + private static boolean matchesSearch(Lead lead, String needle) { + return contains(lead.getName(), needle) + || contains(lead.getEmail(), needle) + || contains(lead.getPhone(), needle) + || contains(lead.getCity(), needle); + } + + private static boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private static List cleanTags(List tags) { + List cleaned = new ArrayList<>(); + if (tags != null) { + Set seen = new LinkedHashSet<>(); + for (String tag : tags) { + String trimmed = trimToNull(tag); + if (trimmed != null && seen.add(trimmed.toLowerCase(Locale.ROOT))) { + cleaned.add(trimmed); + } + } + } + return cleaned; + } + + private static String normalizeEmail(String email) { + String trimmed = trimToNull(email); + return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); + } + + private static String normalizePhone(String phone) { + if (phone == null) { + return null; + } + String cleaned = phone.replaceAll("[\\s\\-()]", "").trim(); + return cleaned.isEmpty() ? null : cleaned; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java new file mode 100644 index 0000000..6958e36 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +/** + * Zrodlo pochodzenia leada. REGISTERED_USER jest synchronizowany z kontem uzytkownika, + * pozostale powstaja recznie, przez import listy lub z formularzy na stronie. + */ +public enum LeadSource { + REGISTERED_USER, + MANUAL, + IMPORT, + FORM +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java new file mode 100644 index 0000000..cc9c57d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +/** + * Status kontaktu w lejku marketingowym. RESPONDED oznacza, ze lead odpowiedzial - takie + * kontakty mozna wykluczyc z kolejnych wysylek. + */ +public enum LeadStatus { + NEW, + CONTACTED, + RESPONDED, + UNSUBSCRIBED +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java new file mode 100644 index 0000000..6e208e1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java @@ -0,0 +1,63 @@ +package pl.polskalokalnie.lead; + +import java.util.Locale; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.polskalokalnie.user.AppUser; + +/** + * Utrzymuje leady typu REGISTERED_USER w zgodzie z kontami uzytkownikow. Wolane przy rejestracji + * i aktualizacji profilu oraz przez backfill przy starcie aplikacji. Zgody marketingowe (mail/SMS) + * uzytkownika przechowujemy na jego leadzie - to jedno zrodlo prawdy dla kwalifikacji do kampanii. + */ +@Service +public class LeadSyncService { + + private final LeadRepository leadRepository; + + public LeadSyncService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + @Transactional + public Lead syncUser(AppUser user) { + if (user == null || user.getId() == null) { + return null; + } + Lead lead = leadRepository.findByUserId(user.getId()) + .orElseGet(() -> leadRepository.findFirstByEmailIgnoreCase(user.getEmail()).orElseGet(Lead::new)); + + lead.setSource(LeadSource.REGISTERED_USER); + lead.setUserId(user.getId()); + lead.setName(user.getFullName()); + lead.setEmail(user.getEmail() == null ? null : user.getEmail().toLowerCase(Locale.ROOT)); + lead.setPhone(normalizePhone(user.getPhone())); + return leadRepository.save(lead); + } + + /** + * Zwraca lead powiazany z uzytkownikiem, tworzac go w razie potrzeby - uzywane przez ekran + * zgod marketingowych uzytkownika. + */ + @Transactional + public Lead getOrCreateForUser(AppUser user) { + Lead lead = leadRepository.findByUserId(user.getId()).orElse(null); + return lead != null ? lead : syncUser(user); + } + + @Transactional + public Lead updateConsentForUser(AppUser user, boolean emailConsent, boolean smsConsent) { + Lead lead = getOrCreateForUser(user); + lead.setEmailConsent(emailConsent); + lead.setSmsConsent(smsConsent); + return leadRepository.save(lead); + } + + private static String normalizePhone(String phone) { + if (phone == null) { + return null; + } + String cleaned = phone.replaceAll("[\\s\\-()]", "").trim(); + return cleaned.isEmpty() ? null : cleaned; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java new file mode 100644 index 0000000..2ade1ff --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Aktualizacja leada z panelu. Pola null pozostawiaja dotychczasowa wartosc; kolekcja tagow i + * flagi zgod, gdy podane, nadpisuja stan. Opt-out ustawiamy tylko na true (trwala rezygnacja). + */ +public record LeadUpdateRequest( + String name, + String email, + String phone, + String city, + LeadStatus status, + List tags, + Boolean emailConsent, + Boolean smsConsent, + Boolean emailOptOut, + Boolean smsOptOut, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java new file mode 100644 index 0000000..7bc69fc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java @@ -0,0 +1,74 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +/** + * Zgody marketingowe zalogowanego uzytkownika. Zapisywane na leadzie powiazanym z kontem, dzieki + * czemu realnie steruja kwalifikacja uzytkownika jako odbiorcy kampanii. Endpoint wymaga + * uwierzytelnienia (regula anyRequest().authenticated() w SecurityConfig). + */ +@RestController +@RequestMapping("/api/me/marketing") +public class MarketingConsentController { + + private final UserRepository userRepository; + private final LeadSyncService leadSyncService; + + public MarketingConsentController(UserRepository userRepository, LeadSyncService leadSyncService) { + this.userRepository = userRepository; + this.leadSyncService = leadSyncService; + } + + @GetMapping + public MarketingConsentResponse get(Authentication authentication) { + AppUser user = currentUser(authentication); + Lead lead = leadSyncService.getOrCreateForUser(user); + return MarketingConsentResponse.from(lead); + } + + @PutMapping + public MarketingConsentResponse update(Authentication authentication, @RequestBody MarketingConsentRequest request) { + AppUser user = currentUser(authentication); + Lead lead = leadSyncService.updateConsentForUser(user, + Boolean.TRUE.equals(request.emailConsent()), + Boolean.TRUE.equals(request.smsConsent())); + return MarketingConsentResponse.from(lead); + } + + private AppUser currentUser(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + } + + public record MarketingConsentRequest( + @NotNull Boolean emailConsent, + @NotNull Boolean smsConsent + ) { + } + + public record MarketingConsentResponse( + boolean emailConsent, + boolean smsConsent, + boolean emailOptOut, + boolean smsOptOut + ) { + static MarketingConsentResponse from(Lead lead) { + return new MarketingConsentResponse( + lead.isEmailConsent(), + lead.isSmsConsent(), + lead.isEmailOptOut(), + lead.isSmsOptOut() + ); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java new file mode 100644 index 0000000..3058aab --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java @@ -0,0 +1,165 @@ +package pl.polskalokalnie.lead; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Grupa docelowa jako dynamiczny segment - przechowuje zestaw kryteriow filtra, a nie zamrozona + * liste leadow. Czlonkowie wyliczaja sie na biezaco w {@link LeadQueryService} przy podgladzie + * i przy starcie kampanii. Puste kryterium oznacza brak ograniczenia dla danego pola. + */ +@Entity +@Table(name = "target_groups") +public class TargetGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 140) + private String name; + + @Column(length = 400) + private String description; + + // EAGER: zbior zrodel jest maly i zawsze uzywany przy wyliczaniu segmentu oraz w DTO + // (mapowanie poza sesja - open-in-view=false). + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable(name = "target_group_sources", joinColumns = @JoinColumn(name = "group_id")) + @Column(name = "source", length = 20) + @Enumerated(EnumType.STRING) + private Set sources = new LinkedHashSet<>(); + + @Column(length = 120) + private String city; + + @Column(length = 60) + private String tag; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private LeadStatus status; + + @Column(nullable = false) + private boolean requireEmail = false; + + @Column(nullable = false) + private boolean requirePhone = false; + + @Column(nullable = false) + private boolean requireEmailConsent = false; + + @Column(nullable = false) + private boolean requireSmsConsent = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Set getSources() { + return sources; + } + + public void setSources(Set sources) { + this.sources = sources; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public LeadStatus getStatus() { + return status; + } + + public void setStatus(LeadStatus status) { + this.status = status; + } + + public boolean isRequireEmail() { + return requireEmail; + } + + public void setRequireEmail(boolean requireEmail) { + this.requireEmail = requireEmail; + } + + public boolean isRequirePhone() { + return requirePhone; + } + + public void setRequirePhone(boolean requirePhone) { + this.requirePhone = requirePhone; + } + + public boolean isRequireEmailConsent() { + return requireEmailConsent; + } + + public void setRequireEmailConsent(boolean requireEmailConsent) { + this.requireEmailConsent = requireEmailConsent; + } + + public boolean isRequireSmsConsent() { + return requireSmsConsent; + } + + public void setRequireSmsConsent(boolean requireSmsConsent) { + this.requireSmsConsent = requireSmsConsent; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java new file mode 100644 index 0000000..9e77d26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java @@ -0,0 +1,56 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Grupy docelowe (dynamiczne segmenty) w panelu administratora. + */ +@RestController +@RequestMapping("/api/admin/target-groups") +public class TargetGroupController { + + private final TargetGroupService targetGroupService; + + public TargetGroupController(TargetGroupService targetGroupService) { + this.targetGroupService = targetGroupService; + } + + @GetMapping + public List list() { + return targetGroupService.list(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public TargetGroupResponse create(@Valid @RequestBody TargetGroupRequest request) { + return targetGroupService.create(request); + } + + @PutMapping("/{id}") + public TargetGroupResponse update(@PathVariable Long id, @Valid @RequestBody TargetGroupRequest request) { + return targetGroupService.update(id, request); + } + + @GetMapping("/{id}/preview") + public TargetGroupPreviewResponse preview(@PathVariable Long id) { + return targetGroupService.preview(id); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + targetGroupService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java new file mode 100644 index 0000000..2372d85 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Podglad segmentu: laczna liczba dopasowanych leadow oraz krotka probka do wyswietlenia w panelu. + */ +public record TargetGroupPreviewResponse( + long total, + List sample +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java new file mode 100644 index 0000000..79d1e88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.lead; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TargetGroupRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java new file mode 100644 index 0000000..25951c2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java @@ -0,0 +1,18 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotBlank; +import java.util.Set; + +public record TargetGroupRequest( + @NotBlank String name, + String description, + Set sources, + String city, + String tag, + LeadStatus status, + boolean requireEmail, + boolean requirePhone, + boolean requireEmailConsent, + boolean requireSmsConsent +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java new file mode 100644 index 0000000..1b8917d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.lead; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +public record TargetGroupResponse( + Long id, + String name, + String description, + Set sources, + String city, + String tag, + LeadStatus status, + boolean requireEmail, + boolean requirePhone, + boolean requireEmailConsent, + boolean requireSmsConsent, + long memberCount, + Instant createdAt +) { + public static TargetGroupResponse from(TargetGroup group, long memberCount) { + return new TargetGroupResponse( + group.getId(), + group.getName(), + group.getDescription(), + new LinkedHashSet<>(group.getSources()), + group.getCity(), + group.getTag(), + group.getStatus(), + group.isRequireEmail(), + group.isRequirePhone(), + group.isRequireEmailConsent(), + group.isRequireSmsConsent(), + memberCount, + group.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java new file mode 100644 index 0000000..72c9662 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java @@ -0,0 +1,88 @@ +package pl.polskalokalnie.lead; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class TargetGroupService { + + private static final int PREVIEW_SAMPLE_SIZE = 20; + + private final TargetGroupRepository targetGroupRepository; + private final LeadQueryService leadQueryService; + + public TargetGroupService(TargetGroupRepository targetGroupRepository, LeadQueryService leadQueryService) { + this.targetGroupRepository = targetGroupRepository; + this.leadQueryService = leadQueryService; + } + + public List list() { + return targetGroupRepository.findAll().stream() + .sorted(Comparator.comparing(TargetGroup::getCreatedAt).reversed()) + .map(group -> TargetGroupResponse.from(group, leadQueryService.count(group))) + .toList(); + } + + @Transactional + public TargetGroupResponse create(TargetGroupRequest request) { + TargetGroup group = new TargetGroup(); + apply(group, request); + TargetGroup saved = targetGroupRepository.save(group); + return TargetGroupResponse.from(saved, leadQueryService.count(saved)); + } + + @Transactional + public TargetGroupResponse update(Long id, TargetGroupRequest request) { + TargetGroup group = requireGroup(id); + apply(group, request); + TargetGroup saved = targetGroupRepository.save(group); + return TargetGroupResponse.from(saved, leadQueryService.count(saved)); + } + + @Transactional + public void delete(Long id) { + TargetGroup group = requireGroup(id); + targetGroupRepository.delete(group); + } + + public TargetGroupPreviewResponse preview(Long id) { + TargetGroup group = requireGroup(id); + List members = leadQueryService.resolve(group); + List sample = members.stream() + .limit(PREVIEW_SAMPLE_SIZE) + .map(LeadResponse::from) + .toList(); + return new TargetGroupPreviewResponse(members.size(), sample); + } + + TargetGroup requireGroup(Long id) { + return targetGroupRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Grupa docelowa nie istnieje")); + } + + private void apply(TargetGroup group, TargetGroupRequest request) { + group.setName(request.name().trim()); + group.setDescription(trimToNull(request.description())); + group.setSources(request.sources() == null ? new LinkedHashSet<>() : new LinkedHashSet<>(request.sources())); + group.setCity(trimToNull(request.city())); + group.setTag(trimToNull(request.tag())); + group.setStatus(request.status()); + group.setRequireEmail(request.requireEmail()); + group.setRequirePhone(request.requirePhone()); + group.setRequireEmailConsent(request.requireEmailConsent()); + group.setRequireSmsConsent(request.requireSmsConsent()); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java new file mode 100644 index 0000000..7c85161 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java @@ -0,0 +1,80 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.send.EmailSender; +import pl.polskalokalnie.send.SendResult; +import pl.polskalokalnie.send.SmsSender; + +/** + * Konfiguracja poczty SMTP i bramki SMS w panelu administratora oraz kontrolowana wysylka testowa + * do pojedynczego odbiorcy. Sekrety nigdy nie sa zwracane - w odpowiedzi jedynie flaga "ustawione". + */ +@RestController +@RequestMapping("/api/admin/mail-config") +public class MailConfigController { + + private final MailConfigService mailConfigService; + private final EmailSender emailSender; + private final SmsSender smsSender; + + public MailConfigController(MailConfigService mailConfigService, EmailSender emailSender, SmsSender smsSender) { + this.mailConfigService = mailConfigService; + this.emailSender = emailSender; + this.smsSender = smsSender; + } + + @GetMapping("/smtp") + public SmtpConfigResponse getSmtp() { + return SmtpConfigResponse.from(mailConfigService.getSmtp()); + } + + @PutMapping("/smtp") + public SmtpConfigResponse updateSmtp(@RequestBody SmtpConfigRequest request) { + return SmtpConfigResponse.from(mailConfigService.updateSmtp(request)); + } + + @GetMapping("/sms") + public SmsConfigResponse getSms() { + return SmsConfigResponse.from(mailConfigService.getSms()); + } + + @PutMapping("/sms") + public SmsConfigResponse updateSms(@RequestBody SmsConfigRequest request) { + return SmsConfigResponse.from(mailConfigService.updateSms(request)); + } + + @PostMapping("/smtp/test") + public TestResultResponse testSmtp(@Valid @RequestBody TestSendRequest request) { + SendResult result = emailSender.send( + mailConfigService.getSmtp(), + request.recipient().trim(), + "Test konfiguracji SMTP – Polska Lokalnie", + "

To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.

"); + return TestResultResponse.from(result); + } + + @PostMapping("/sms/test") + public TestResultResponse testSms(@Valid @RequestBody TestSendRequest request) { + SendResult result = smsSender.send( + mailConfigService.getSms(), + request.recipient().trim(), + "Test konfiguracji bramki SMS – Polska Lokalnie"); + return TestResultResponse.from(result); + } + + public record TestSendRequest(@NotBlank String recipient) { + } + + public record TestResultResponse(boolean ok, String providerMessageId, String error) { + static TestResultResponse from(SendResult result) { + return new TestResultResponse(result.ok(), result.providerMessageId(), result.error()); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java new file mode 100644 index 0000000..baa314a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java @@ -0,0 +1,91 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Dostep do jednorekordowej konfiguracji SMTP i bramki SMS. Sekrety (haslo, klucz API) sa + * nadpisywane tylko przy jawnej zmianie; puste pole w zadaniu zachowuje dotychczasowa wartosc. + * Metody get* zwracaja encje z sekretami wylacznie na uzytek wewnetrzny (sendery) - nie sa + * serializowane do klienta. + */ +@Service +public class MailConfigService { + + private static final long SINGLETON_ID = 1L; + + private final SmtpConfigRepository smtpConfigRepository; + private final SmsGatewayConfigRepository smsGatewayConfigRepository; + + public MailConfigService(SmtpConfigRepository smtpConfigRepository, + SmsGatewayConfigRepository smsGatewayConfigRepository) { + this.smtpConfigRepository = smtpConfigRepository; + this.smsGatewayConfigRepository = smsGatewayConfigRepository; + } + + @Transactional + public SmtpConfig getSmtp() { + return smtpConfigRepository.findById(SINGLETON_ID).orElseGet(() -> { + SmtpConfig config = new SmtpConfig(); + config.setId(SINGLETON_ID); + return smtpConfigRepository.save(config); + }); + } + + @Transactional + public SmsGatewayConfig getSms() { + return smsGatewayConfigRepository.findById(SINGLETON_ID).orElseGet(() -> { + SmsGatewayConfig config = new SmsGatewayConfig(); + config.setId(SINGLETON_ID); + return smsGatewayConfigRepository.save(config); + }); + } + + @Transactional + public SmtpConfig updateSmtp(SmtpConfigRequest request) { + SmtpConfig config = getSmtp(); + config.setHost(trimToNull(request.host())); + config.setPort(request.port()); + if (request.sslEnabled() != null) { + config.setSslEnabled(request.sslEnabled()); + } + config.setUsername(trimToNull(request.username())); + config.setFromName(trimToNull(request.fromName())); + config.setContactFormRecipient(trimToNull(request.contactFormRecipient())); + if (request.enabled() != null) { + config.setEnabled(request.enabled()); + } + // Haslo zmieniamy tylko gdy podane (niepuste) - inaczej zachowujemy dotychczasowe. + String password = request.password(); + if (password != null && !password.isBlank()) { + config.setPassword(password); + } + return smtpConfigRepository.save(config); + } + + @Transactional + public SmsGatewayConfig updateSms(SmsConfigRequest request) { + SmsGatewayConfig config = getSms(); + config.setEndpointUrl(trimToNull(request.endpointUrl())); + config.setCreator(trimToNull(request.creator())); + if (request.timeoutSeconds() != null) { + config.setTimeoutSeconds(request.timeoutSeconds()); + } + if (request.enabled() != null) { + config.setEnabled(request.enabled()); + } + String apiKey = request.apiKey(); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + return smsGatewayConfigRepository.save(config); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java new file mode 100644 index 0000000..7b76afa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Zapis konfiguracji bramki SMS. Pole {@code apiKey} nadpisuje klucz tylko gdy niepuste. + */ +public record SmsConfigRequest( + String endpointUrl, + String apiKey, + String creator, + Integer timeoutSeconds, + Boolean enabled +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java new file mode 100644 index 0000000..de6dafb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java @@ -0,0 +1,23 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Odpowiedz konfiguracji bramki SMS - bez klucza API. {@code apiKeySet} sygnalizuje jedynie, czy + * klucz jest zapisany (badge "ustawione"). + */ +public record SmsConfigResponse( + String endpointUrl, + String creator, + Integer timeoutSeconds, + boolean enabled, + boolean apiKeySet +) { + public static SmsConfigResponse from(SmsGatewayConfig config) { + return new SmsConfigResponse( + config.getEndpointUrl(), + config.getCreator(), + config.getTimeoutSeconds(), + config.isEnabled(), + config.getApiKey() != null && !config.getApiKey().isBlank() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java new file mode 100644 index 0000000..1357b88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java @@ -0,0 +1,81 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja bramki SMS (SoftSPM) - pojedynczy rekord (id = 1). Klucz API trzymany w bazie, + * nigdy zwracany w odpowiedziach API (maskowany) ani logowany. + */ +@Entity +@Table(name = "sms_gateway_config") +public class SmsGatewayConfig { + + @Id + private Long id = 1L; + + @Column(length = 400) + private String endpointUrl; + + @Column(length = 400) + private String apiKey; + + // Pole "creator" bramki - znacznik zrodla wiadomosci, max 50 znakow. + @Column(length = 50) + private String creator; + + private Integer timeoutSeconds = 10; + + @Column(nullable = false) + private boolean enabled = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEndpointUrl() { + return endpointUrl; + } + + public void setEndpointUrl(String endpointUrl) { + this.endpointUrl = endpointUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public Integer getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(Integer timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java new file mode 100644 index 0000000..cd2d90d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmsGatewayConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java new file mode 100644 index 0000000..9cd39e5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java @@ -0,0 +1,114 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja konta e-mail (SMTP) - pojedynczy rekord (id = 1). Haslo trzymane w bazie, nigdy + * zwracane w odpowiedziach API (maskowane) ani logowane. + */ +@Entity +@Table(name = "smtp_config") +public class SmtpConfig { + + @Id + private Long id = 1L; + + @Column(length = 200) + private String host; + + private Integer port; + + @Column(nullable = false) + private boolean sslEnabled = true; + + // Login SMTP = adres nadawcy. + @Column(length = 200) + private String username; + + @Column(length = 160) + private String fromName; + + @Column(length = 400) + private String password; + + @Column(length = 200) + private String contactFormRecipient; + + @Column(nullable = false) + private boolean enabled = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + public boolean isSslEnabled() { + return sslEnabled; + } + + public void setSslEnabled(boolean sslEnabled) { + this.sslEnabled = sslEnabled; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFromName() { + return fromName; + } + + public void setFromName(String fromName) { + this.fromName = fromName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getContactFormRecipient() { + return contactFormRecipient; + } + + public void setContactFormRecipient(String contactFormRecipient) { + this.contactFormRecipient = contactFormRecipient; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java new file mode 100644 index 0000000..bbaa406 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmtpConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java new file mode 100644 index 0000000..c15cc26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java @@ -0,0 +1,17 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Zapis konfiguracji SMTP. Pole {@code password} nadpisuje haslo tylko gdy niepuste - puste + * pozostawia dotychczasowe (wzorzec "wpisz, aby zmienic"). + */ +public record SmtpConfigRequest( + String host, + Integer port, + Boolean sslEnabled, + String username, + String fromName, + String password, + String contactFormRecipient, + Boolean enabled +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java new file mode 100644 index 0000000..5cd2da4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Odpowiedz konfiguracji SMTP - bez hasla. {@code passwordSet} mowi tylko, czy haslo jest + * zapisane (badge "ustawione"), sama wartosc nigdy nie opuszcza serwera. + */ +public record SmtpConfigResponse( + String host, + Integer port, + boolean sslEnabled, + String username, + String fromName, + String contactFormRecipient, + boolean enabled, + boolean passwordSet +) { + public static SmtpConfigResponse from(SmtpConfig config) { + return new SmtpConfigResponse( + config.getHost(), + config.getPort(), + config.isSslEnabled(), + config.getUsername(), + config.getFromName(), + config.getContactFormRecipient(), + config.isEnabled(), + config.getPassword() != null && !config.getPassword().isBlank() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java new file mode 100644 index 0000000..26d1caa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java @@ -0,0 +1,140 @@ +package pl.polskalokalnie.send; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.campaign.Campaign; +import pl.polskalokalnie.campaign.CampaignRecipient; +import pl.polskalokalnie.campaign.CampaignRecipientRepository; +import pl.polskalokalnie.campaign.CampaignRepository; +import pl.polskalokalnie.campaign.CampaignStatus; +import pl.polskalokalnie.campaign.Channel; +import pl.polskalokalnie.campaign.RecipientStatus; +import pl.polskalokalnie.mailconfig.MailConfigService; +import pl.polskalokalnie.mailconfig.SmsGatewayConfig; +import pl.polskalokalnie.mailconfig.SmtpConfig; + +/** + * Worker wysylajacy kampanie z outboxu. Cyklicznie promuje zaplanowane kampanie, ktorych termin + * nadszedl, a nastepnie wysyla zakolejkowane wiadomosci respektujac dzienny limit. Wysylka + * odbywa sie tylko gdy odpowiednia konfiguracja (SMTP/SMS) jest wlaczona - w przeciwnym razie + * pozycje pozostaja w kolejce (tryb sandbox). Jeden przebieg przetwarza ograniczona partie, by + * nie blokowac watku schedulera. + */ +@Component +public class CampaignDispatcher { + + private static final int BATCH_PER_TICK = 25; + + private final CampaignRepository campaignRepository; + private final CampaignRecipientRepository recipientRepository; + private final MailConfigService mailConfigService; + private final EmailSender emailSender; + private final SmsSender smsSender; + private final MessageComposer messageComposer; + + public CampaignDispatcher(CampaignRepository campaignRepository, + CampaignRecipientRepository recipientRepository, + MailConfigService mailConfigService, + EmailSender emailSender, + SmsSender smsSender, + MessageComposer messageComposer) { + this.campaignRepository = campaignRepository; + this.recipientRepository = recipientRepository; + this.mailConfigService = mailConfigService; + this.emailSender = emailSender; + this.smsSender = smsSender; + this.messageComposer = messageComposer; + } + + @Scheduled(fixedDelayString = "${app.campaign.dispatch-interval-ms:15000}", initialDelay = 20000) + public void dispatch() { + promoteScheduled(); + for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) { + processCampaign(campaign); + } + } + + private void promoteScheduled() { + Instant now = Instant.now(); + for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SCHEDULED)) { + if (campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now)) { + campaign.setStatus(CampaignStatus.SENDING); + campaignRepository.save(campaign); + } + } + } + + private void processCampaign(Campaign campaign) { + // Reset dziennego licznika przy zmianie daty. + LocalDate today = LocalDate.now(); + if (!today.equals(campaign.getSentTodayDate())) { + campaign.setSentToday(0); + campaign.setSentTodayDate(today); + campaignRepository.save(campaign); + } + + int dailyLimit = campaign.getDailyLimit() != null ? campaign.getDailyLimit() : Integer.MAX_VALUE; + int remainingToday = dailyLimit - (campaign.getSentToday() != null ? campaign.getSentToday() : 0); + if (remainingToday <= 0) { + return; + } + + // Konfiguracja wlaczona? Jesli nie - tryb sandbox, nic nie wychodzi (pozycje zostaja w kolejce). + SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null; + SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null; + if (campaign.getChannel() == Channel.EMAIL && (smtp == null || !smtp.isEnabled())) { + return; + } + if (campaign.getChannel() == Channel.SMS && (sms == null || !sms.isEnabled())) { + return; + } + + int batch = Math.min(BATCH_PER_TICK, remainingToday); + List queued = recipientRepository + .findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(batch)); + + int sentNow = 0; + for (CampaignRecipient recipient : queued) { + SendResult result = campaign.getChannel() == Channel.EMAIL + ? sendEmail(campaign, recipient, smtp) + : sendSms(campaign, recipient, sms); + + recipient.setSentAt(Instant.now()); + if (result.ok()) { + recipient.setStatus(RecipientStatus.SENT); + recipient.setProviderMessageId(result.providerMessageId()); + recipient.setError(null); + sentNow++; + } else { + recipient.setStatus(RecipientStatus.FAILED); + recipient.setError(result.error()); + } + recipientRepository.save(recipient); + } + + if (sentNow > 0) { + campaign.setSentToday((campaign.getSentToday() != null ? campaign.getSentToday() : 0) + sentNow); + campaignRepository.save(campaign); + } + + // Zakonczenie kampanii, gdy nie ma juz nic w kolejce. + if (recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.QUEUED) == 0) { + campaign.setStatus(CampaignStatus.COMPLETED); + campaignRepository.save(campaign); + } + } + + private SendResult sendEmail(Campaign campaign, CampaignRecipient recipient, SmtpConfig smtp) { + String html = messageComposer.composeEmail(campaign.getBody(), recipient.getName(), recipient.getLeadId()); + return emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), html); + } + + private SendResult sendSms(Campaign campaign, CampaignRecipient recipient, SmsGatewayConfig sms) { + String text = messageComposer.composeSms(campaign.getBody(), recipient.getName(), recipient.getLeadId()); + return smsSender.send(sms, recipient.getAddress(), text); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java new file mode 100644 index 0000000..d1c9759 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java @@ -0,0 +1,83 @@ +package pl.polskalokalnie.send; + +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.mailconfig.SmtpConfig; + +/** + * Wysylka pojedynczego e-maila przez konto SMTP z konfiguracji. JavaMailSender budowany jest per + * wywolanie, bo konfiguracja moze zmienic sie w czasie dzialania aplikacji (niski wolumen). + * Dla portu 465 wlacza SSL, w przeciwnym razie STARTTLS. + */ +@Component +public class EmailSender { + + public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) { + if (config == null || config.getHost() == null || config.getHost().isBlank() + || config.getUsername() == null || config.getUsername().isBlank()) { + return SendResult.failure("Konfiguracja SMTP jest niekompletna"); + } + try { + JavaMailSenderImpl mailSender = buildMailSender(config); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); + helper.setFrom(fromAddress(config)); + helper.setTo(to); + helper.setSubject(subject == null ? "" : subject); + helper.setText(htmlBody, true); + mailSender.send(message); + String messageId = message.getMessageID(); + return SendResult.ok(messageId); + } catch (Exception ex) { + return SendResult.failure(shorten(ex.getMessage())); + } + } + + private JavaMailSenderImpl buildMailSender(SmtpConfig config) { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(config.getHost()); + int port = config.getPort() != null ? config.getPort() : (config.isSslEnabled() ? 465 : 587); + mailSender.setPort(port); + mailSender.setUsername(config.getUsername()); + mailSender.setPassword(config.getPassword()); + mailSender.setDefaultEncoding(StandardCharsets.UTF_8.name()); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.connectiontimeout", "10000"); + props.put("mail.smtp.timeout", "15000"); + props.put("mail.smtp.writetimeout", "15000"); + if (config.isSslEnabled()) { + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.ssl.protocols", "TLSv1.2 TLSv1.3"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + } + return mailSender; + } + + private InternetAddress fromAddress(SmtpConfig config) throws UnsupportedEncodingException { + try { + if (config.getFromName() != null && !config.getFromName().isBlank()) { + return new InternetAddress(config.getUsername(), config.getFromName(), StandardCharsets.UTF_8.name()); + } + return new InternetAddress(config.getUsername()); + } catch (Exception ex) { + throw new UnsupportedEncodingException("Nieprawidłowy adres nadawcy"); + } + } + + private static String shorten(String message) { + if (message == null) { + return "Nieznany błąd wysyłki"; + } + return message.length() > 480 ? message.substring(0, 480) : message; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java new file mode 100644 index 0000000..bf9b796 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java @@ -0,0 +1,69 @@ +package pl.polskalokalnie.send; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.unsubscribe.UnsubscribeService; + +/** + * Sklada finalna tresc wiadomosci: personalizacja znacznikow ({{name}}) oraz obowiazkowa stopka z + * linkiem rezygnacji. Kazda wiadomosc marketingowa musi umozliwiac wypisanie sie - link generuje + * podpisany token per lead i kanal. + */ +@Component +public class MessageComposer { + + private final UnsubscribeService unsubscribeService; + private final String baseUrl; + + public MessageComposer(UnsubscribeService unsubscribeService, + @Value("${app.public-base-url:http://localhost}") String baseUrl) { + this.unsubscribeService = unsubscribeService; + this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); + } + + /** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */ + public String composeEmail(String rawBody, String recipientName, Long leadId) { + String personalized = personalize(rawBody, recipientName); + String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized); + String link = unsubscribeLink(leadId, "email"); + String footer = "
" + + "

" + + "Otrzymujesz tę wiadomość, ponieważ wyraziłeś zgodę na kontakt marketingowy. " + + "Zrezygnuj z otrzymywania wiadomości.

"; + return "
" + html + footer + "
"; + } + + /** Tresc SMS z personalizacja i krotka informacja o rezygnacji. */ + public String composeSms(String rawBody, String recipientName, Long leadId) { + String personalized = personalize(rawBody, recipientName).trim(); + String link = unsubscribeLink(leadId, "sms"); + return personalized + "\nRezygnacja: " + link; + } + + private String unsubscribeLink(Long leadId, String channel) { + return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(leadId, channel); + } + + private static String personalize(String body, String recipientName) { + String name = recipientName == null || recipientName.isBlank() ? "" : recipientName.trim(); + String value = body == null ? "" : body; + // {{name}} -> imie/nazwa; gdy brak, usuwamy podwojne spacje po podstawieniu pustej wartosci. + return value.replace("{{name}}", name).replace("{{ name }}", name); + } + + private static boolean looksLikeHtml(String value) { + return value != null && value.contains("<") && value.contains(">"); + } + + private static String textToHtml(String text) { + return escapeHtml(text).replace("\r\n", "\n").replace("\n", "
"); + } + + private static String escapeHtml(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SendResult.java b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java new file mode 100644 index 0000000..9677de7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java @@ -0,0 +1,18 @@ +package pl.polskalokalnie.send; + +/** + * Wynik proby wysylki pojedynczej wiadomosci przez sender (e-mail/SMS). + */ +public record SendResult( + boolean ok, + String providerMessageId, + String error +) { + public static SendResult ok(String providerMessageId) { + return new SendResult(true, providerMessageId, null); + } + + public static SendResult failure(String error) { + return new SendResult(false, null, error); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java new file mode 100644 index 0000000..506f8d1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java @@ -0,0 +1,73 @@ +package pl.polskalokalnie.send; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.mailconfig.SmsGatewayConfig; + +/** + * Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST + * (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji. + * + * UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z + * dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac. + */ +@Component +public class SmsSender { + + private static final String PARAM_API_KEY = "apiKey"; + private static final String PARAM_CREATOR = "creator"; + private static final String PARAM_TO = "to"; + private static final String PARAM_MESSAGE = "message"; + + public SendResult send(SmsGatewayConfig config, String phone, String message) { + if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank() + || config.getApiKey() == null || config.getApiKey().isBlank()) { + return SendResult.failure("Konfiguracja bramki SMS jest niekompletna"); + } + try { + int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0 + ? config.getTimeoutSeconds() : 10; + String form = PARAM_API_KEY + "=" + enc(config.getApiKey()) + + "&" + PARAM_CREATOR + "=" + enc(config.getCreator()) + + "&" + PARAM_TO + "=" + enc(phone) + + "&" + PARAM_MESSAGE + "=" + enc(message); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(timeout)) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getEndpointUrl())) + .timeout(Duration.ofSeconds(timeout)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return SendResult.ok(shorten(body)); + } + return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode() + + (body.isEmpty() ? "" : ": " + shorten(body))); + } catch (Exception ex) { + return SendResult.failure(shorten(ex.getMessage())); + } + } + + private static String enc(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String shorten(String value) { + if (value == null) { + return ""; + } + return value.length() > 200 ? value.substring(0, 200) : value; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java new file mode 100644 index 0000000..bfcbe42 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java @@ -0,0 +1,44 @@ +package pl.polskalokalnie.unsubscribe; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej. + * Musi byc na liscie permitAll w SecurityConfig. Zwraca prosta strone potwierdzenia po polsku. + */ +@RestController +@RequestMapping("/api/unsubscribe") +public class UnsubscribeController { + + private final UnsubscribeService unsubscribeService; + + public UnsubscribeController(UnsubscribeService unsubscribeService) { + this.unsubscribeService = unsubscribeService; + } + + @GetMapping(produces = MediaType.TEXT_HTML_VALUE) + public String unsubscribe(@RequestParam("token") String token) { + UnsubscribeService.Result result = unsubscribeService.unsubscribe(token); + String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail"; + return page("Rezygnacja przyjęta", + "Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")") + + " został wypisany z wiadomości marketingowych (" + kanal + "). " + + "Nie będziemy już wysyłać Ci kampanii tym kanałem."); + } + + private static String page(String title, String message) { + return "" + + "" + + "" + title + "" + + "" + + "

" + title + "

" + message + "

"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java new file mode 100644 index 0000000..c2c80b8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java @@ -0,0 +1,112 @@ +package pl.polskalokalnie.unsubscribe; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.Lead; +import pl.polskalokalnie.lead.LeadRepository; +import pl.polskalokalnie.lead.LeadStatus; + +/** + * Generuje i weryfikuje podpisane (HMAC-SHA256) tokeny rezygnacji doklejane do kazdej wiadomosci + * marketingowej. Rezygnacja jest trwala: ustawia opt-out i zdejmuje zgode na danym kanale, dzieki + * czemu kontakt nie trafi do zadnej kolejnej kampanii. + */ +@Service +public class UnsubscribeService { + + private static final String HMAC_ALGO = "HmacSHA256"; + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + + private final LeadRepository leadRepository; + private final byte[] secret; + + public UnsubscribeService(LeadRepository leadRepository, @Value("${app.jwt.secret}") String secret) { + this.leadRepository = leadRepository; + this.secret = secret.getBytes(StandardCharsets.UTF_8); + } + + /** Token dla leada na danym kanale ("email" albo "sms"). */ + public String generateToken(Long leadId, String channel) { + String payload = leadId + ":" + normalizeChannel(channel); + String encodedPayload = URL_ENCODER.encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + return encodedPayload + "." + sign(encodedPayload); + } + + @Transactional + public Result unsubscribe(String token) { + if (token == null || !token.contains(".")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + int dot = token.lastIndexOf('.'); + String encodedPayload = token.substring(0, dot); + String signature = token.substring(dot + 1); + if (!sign(encodedPayload).equals(signature)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł"); + } + + String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8); + String[] parts = payload.split(":", 2); + if (parts.length != 2) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + Long leadId = parseLeadId(parts[0]); + String channel = normalizeChannel(parts[1]); + + Lead lead = leadRepository.findById(leadId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje")); + + if ("sms".equals(channel)) { + lead.setSmsOptOut(true); + lead.setSmsConsent(false); + } else { + lead.setEmailOptOut(true); + lead.setEmailConsent(false); + } + lead.setStatus(LeadStatus.UNSUBSCRIBED); + leadRepository.save(lead); + return new Result(channel, maskContact(lead, channel)); + } + + private String sign(String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(secret, HMAC_ALGO)); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return URL_ENCODER.encodeToString(raw); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Błąd podpisu tokenu"); + } + } + + private static Long parseLeadId(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + } + + private static String normalizeChannel(String channel) { + return channel != null && channel.trim().toLowerCase(Locale.ROOT).startsWith("sms") ? "sms" : "email"; + } + + private static String maskContact(Lead lead, String channel) { + String value = "sms".equals(channel) ? lead.getPhone() : lead.getEmail(); + if (value == null || value.length() < 4) { + return ""; + } + return value.substring(0, 2) + "***" + value.substring(value.length() - 2); + } + + public record Result(String channel, String maskedContact) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3ffcf1f..368e451 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,6 +19,8 @@ spring: format_sql: true app: + # Publiczny adres bazowy uzywany do budowy linkow (np. rezygnacja z kampanii). + public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost} jwt: # W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa. secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05528d2..c71a078 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; +import { AdminLeadsView, AdminCampaignsView, AdminMailConfigView } from './admin/marketing'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; @@ -3636,7 +3637,8 @@ function accountStorageKey(suffix: string, user: AuthUser | null): string { type AdminTab = | 'dashboard' | 'listings' | 'users' | 'messages' | 'reports' | 'payments' - | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords'; + | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords' + | 'leads' | 'campaigns' | 'mailConfig'; const ADMIN_TAB_META: Record = { dashboard: { title: 'Panel administratora', subtitle: 'Zarządzaj ogłoszeniami i użytkownikami serwisu.' }, @@ -3652,6 +3654,9 @@ const ADMIN_TAB_META: Record = { locations: { title: 'Lokalizacje', subtitle: 'Zarządzaj obsługiwanymi miastami i dzielnicami.' }, promotions: { title: 'Banery i promocje', subtitle: 'Zarządzaj banerami reklamowymi i promocjami.' }, forbiddenWords: { title: 'Zakazane słowa', subtitle: 'Lista wszystkich niedozwolonych słów używanych przez globalny filtr treści.' }, + leads: { title: 'Leady', subtitle: 'Baza kontaktów marketingowych i grupy docelowe (dynamiczne segmenty).' }, + campaigns: { title: 'Kampanie marketingowe', subtitle: 'Twórz i wysyłaj kampanie e-mail oraz SMS do grup docelowych.' }, + mailConfig: { title: 'Konfiguracja wysyłki', subtitle: 'Ustawienia poczty SMTP i bramki SMS dla kampanii i powiadomień.' }, }; const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ @@ -3660,6 +3665,8 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, + { id: 'leads', icon: 'user', label: 'Leady' }, + { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, { id: 'payments', icon: 'credit-card', label: 'Płatności' }, { id: 'stats', icon: 'chart', label: 'Statystyki' }, { id: 'settings', icon: 'gear', label: 'Ustawienia serwisu' }, @@ -3667,6 +3674,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ const ADMIN_TOOL_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'moderation', icon: 'check', label: 'Weryfikacja ogłoszeń' }, + { id: 'mailConfig', icon: 'gear', label: 'Konfiguracja wysyłki' }, { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, { id: 'categories', icon: 'list', label: 'Kategorie' }, { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, @@ -5028,6 +5036,9 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { {!loading && tab === 'categories' && } {!loading && tab === 'locations' && } {!loading && tab === 'promotions' && } + {!loading && tab === 'leads' && } + {!loading && tab === 'campaigns' && } + {!loading && tab === 'mailConfig' && }
@@ -8806,16 +8817,52 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi function AccountSettingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { const { user } = useAuth(); const [accountSwitches, setAccountSwitches] = useState([true, true, false, false]); + // Indeks 4 ("Oferty promowane i newsletter") to zgoda marketingowa e-mail zapisywana na serwerze. const [mailSwitches, setMailSwitches] = useState([true, true, true, false, false]); + const [smsConsent, setSmsConsent] = useState(true); const address = user?.address ?? ''; const birthDate = user?.birthDate ?? ''; + // Wczytaj aktualną zgodę marketingową użytkownika (steruje kwalifikacją do kampanii). + useEffect(() => { + if (!user) { + return; + } + let active = true; + apiFetch<{ emailConsent: boolean; smsConsent: boolean }>('/me/marketing') + .then((consent) => { + if (!active) { + return; + } + setSmsConsent(consent.smsConsent); + setMailSwitches((current) => current.map((value, idx) => (idx === 4 ? consent.emailConsent : value))); + }) + .catch(() => { + // Brak zgody/sesji - zostawiamy wartości domyślne. + }); + return () => { + active = false; + }; + }, [user]); + const toggleAccountSwitch = (index: number) => { setAccountSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); }; const toggleMailSwitch = (index: number) => { - setMailSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); + setMailSwitches((current) => { + const next = current.map((value, idx) => (idx === index ? !value : value)); + // Zgodę marketingową e-mail (indeks 4) utrwalamy na serwerze. + if (index === 4) { + apiFetch('/me/marketing', { + method: 'PUT', + body: JSON.stringify({ emailConsent: next[4], smsConsent }), + }).catch(() => { + // Błąd zapisu nie blokuje UI; wartość zostanie zsynchronizowana przy kolejnym wejściu. + }); + } + return next; + }); }; return ( diff --git a/frontend/src/admin/marketing.tsx b/frontend/src/admin/marketing.tsx new file mode 100644 index 0000000..4da6dbd --- /dev/null +++ b/frontend/src/admin/marketing.tsx @@ -0,0 +1,974 @@ +/** + * Widoki panelu administratora dla modułu marketingowego: Leady + grupy docelowe, Kampanie oraz + * konfiguracja poczty SMTP i bramki SMS. Wydzielone z App.tsx (monolit 22k linii), aby go nie + * powiększać. Komponent `Icon` przekazywany jest propem, żeby uniknąć cyklicznego importu. + */ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '../auth'; + +type IconComponent = (props: { name: string }) => JSX.Element | null; + +type LeadSource = 'REGISTERED_USER' | 'MANUAL' | 'IMPORT' | 'FORM'; +type LeadStatus = 'NEW' | 'CONTACTED' | 'RESPONDED' | 'UNSUBSCRIBED'; + +type Lead = { + id: number; + name: string | null; + email: string | null; + phone: string | null; + city: string | null; + source: LeadSource; + userId: number | null; + status: LeadStatus; + tags: string[]; + emailConsent: boolean; + smsConsent: boolean; + emailOptOut: boolean; + smsOptOut: boolean; + notes: string | null; + createdAt: string; +}; + +type TargetGroup = { + id: number; + name: string; + description: string | null; + sources: LeadSource[]; + city: string | null; + tag: string | null; + status: LeadStatus | null; + requireEmail: boolean; + requirePhone: boolean; + requireEmailConsent: boolean; + requireSmsConsent: boolean; + memberCount: number; + createdAt: string; +}; + +type Channel = 'EMAIL' | 'SMS'; +type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; + +type Campaign = { + id: number; + name: string; + channel: Channel; + subject: string | null; + body: string; + targetGroupId: number; + scheduledAt: string | null; + dailyLimit: number; + status: CampaignStatus; + sentToday: number; + createdAt: string; + createdBy: string | null; + totalRecipients: number; + sentCount: number; +}; + +type CampaignStats = { + total: number; + queued: number; + sent: number; + delivered: number; + failed: number; + bounced: number; + optedOut: number; +}; + +const SOURCE_LABEL: Record = { + REGISTERED_USER: 'Użytkownik', + MANUAL: 'Ręczny', + IMPORT: 'Import', + FORM: 'Formularz', +}; + +const STATUS_LABEL: Record = { + NEW: 'Nowy', + CONTACTED: 'Kontakt', + RESPONDED: 'Odpowiedział', + UNSUBSCRIBED: 'Wypisany', +}; + +const CAMPAIGN_STATUS_LABEL: Record = { + DRAFT: 'Robocza', + SCHEDULED: 'Zaplanowana', + SENDING: 'Wysyłka', + PAUSED: 'Wstrzymana', + COMPLETED: 'Zakończona', + CANCELLED: 'Anulowana', +}; + +const ALL_SOURCES: LeadSource[] = ['REGISTERED_USER', 'MANUAL', 'IMPORT', 'FORM']; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : 'Wystąpił błąd. Spróbuj ponownie.'; +} + +/* ============================ LEADY + GRUPY DOCELOWE ============================ */ + +export function AdminLeadsView({ Icon }: { Icon: IconComponent }) { + const [section, setSection] = useState<'leads' | 'groups'>('leads'); + return ( +
+
+ + +
+ {section === 'leads' ? : } +
+ ); +} + +function LeadsSection({ Icon }: { Icon: IconComponent }) { + const [leads, setLeads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sourceFilter, setSourceFilter] = useState<'' | LeadSource>(''); + const [search, setSearch] = useState(''); + const [busyId, setBusyId] = useState(null); + + // Formularz nowego leada. + const [form, setForm] = useState({ name: '', email: '', phone: '', city: '', tags: '' }); + const [saving, setSaving] = useState(false); + + // Import. + const [csv, setCsv] = useState(''); + const [importTag, setImportTag] = useState(''); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (sourceFilter) params.set('source', sourceFilter); + if (search.trim()) params.set('search', search.trim()); + const data = await apiFetch(`/admin/leads${params.toString() ? `?${params.toString()}` : ''}`); + setLeads(data); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, [sourceFilter, search]); + + useEffect(() => { + const timer = setTimeout(load, 200); + return () => clearTimeout(timer); + }, [load]); + + const addLead = async () => { + if (!form.email.trim() && !form.phone.trim()) { + setError('Podaj co najmniej e-mail lub telefon.'); + return; + } + setSaving(true); + setError(null); + try { + await apiFetch('/admin/leads', { + method: 'POST', + body: JSON.stringify({ + name: form.name.trim() || null, + email: form.email.trim() || null, + phone: form.phone.trim() || null, + city: form.city.trim() || null, + tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), + }), + }); + setForm({ name: '', email: '', phone: '', city: '', tags: '' }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + }; + + const runImport = async () => { + if (!csv.trim()) return; + setImporting(true); + setImportResult(null); + setError(null); + try { + const res = await apiFetch<{ imported: number; skipped: number; messages: string[] }>( + '/admin/leads/import', + { method: 'POST', body: JSON.stringify({ csv, defaultTag: importTag.trim() || null }) }, + ); + setImportResult(`Dodano ${res.imported}, pominięto ${res.skipped}.`); + setCsv(''); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setImporting(false); + } + }; + + const optOut = async (lead: Lead) => { + setBusyId(lead.id); + try { + await apiFetch(`/admin/leads/${lead.id}`, { + method: 'PATCH', + body: JSON.stringify({ emailOptOut: true, smsOptOut: true }), + }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusyId(null); + } + }; + + const remove = async (lead: Lead) => { + setBusyId(lead.id); + try { + await apiFetch(`/admin/leads/${lead.id}`, { method: 'DELETE' }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+

Baza leadów

+ {leads.length} kontaktów +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Szukaj po nazwie, e-mailu, telefonie, mieście..." + /> +
+ +
+ +
+ setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, email: e.target.value })} /> + setForm({ ...form, phone: e.target.value })} /> + setForm({ ...form, city: e.target.value })} /> + setForm({ ...form, tags: e.target.value })} /> + +
+ + {error &&

{error}

} + + {loading ? ( +

Ładowanie...

+ ) : leads.length === 0 ? ( +

Brak leadów. Dodaj kontakt lub zaimportuj listę.

+ ) : ( +
+ + + + + + + + {leads.map((lead) => ( + + + + + + + + + + ))} + +
NazwaKontaktMiastoŹródłoStatusZgody
{lead.name || '—'} +
+ {lead.email && {lead.email}} + {lead.phone && {lead.phone}} +
+
{lead.city || '—'}{SOURCE_LABEL[lead.source]}{STATUS_LABEL[lead.status]} +
+ @ + SMS +
+
+ {!(lead.emailOptOut && lead.smsOptOut) && ( + + )} + +
+
+ )} + +
+

Import kontaktów (CSV)

+

Kolumny: nazwa;email;telefon;miasto — jeden kontakt w wierszu, nagłówek opcjonalny.

+