diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java index 3856190..5c4a945 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java @@ -132,7 +132,7 @@ public class AccountEmailService { + "
" // Naglowek z logo + nazwa + "
" - + "\"Polska" + "" + "Polska Lokalnie" diff --git a/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java index d1c9759..bec4e00 100644 --- a/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java +++ b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java @@ -5,6 +5,7 @@ import jakarta.mail.internet.MimeMessage; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; import java.util.Properties; +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; @@ -18,6 +19,14 @@ import pl.polskalokalnie.mailconfig.SmtpConfig; @Component public class EmailSender { + /** + * Logo serwisu osadzone w samym mailu (obrazek inline). Szablon odwoluje sie do niego przez + * src="cid:...", wiec logo nie zalezy od pobierania z adresu serwisu - dziala tez w mailach + * ze srodowiska lokalnego i w programach, ktore blokuja obrazki z zewnatrz. + */ + public static final String LOGO_CID = "polskalokalnie-logo"; + private static final String LOGO_RESOURCE = "mail/logo.png"; + 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()) { @@ -26,11 +35,7 @@ public class EmailSender { 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); + fillMessage(message, fromAddress(config), to, subject, htmlBody); mailSender.send(message); String messageId = message.getMessageID(); return SendResult.ok(messageId); @@ -39,6 +44,20 @@ public class EmailSender { } } + // Tresc HTML; gdy szablon uzywa logo (cid:), wiadomosc jest wieloczesciowa z logo jako zalacznikiem inline. + void fillMessage(MimeMessage message, InternetAddress from, String to, String subject, String htmlBody) + throws jakarta.mail.MessagingException { + boolean withLogo = htmlBody != null && htmlBody.contains("cid:" + LOGO_CID); + MimeMessageHelper helper = new MimeMessageHelper(message, withLogo, StandardCharsets.UTF_8.name()); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject == null ? "" : subject); + helper.setText(htmlBody, true); + if (withLogo) { + helper.addInline(LOGO_CID, new ClassPathResource(LOGO_RESOURCE), "image/png"); + } + } + private JavaMailSenderImpl buildMailSender(SmtpConfig config) { JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); mailSender.setHost(config.getHost()); diff --git a/backend/src/main/resources/mail/logo.png b/backend/src/main/resources/mail/logo.png new file mode 100644 index 0000000..bb90012 Binary files /dev/null and b/backend/src/main/resources/mail/logo.png differ diff --git a/backend/src/test/java/pl/polskalokalnie/send/EmailSenderTest.java b/backend/src/test/java/pl/polskalokalnie/send/EmailSenderTest.java new file mode 100644 index 0000000..a492576 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/send/EmailSenderTest.java @@ -0,0 +1,79 @@ +package pl.polskalokalnie.send; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.mail.BodyPart; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +class EmailSenderTest { + + private final EmailSender sender = new EmailSender(); + + // Regresja: logo bylo linkiem do adresu serwisu, wiec w mailach z lokalnego srodowiska + // (i w programach blokujacych obrazki z zewnatrz) zamiast logo pojawiala sie ikona bledu. + @Test + void templateWithLogoEmbedsItAsInlineImage() throws Exception { + MimeMessage message = newMessage(); + sender.fillMessage(message, new InternetAddress("info@polskalokalnie.pl"), "jan@example.com", "Temat", + "

Tresc

"); + + BodyPart logo = null; + boolean hasHtml = false; + for (BodyPart part : allParts(message)) { + if (part.getHeader("Content-ID") != null) { + logo = part; + } + hasHtml |= part.getContentType().startsWith("text/html"); + } + assertThat(logo).as("czesc z logo").isNotNull(); + assertThat(logo.getHeader("Content-ID")[0]).isEqualTo("<" + EmailSender.LOGO_CID + ">"); + assertThat(logo.getContentType()).startsWith("image/png"); + assertThat(logo.getDisposition()).isEqualTo(Part.INLINE); + assertThat(hasHtml).as("tresc HTML").isTrue(); + } + + @Test + void messageWithoutLogoStaysSinglePart() throws Exception { + MimeMessage message = newMessage(); + sender.fillMessage(message, new InternetAddress("info@polskalokalnie.pl"), "jan@example.com", "Temat", + "

Kampania bez logo

"); + + assertThat(message.getContent()).isInstanceOf(String.class); + } + + private static MimeMessage newMessage() { + return new MimeMessage(Session.getInstance(new Properties())); + } + + // Przebudowa przez zapis, zeby czytac wiadomosc tak, jak zobaczy ja serwer poczty. + private static List allParts(MimeMessage message) throws Exception { + message.saveChanges(); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + message.writeTo(raw); + MimeMessage parsed = new MimeMessage(Session.getInstance(new Properties()), + new java.io.ByteArrayInputStream(raw.toByteArray())); + List result = new ArrayList<>(); + collect((Multipart) parsed.getContent(), result); + return result; + } + + private static void collect(Multipart multipart, List result) throws Exception { + for (int i = 0; i < multipart.getCount(); i++) { + BodyPart part = multipart.getBodyPart(i); + if (part.getContent() instanceof Multipart nested) { + collect(nested, result); + } else { + result.add(part); + } + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0607b3..fa39f8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3648,13 +3648,16 @@ function App() { }, [location.pathname, navigationType]); return ( -
+
0} onOpenNotification={handleOpenNotification} />
{/* Trasy publiczne */} } /> - } /> + {/* Logowanie i rejestracja: kazdy krok pod wlasnym adresem, jeden komponent. */} + {Object.values(LOGIN_STEP_PATHS).map((path) => ( + } /> + ))} } /> } /> } /> @@ -12086,12 +12089,32 @@ function AccountPublicProfilePage() { type LoginStep = 'login' | 'register-choice' | 'register-private' | 'register-company' | 'register-pending' | 'forgot'; +// Krok logowania/rejestracji wynika z adresu - kazdy ma wlasna trase. +const LOGIN_STEP_PATHS: Record = { + login: ROUTES.login, + forgot: ROUTES.loginForgot, + 'register-choice': ROUTES.register, + 'register-private': ROUTES.registerPrivate, + 'register-company': ROUTES.registerCompany, + 'register-pending': ROUTES.registerPending, +}; + +function loginStepFromPath(pathname: string): LoginStep { + const entry = Object.entries(LOGIN_STEP_PATHS).find(([, path]) => path === pathname); + return (entry?.[0] as LoginStep | undefined) ?? 'login'; +} + +// Strony logowania i rejestracji maja wspolny uklad (login-shell). +const LOGIN_PAGE_PATHS = new Set(Object.values(LOGIN_STEP_PATHS)); + // Dane firmy z rejestru REGON (GUS) - odpowiedz /api/gus/company. type GusCompany = { name: string | null; nip: string; regon: string | null; address: string | null }; function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { const { login, register, forgotPassword, resendActivation } = useAuth(); - const [step, setStep] = useState('login'); + const location = useLocation(); + const navigate = useNavigate(); + const step = loginStepFromPath(location.pathname); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -12153,7 +12176,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { try { const pending = await action(); setPendingEmail(pending.email); - setStep('register-pending'); + goToStep('register-pending'); // Backend potwierdza, czy SMS naprawde wyszedl - przy awarii bramki nie otwieramy okna na kod. setOtpCodeSent(pending.phoneVerificationRequired); if (pending.phoneVerificationRequired) { @@ -12167,13 +12190,24 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { } }; + // Przejscie do innego kroku to zmiana adresu. Cel powrotu po zalogowaniu (state.from, + // ustawiany przez ProtectedRoute) idzie dalej, zeby nie zgubic go np. po wizycie w rejestracji. + const goToStep = (nextStep: LoginStep) => { + navigate(LOGIN_STEP_PATHS[nextStep], { state: location.state }); + }; + // Logowanie i rejestracja korzystaja z tych samych pol (e-mail, haslo). Formularz rejestracji // - prywatny i firmowy - zawsze zaczyna sie pusty, zeby dane wpisane albo podstawione przez // przegladarke przy logowaniu nie trafialy do nowego konta. E-mail zostaje tylko miedzy - // logowaniem a resetem hasla, gdzie chodzi o to samo konto. - const goToStep = (nextStep: LoginStep) => { - const keepsEmail = (step === 'login' || step === 'forgot') && (nextStep === 'login' || nextStep === 'forgot'); - setStep(nextStep); + // logowaniem a resetem hasla, gdzie chodzi o to samo konto. Dziala tez dla Wstecz/Dalej. + const previousStepRef = useRef(step); + useEffect(() => { + const previousStep = previousStepRef.current; + previousStepRef.current = step; + if (previousStep === step) { + return; + } + const keepsEmail = (previousStep === 'login' || previousStep === 'forgot') && (step === 'login' || step === 'forgot'); setError(null); setInfo(null); setNotActivated(false); @@ -12192,7 +12226,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { setCompanyAddress(''); setCompanyPhone(''); setAgreeTerms(false); - }; + }, [step]); const handleLoginSubmit = (event: ReactFormEvent) => { event.preventDefault(); @@ -12749,7 +12783,11 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { )} - {step === 'register-pending' && ( + {/* Ekran po rejestracji ma sens tylko zaraz po niej - e-mail nie trafia do adresu, + wiec po odswiezeniu strony wracamy do rejestracji. */} + {step === 'register-pending' && !pendingEmail && } + + {step === 'register-pending' && pendingEmail && (

Sprawdź swoją skrzynkę e-mail

@@ -23542,6 +23580,9 @@ function Header({ (isActive ? 'icon-text favorites-shortcut active' : 'icon-text favorites-shortcut')} + // Na wezszych ekranach podpis jest ukryty - nazwa zostaje w dymku i dla czytnikow ekranu. + aria-label="Ulubione" + title="Ulubione" > Ulubione diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index ce246b4..278c2de 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -28,6 +28,12 @@ export const ROUTES = { creditCalculator: '/kalkulator-zdolnosci', map: '/mapa', login: '/logowanie', + // Kroki logowania i rejestracji - kazdy ma wlasny adres (F5, Wstecz/Dalej, nowa karta). + loginForgot: '/logowanie/nie-pamietam-hasla', + register: '/rejestracja', + registerPrivate: '/rejestracja/osoba-prywatna', + registerCompany: '/rejestracja/firma', + registerPending: '/rejestracja/sprawdz-poczte', account: '/konto', accountSearches: '/konto/wyszukiwania', accountPriceAlerts: '/konto/alerty-cenowe', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 428605e..3d041c0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -288,6 +288,28 @@ svg { position: relative; } +/* Ponizej 1560 px menu glowne nie miescilo sie miedzy logo a panelem i nachodzilo na oba - + "Ulubione" zostaje samym sercem (jak dzwonek i wiadomosci), podpis jest w dymku. */ +@media (max-width: 1560px) { + .favorites-shortcut span { + display: none; + } + + .header { + gap: 20px; + } + + .nav { + gap: clamp(14px, 1.2vw, 44px); + } + + .post-button { + min-width: 0; + padding-left: 14px; + padding-right: 14px; + } +} + /* Skrot do wiadomosci w pasku gornym - plakietka z liczba nieprzeczytanych. */ .messages-shortcut { position: relative; @@ -12134,7 +12156,13 @@ a.city-card:hover { } .post-button { - min-width: 210px; + min-width: 0; + padding-left: 14px; + padding-right: 14px; + } + + .header-actions { + gap: 16px; } .hero-inner, @@ -12325,6 +12353,33 @@ a.city-card:hover { } } +/* Tuz nad ukladem dwurzedowym (<= 1100 px) menu w jednej linii potrzebuje ciasniejszych odstepow. */ +@media (min-width: 1101px) and (max-width: 1270px) { + .header { + gap: 10px; + } + + .nav { + gap: 10px; + } + + .header-actions { + gap: 10px; + } + + .brand strong { + font-size: 21px; + } + + .brand-logo-img { + height: 40px; + } + + .post-button span { + font-size: 13px; + } +} + @media (max-width: 1100px) { .header { grid-template-columns: 1fr auto; diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 4063a48..79b3bf6 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -22,6 +22,10 @@ const PUBLIC_ROUTES = [ ROUTES.guides, ROUTES.guideBuying, ROUTES.login, + ROUTES.loginForgot, + ROUTES.register, + ROUTES.registerPrivate, + ROUTES.registerCompany, ]; const PROTECTED_ROUTES = [ @@ -397,3 +401,20 @@ test.describe('Wiadomosci', () => { await page.waitForFunction(() => window.location.pathname === '/wiadomosci'); }); }); + +test.describe('Logowanie i rejestracja', () => { + test('kroki maja wlasne adresy, a Wstecz wraca do poprzedniego kroku', async ({ page }) => { + await goto(page, ROUTES.login); + await page.getByRole('button', { name: 'Zarejestruj się' }).first().click(); + await page.waitForFunction(() => window.location.pathname === '/rejestracja'); + await page.locator('.register-choice-option').first().click(); + await page.waitForFunction(() => window.location.pathname === '/rejestracja/osoba-prywatna'); + await page.goBack(); + await page.waitForFunction(() => window.location.pathname === '/rejestracja'); + }); + + test('ekran "sprawdz poczte" bez rejestracji wraca do rejestracji', async ({ page }) => { + await goto(page, ROUTES.registerPending); + await page.waitForFunction(() => window.location.pathname === '/rejestracja'); + }); +});