Popraw rejestracje, kod SMS, opisy wysylki i widok wiadomosci
Deploy Polska Lokalnie / deploy (push) Successful in 11s

Rejestracja (/logowanie):
- Formularz rejestracji (osoba prywatna i firma) zawsze zaczyna sie pusty.
  Logowanie i rejestracja dzielily pola e-mail i hasla, wiec dane wpisane
  albo podstawione przez przegladarke przy logowaniu trafialy do nowego konta.
- "Powtorz haslo" ma wlasny przelacznik widocznosci; wczesniej oko przy
  "Haslo" odslanialo oba pola, a ikona przy "Powtorz haslo" nie reagowala.
- Przycisk oka ma normalny obszar klikniecia (mial zerowy rozmiar).

Kod SMS:
- Okno potwierdzania numeru odlicza czas od SMS-a z rejestracji, a odmowa
  serwera ("Kolejny mozesz zamowic za N s") odlicza czas w dol zamiast
  pokazywac stala liczbe sekund. To samo w ustawieniach konta.

Panel admina:
- "Wysylka kampanii e-mail aktywna" -> "Wysylka e-maili aktywna" z opisem:
  przelacznik blokuje wszystkie e-maile (aktywacja, reset hasla,
  powiadomienia), nie tylko kampanie. Podobny opis przy SMS.

Wiadomosci:
- Puste pole pisania bez "Napisz wiadomosc do ...".
- Naglowek rozmowy "Nazwa - opis" zamiast sklejonego tekstu.
- Na liscie rozmow jedna miniatura ogloszenia zamiast dwoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 09:30:21 +02:00
parent 9615f131f4
commit 72af24469a
4 changed files with 125 additions and 26 deletions
+66 -10
View File
@@ -10481,6 +10481,8 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
const [otpInfo, setOtpInfo] = useState(''); const [otpInfo, setOtpInfo] = useState('');
const [otpBusy, setOtpBusy] = useState(false); const [otpBusy, setOtpBusy] = useState(false);
const [otpCooldown, setOtpCooldown] = useState(0); const [otpCooldown, setOtpCooldown] = useState(0);
// Serwer odmowil kolejnego kodu - komunikat z odliczanym czasem zamiast stalej liczby sekund.
const [otpCooldownRejected, setOtpCooldownRejected] = useState(false);
// --- Preferencje wyszukiwania --- // --- Preferencje wyszukiwania ---
const [searchPrefs, setSearchPrefs] = useState({ const [searchPrefs, setSearchPrefs] = useState({
@@ -10864,6 +10866,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setOtpBusy(true); setOtpBusy(true);
setOtpError(''); setOtpError('');
setOtpInfo(''); setOtpInfo('');
setOtpCooldownRejected(false);
try { try {
await resendPhoneOtp(user.email); await resendPhoneOtp(user.email);
setOtpInfo(`Wysłaliśmy kod SMS na numer ${user.phone ?? ''}.`); setOtpInfo(`Wysłaliśmy kod SMS na numer ${user.phone ?? ''}.`);
@@ -10871,7 +10874,14 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setIsOtpOpen(true); setIsOtpOpen(true);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Nie udało się wysłać kodu SMS.'; const message = error instanceof Error ? error.message : 'Nie udało się wysłać kodu SMS.';
if (isOtpOpen) { // Za wczesnie na kolejny kod - poprzedni nadal jest wazny, wiec okno otwieramy
// i odliczamy czas podany przez serwer ("Kolejny możesz zamówić za N s").
const secondsLeft = Number(/za (\d+) s/.exec(message)?.[1]);
if ((error as { status?: number }).status === 429 && secondsLeft > 0) {
setOtpCooldown(secondsLeft);
setOtpCooldownRejected(true);
setIsOtpOpen(true);
} else if (isOtpOpen) {
// Okno juz otwarte (ponowna wysylka) - blad pokazujemy w nim. // Okno juz otwarte (ponowna wysylka) - blad pokazujemy w nim.
setOtpError(message); setOtpError(message);
} else { } else {
@@ -11873,6 +11883,9 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
autoFocus autoFocus
/> />
{otpError && <p className="auth-status-error" role="alert">{otpError}</p>} {otpError && <p className="auth-status-error" role="alert">{otpError}</p>}
{otpCooldownRejected && otpCooldown > 0 && (
<p className="auth-status-error" role="alert">Kod został już wysłany. Kolejny możesz zamówić za {otpCooldown} s</p>
)}
{otpInfo && !otpError && <p className="otp-modal-ok">{otpInfo}</p>} {otpInfo && !otpError && <p className="otp-modal-ok">{otpInfo}</p>}
<button className="auth-status-btn" type="submit" disabled={otpBusy || otpCode.length < 6}> <button className="auth-status-btn" type="submit" disabled={otpBusy || otpCode.length < 6}>
{otpBusy ? 'Sprawdzanie...' : 'Potwierdź numer'} {otpBusy ? 'Sprawdzanie...' : 'Potwierdź numer'}
@@ -12092,6 +12105,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [companyPhone, setCompanyPhone] = useState(''); const [companyPhone, setCompanyPhone] = useState('');
const [agreeTerms, setAgreeTerms] = useState(false); const [agreeTerms, setAgreeTerms] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// "Powtórz hasło" ma własny przełącznik widoczności - niezależny od pola "Hasło".
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -12101,6 +12116,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [otpModalOpen, setOtpModalOpen] = useState(false); const [otpModalOpen, setOtpModalOpen] = useState(false);
// Czy kod SMS faktycznie wyszedl z bramki - decyduje o tresci ekranu po rejestracji. // Czy kod SMS faktycznie wyszedl z bramki - decyduje o tresci ekranu po rejestracji.
const [otpCodeSent, setOtpCodeSent] = useState(false); const [otpCodeSent, setOtpCodeSent] = useState(false);
// Kiedy wyszedl ostatni kod SMS - okno odlicza od tej chwili czas do ponownej wysylki.
const [otpSentAt, setOtpSentAt] = useState<number | null>(null);
const [notActivated, setNotActivated] = useState(false); const [notActivated, setNotActivated] = useState(false);
const runAuth = async (action: () => Promise<AuthUser>) => { const runAuth = async (action: () => Promise<AuthUser>) => {
@@ -12140,6 +12157,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
// Backend potwierdza, czy SMS naprawde wyszedl - przy awarii bramki nie otwieramy okna na kod. // Backend potwierdza, czy SMS naprawde wyszedl - przy awarii bramki nie otwieramy okna na kod.
setOtpCodeSent(pending.phoneVerificationRequired); setOtpCodeSent(pending.phoneVerificationRequired);
if (pending.phoneVerificationRequired) { if (pending.phoneVerificationRequired) {
setOtpSentAt(Date.now());
setOtpModalOpen(true); setOtpModalOpen(true);
} }
} catch (err) { } catch (err) {
@@ -12149,11 +12167,31 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
} }
}; };
// 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 goToStep = (nextStep: LoginStep) => {
const keepsEmail = (step === 'login' || step === 'forgot') && (nextStep === 'login' || nextStep === 'forgot');
setStep(nextStep); setStep(nextStep);
setError(null); setError(null);
setInfo(null); setInfo(null);
setNotActivated(false); setNotActivated(false);
if (!keepsEmail) {
setEmail('');
}
setPassword('');
setConfirmPassword('');
setShowPassword(false);
setShowConfirmPassword(false);
setFirstName('');
setLastName('');
setPhone('');
setCompanyName('');
setNip('');
setCompanyAddress('');
setCompanyPhone('');
setAgreeTerms(false);
}; };
const handleLoginSubmit = (event: ReactFormEvent<HTMLFormElement>) => { const handleLoginSubmit = (event: ReactFormEvent<HTMLFormElement>) => {
@@ -12498,7 +12536,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<span>Powtórz hasło</span> <span>Powtórz hasło</span>
<div> <div>
<input <input
type={showPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
placeholder="Powtórz swoje hasło" placeholder="Powtórz swoje hasło"
value={confirmPassword} value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)} onChange={(event) => setConfirmPassword(event.target.value)}
@@ -12506,7 +12544,14 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
minLength={6} minLength={6}
required required
/> />
<Icon name="eye" /> <button
type="button"
className="login-eye"
aria-label={showConfirmPassword ? 'Ukryj hasło' : 'Pokaż hasło'}
onClick={() => setShowConfirmPassword((current) => !current)}
>
<Icon name="eye" />
</button>
</div> </div>
</label> </label>
</div> </div>
@@ -12668,7 +12713,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<span>Powtórz hasło</span> <span>Powtórz hasło</span>
<div> <div>
<input <input
type={showPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
placeholder="Powtórz swoje hasło" placeholder="Powtórz swoje hasło"
value={confirmPassword} value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)} onChange={(event) => setConfirmPassword(event.target.value)}
@@ -12676,7 +12721,14 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
minLength={6} minLength={6}
required required
/> />
<Icon name="eye" /> <button
type="button"
className="login-eye"
aria-label={showConfirmPassword ? 'Ukryj hasło' : 'Pokaż hasło'}
onClick={() => setShowConfirmPassword((current) => !current)}
>
<Icon name="eye" />
</button>
</div> </div>
</label> </label>
</div> </div>
@@ -12756,6 +12808,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<PhoneOtpModal <PhoneOtpModal
email={pendingEmail} email={pendingEmail}
phone={pendingPhone} phone={pendingPhone}
lastSentAt={otpSentAt}
onCodeSent={() => setOtpSentAt(Date.now())}
onClose={() => setOtpModalOpen(false)} onClose={() => setOtpModalOpen(false)}
onVerified={() => { setOtpModalOpen(false); setInfo('Numer telefonu został potwierdzony.'); }} onVerified={() => { setOtpModalOpen(false); setInfo('Numer telefonu został potwierdzony.'); }}
/> />
@@ -25122,9 +25176,7 @@ function MessagesPage({
{/* Czego dotyczy rozmowa - widac od razu, bez wchodzenia w watek. */} {/* Czego dotyczy rozmowa - widac od razu, bez wchodzenia w watek. */}
{thread.listing && ( {thread.listing && (
<span className="messages-thread-offer" title={`${thread.listing.title} • ${formatPrice(thread.listing.price)}`}> <span className="messages-thread-offer" title={`${thread.listing.title} • ${formatPrice(thread.listing.price)}`}>
{thread.listing.thumbnail && ( {/* Miniatura ogloszenia jest juz awatarem watku - tu sam tytul i cena. */}
<i style={{ backgroundImage: `url(${thread.listing.thumbnail})` }} aria-hidden="true" />
)}
<em>{thread.listing.title}</em> <em>{thread.listing.title}</em>
<b>{formatPrice(thread.listing.price)}</b> <b>{formatPrice(thread.listing.price)}</b>
</span> </span>
@@ -25153,7 +25205,9 @@ function MessagesPage({
) : ( ) : (
<strong>{activeThread.name}</strong> <strong>{activeThread.name}</strong>
)} )}
<small>{activeThread.status}</small> {activeThread.status && (
<small><span className="messages-chat-status-sep" aria-hidden="true"> - </span>{activeThread.status}</small>
)}
</div> </div>
</div> </div>
<div className="messages-chat-listing"> <div className="messages-chat-listing">
@@ -25222,9 +25276,11 @@ function MessagesPage({
sendMessage(); sendMessage();
} }
}} }}
// Puste pole do pisania; podpowiedz zostaje tylko przy blokadzie, zeby bylo jasne, czemu nie da sie pisac.
placeholder={isActiveThreadBlocked placeholder={isActiveThreadBlocked
? (activeThread.blockedByMe ? 'Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.' : 'Nie możesz pisać do tego użytkownika.') ? (activeThread.blockedByMe ? 'Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.' : 'Nie możesz pisać do tego użytkownika.')
: `Napisz wiadomość do ${activeThread.name}...`} : ''}
aria-label={`Wiadomość do ${activeThread.name}`}
maxLength={2000} maxLength={2000}
/> />
<button type="button" className="send" aria-label="Wyślij wiadomość" onClick={sendMessage} disabled={isActiveThreadBlocked || isSending}><Icon name="arrow" /></button> <button type="button" className="send" aria-label="Wyślij wiadomość" onClick={sendMessage} disabled={isActiveThreadBlocked || isSending}><Icon name="arrow" /></button>
+25 -5
View File
@@ -128,10 +128,16 @@ export function PasswordResetPage() {
); );
} }
// Odstep miedzy wysylkami kodu - ten sam, ktory wymusza backend (RESEND_COOLDOWN_SECONDS).
const OTP_RESEND_COOLDOWN_SECONDS = 60;
// Okno potwierdzenia numeru telefonu kodem SMS (OTP), pokazywane po rejestracji z podanym telefonem. // Okno potwierdzenia numeru telefonu kodem SMS (OTP), pokazywane po rejestracji z podanym telefonem.
export function PhoneOtpModal({ email, phone, onClose, onVerified }: { // lastSentAt - kiedy wyszedl ostatni kod; od niego odliczamy czas do mozliwosci ponownej wysylki.
export function PhoneOtpModal({ email, phone, lastSentAt, onCodeSent, onClose, onVerified }: {
email: string; email: string;
phone: string; phone: string;
lastSentAt?: number | null;
onCodeSent?: () => void;
onClose: () => void; onClose: () => void;
onVerified: () => void; onVerified: () => void;
}) { }) {
@@ -140,7 +146,11 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [cooldown, setCooldown] = useState(0); const [cooldown, setCooldown] = useState(() => (
lastSentAt ? Math.max(0, OTP_RESEND_COOLDOWN_SECONDS - Math.floor((Date.now() - lastSentAt) / 1000)) : 0
));
// Odmowa serwera ("Kolejny możesz zamówić za N s") - pokazujemy ja z odliczanym czasem.
const [cooldownRejected, setCooldownRejected] = useState(false);
useEffect(() => { useEffect(() => {
if (cooldown <= 0) { if (cooldown <= 0) {
@@ -167,13 +177,20 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
const resend = async () => { const resend = async () => {
setError(null); setError(null);
setCooldownRejected(false);
try { try {
await resendPhoneOtp(email); await resendPhoneOtp(email);
setInfo('Wysłaliśmy nowy kod SMS.'); setInfo('Wysłaliśmy nowy kod SMS.');
// Zgodne z odstepem wymuszanym przez backend (RESEND_COOLDOWN_SECONDS). setCooldown(OTP_RESEND_COOLDOWN_SECONDS);
setCooldown(60); onCodeSent?.();
} catch (e) { } catch (e) {
setError(errMsg(e)); const secondsLeft = Number(/za (\d+) s/.exec(errMsg(e))?.[1]);
if ((e as { status?: number }).status === 429 && secondsLeft > 0) {
setCooldown(secondsLeft);
setCooldownRejected(true);
} else {
setError(errMsg(e));
}
} }
}; };
@@ -197,6 +214,9 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
autoFocus autoFocus
/> />
{error && <p className="auth-status-error" role="alert">{error}</p>} {error && <p className="auth-status-error" role="alert">{error}</p>}
{cooldownRejected && cooldown > 0 && (
<p className="auth-status-error" role="alert">Kod został już wysłany. Kolejny możesz zamówić za {cooldown} s</p>
)}
{info && <p className="otp-modal-ok">{info}</p>} {info && <p className="otp-modal-ok">{info}</p>}
<button className="auth-status-btn" type="submit" disabled={busy || code.length < 6}>{busy ? 'Sprawdzanie...' : 'Potwierdź numer'}</button> <button className="auth-status-btn" type="submit" disabled={busy || code.length < 6}>{busy ? 'Sprawdzanie...' : 'Potwierdź numer'}</button>
</form> </form>
+9 -1
View File
@@ -844,11 +844,15 @@ function SmtpCard({ Icon }: { Icon: IconComponent }) {
<label>Login SMTP / adres nadawcy <label>Login SMTP / adres nadawcy
<input value={config.username ?? ''} onChange={(e) => set({ username: e.target.value })} placeholder="np. marketing@twojadomena.pl" /> <input value={config.username ?? ''} onChange={(e) => set({ username: e.target.value })} placeholder="np. marketing@twojadomena.pl" />
</label> </label>
<label>Wysyłka kampanii e-mail aktywna <label>Wysyłka e-maili aktywna
<select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}> <select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}>
<option value="off">Wyłączone (sandbox)</option> <option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option> <option value="on">Włączone</option>
</select> </select>
<small className="mkt-field-note">
Dotyczy wszystkich e-maili serwisu: linków aktywacyjnych, resetu hasła, powiadomień o wiadomościach
i kampanii. Po wyłączeniu nic nie jest wysyłane.
</small>
</label> </label>
</div> </div>
{error && <p className="mkt-error">{error}</p>} {error && <p className="mkt-error">{error}</p>}
@@ -955,6 +959,10 @@ function SmsCard({ Icon }: { Icon: IconComponent }) {
<option value="off">Wyłączone (sandbox)</option> <option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option> <option value="on">Włączone</option>
</select> </select>
<small className="mkt-field-note">
Dotyczy wszystkich SMS-ów serwisu: kodów weryfikujących telefon przy rejestracji i kampanii.
Po wyłączeniu nic nie jest wysyłane.
</small>
</label> </label>
<label>Pełny URL endpointu bramki SMS <label>Pełny URL endpointu bramki SMS
<input value={config.endpointUrl ?? ''} onChange={(e) => set({ endpointUrl: e.target.value })} placeholder="https://api.softspm.pl/send_sms_api.php" /> <input value={config.endpointUrl ?? ''} onChange={(e) => set({ endpointUrl: e.target.value })} placeholder="https://api.softspm.pl/send_sms_api.php" />
+25 -10
View File
@@ -16791,15 +16791,6 @@ a.city-card:hover {
min-width: 0; min-width: 0;
} }
.messages-thread-offer i {
background-position: center;
background-size: cover;
border-radius: 4px;
flex: none;
height: 16px;
width: 16px;
}
.messages-thread-offer em { .messages-thread-offer em {
color: #5f748a; color: #5f748a;
font-size: 11px; font-size: 11px;
@@ -16888,11 +16879,16 @@ a.city-card:hover {
.messages-chat-user strong { .messages-chat-user strong {
color: #162841; color: #162841;
display: block; display: inline-block;
font-size: 15px; font-size: 15px;
line-height: 1; line-height: 1;
} }
/* "Nazwa - opis" w jednej linii, np. "Piotr Nowak - Sprzedający". */
.messages-chat-status-sep {
white-space: pre;
}
.messages-chat-user small { .messages-chat-user small {
color: #6e8199; color: #6e8199;
font-size: 11px; font-size: 11px;
@@ -23369,6 +23365,24 @@ a.city-card:hover {
width: 18px; width: 18px;
} }
/* Przycisk oka w polu hasla: sam zajmuje miejsce ikony, zeby klikniecie obok rysunku
tez przelaczalo widocznosc (wczesniej przycisk mial zerowy rozmiar). */
.login-field .login-eye {
align-items: center;
height: 32px;
justify-content: center;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 32px;
}
.login-field .login-eye svg {
position: static;
transform: none;
}
.publish-feedback { .publish-feedback {
align-items: center; align-items: center;
border-radius: 10px; border-radius: 10px;
@@ -30635,6 +30649,7 @@ a.city-card:hover {
@media (max-width: 760px) { .mkt-config-grid { grid-template-columns: 1fr; } .mkt-form-row { grid-template-columns: 1fr; } } @media (max-width: 760px) { .mkt-config-grid { grid-template-columns: 1fr; } .mkt-form-row { grid-template-columns: 1fr; } }
.mkt-config-grid label { display: grid; gap: 6px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .03em; color: #6b7f96; } .mkt-config-grid label { display: grid; gap: 6px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .03em; color: #6b7f96; }
.mkt-config-grid input, .mkt-config-grid select { padding: 10px 12px; border: 1px solid #d8e3ef; border-radius: 8px; font-size: 13px; font-weight: 500; text-transform: none; letter-spacing: normal; color: #17293c; } .mkt-config-grid input, .mkt-config-grid select { padding: 10px 12px; border: 1px solid #d8e3ef; border-radius: 8px; font-size: 13px; font-weight: 500; text-transform: none; letter-spacing: normal; color: #17293c; }
.mkt-config-grid .mkt-field-note { font-size: 12px; font-weight: 500; line-height: 1.4; text-transform: none; letter-spacing: normal; color: #6b7f96; }
.mkt-badge-set { display: inline-flex; align-items: center; gap: 4px; text-transform: none; letter-spacing: normal; font-size: 11px; font-weight: 800; color: #1256a0; background: #e4f0ff; border-radius: 999px; padding: 2px 8px; } .mkt-badge-set { display: inline-flex; align-items: center; gap: 4px; text-transform: none; letter-spacing: normal; font-size: 11px; font-weight: 800; color: #1256a0; background: #e4f0ff; border-radius: 999px; padding: 2px 8px; }
.mkt-badge-set svg { width: 12px; height: 12px; } .mkt-badge-set svg { width: 12px; height: 12px; }
.mkt-config-save { display: flex; justify-content: flex-end; } .mkt-config-save { display: flex; justify-content: flex-end; }