Osadz logo w mailach, popraw naglowek i nadaj adresy krokom logowania
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Maile: - Logo jest zalacznikiem inline (cid:) zamiast obrazka z adresu serwisu. Wczesniej w mailach z lokalnego srodowiska (i w programach blokujacych obrazki z zewnatrz) w miejscu logo byla ikona bledu. Usuniety tekst zastepczy, ktory dublowal napis "Polska Lokalnie". Naglowek: - Menu nie nachodzi na logo ani panel przy zadnej szerokosci 1110-1920 px. Ponizej 1560 px "Ulubione" to samo serce (nazwa w dymku), mniejsze odstepy i przycisk "Dodaj ogloszenie" bez sztywnej szerokosci; w 1101-1270 px mniejszy napis logo i tekst przycisku. Logowanie i rejestracja (standard routingu): - Kazdy krok ma wlasny adres: /logowanie, /logowanie/nie-pamietam-hasla, /rejestracja, /rejestracja/osoba-prywatna, /rejestracja/firma, /rejestracja/sprawdz-poczte. Dzialaja F5, Wstecz/Dalej i nowa karta, formularz czysci sie takze przy Wstecz/Dalej, a cel powrotu po zalogowaniu nie ginie przy przejsciach miedzy krokami. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,7 @@ public class AccountEmailService {
|
||||
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
|
||||
// Naglowek z logo + nazwa
|
||||
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
|
||||
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\""
|
||||
+ "<img src=\"cid:" + EmailSender.LOGO_CID + "\" width=\"34\" height=\"34\" alt=\"\""
|
||||
+ " style=\"vertical-align:middle;border-radius:8px;\">"
|
||||
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
|
||||
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
|
||||
|
||||
@@ -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());
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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",
|
||||
"<img src=\"cid:" + EmailSender.LOGO_CID + "\"><p>Tresc</p>");
|
||||
|
||||
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",
|
||||
"<p>Kampania bez logo</p>");
|
||||
|
||||
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<BodyPart> 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<BodyPart> result = new ArrayList<>();
|
||||
collect((Multipart) parsed.getContent(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void collect(Multipart multipart, List<BodyPart> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-10
@@ -3648,13 +3648,16 @@ function App() {
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
return (
|
||||
<div className={`site-shell ${location.pathname === ROUTES.login ? 'login-shell' : ''}`}>
|
||||
<div className={`site-shell ${LOGIN_PAGE_PATHS.has(location.pathname) ? 'login-shell' : ''}`}>
|
||||
<Header onLogout={handleLogout} hasUnreadFavorites={unreadFavoriteIds.size > 0} onOpenNotification={handleOpenNotification} />
|
||||
<main>
|
||||
<Routes>
|
||||
{/* Trasy publiczne */}
|
||||
<Route path={ROUTES.home} element={<HomePage filters={filters} onOpenMap={openMap} />} />
|
||||
<Route path={ROUTES.login} element={<LoginPage onLogin={handleAuthenticated} />} />
|
||||
{/* Logowanie i rejestracja: kazdy krok pod wlasnym adresem, jeden komponent. */}
|
||||
{Object.values(LOGIN_STEP_PATHS).map((path) => (
|
||||
<Route key={path} path={path} element={<LoginPage onLogin={handleAuthenticated} />} />
|
||||
))}
|
||||
<Route path={ROUTES.terms} element={<TermsPage />} />
|
||||
<Route path={ROUTES.privacy} element={<PrivacyPolicyPage />} />
|
||||
<Route path={ROUTES.activation} element={<ActivationPage />} />
|
||||
@@ -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<LoginStep, string> = {
|
||||
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<LoginStep>('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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -12749,7 +12783,11 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{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 && <Navigate to={ROUTES.register} replace />}
|
||||
|
||||
{step === 'register-pending' && pendingEmail && (
|
||||
<div className="login-card register-pending-card">
|
||||
<span className="register-pending-icon"><Icon name="mail" /></span>
|
||||
<h2>Sprawdź swoją skrzynkę e-mail</h2>
|
||||
@@ -23542,6 +23580,9 @@ function Header({
|
||||
<NavLink
|
||||
to={ROUTES.favorites}
|
||||
className={({ isActive }) => (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"
|
||||
>
|
||||
<Icon name="heart" />
|
||||
<span>Ulubione</span>
|
||||
|
||||
@@ -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',
|
||||
|
||||
+56
-1
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user