"
- + "

"
+ "
"
+ "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');
+ });
+});