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 [otpBusy, setOtpBusy] = useState(false);
const [otpCooldown, setOtpCooldown] = useState(0);
// Serwer odmowil kolejnego kodu - komunikat z odliczanym czasem zamiast stalej liczby sekund.
const [otpCooldownRejected, setOtpCooldownRejected] = useState(false);
// --- Preferencje wyszukiwania ---
const [searchPrefs, setSearchPrefs] = useState({
@@ -10864,6 +10866,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setOtpBusy(true);
setOtpError('');
setOtpInfo('');
setOtpCooldownRejected(false);
try {
await resendPhoneOtp(user.email);
setOtpInfo(`Wysłaliśmy kod SMS na numer ${user.phone ?? ''}.`);
@@ -10871,7 +10874,14 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
setIsOtpOpen(true);
} catch (error) {
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.
setOtpError(message);
} else {
@@ -11873,6 +11883,9 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
autoFocus
/>
{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>}
<button className="auth-status-btn" type="submit" disabled={otpBusy || otpCode.length < 6}>
{otpBusy ? 'Sprawdzanie...' : 'Potwierdź numer'}
@@ -12092,6 +12105,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [companyPhone, setCompanyPhone] = useState('');
const [agreeTerms, setAgreeTerms] = 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 [info, setInfo] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -12101,6 +12116,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [otpModalOpen, setOtpModalOpen] = useState(false);
// Czy kod SMS faktycznie wyszedl z bramki - decyduje o tresci ekranu po rejestracji.
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 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.
setOtpCodeSent(pending.phoneVerificationRequired);
if (pending.phoneVerificationRequired) {
setOtpSentAt(Date.now());
setOtpModalOpen(true);
}
} 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 keepsEmail = (step === 'login' || step === 'forgot') && (nextStep === 'login' || nextStep === 'forgot');
setStep(nextStep);
setError(null);
setInfo(null);
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>) => {
@@ -12498,7 +12536,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<span>Powtórz hasło</span>
<div>
<input
type={showPassword ? 'text' : 'password'}
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Powtórz swoje hasło"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
@@ -12506,7 +12544,14 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
minLength={6}
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>
</label>
</div>
@@ -12668,7 +12713,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<span>Powtórz hasło</span>
<div>
<input
type={showPassword ? 'text' : 'password'}
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Powtórz swoje hasło"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
@@ -12676,7 +12721,14 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
minLength={6}
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>
</label>
</div>
@@ -12756,6 +12808,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
<PhoneOtpModal
email={pendingEmail}
phone={pendingPhone}
lastSentAt={otpSentAt}
onCodeSent={() => setOtpSentAt(Date.now())}
onClose={() => setOtpModalOpen(false)}
onVerified={() => { setOtpModalOpen(false); setInfo('Numer telefonu został potwierdzony.'); }}
/>
@@ -25122,9 +25176,7 @@ function MessagesPage({
{/* Czego dotyczy rozmowa - widac od razu, bez wchodzenia w watek. */}
{thread.listing && (
<span className="messages-thread-offer" title={`${thread.listing.title} • ${formatPrice(thread.listing.price)}`}>
{thread.listing.thumbnail && (
<i style={{ backgroundImage: `url(${thread.listing.thumbnail})` }} aria-hidden="true" />
)}
{/* Miniatura ogloszenia jest juz awatarem watku - tu sam tytul i cena. */}
<em>{thread.listing.title}</em>
<b>{formatPrice(thread.listing.price)}</b>
</span>
@@ -25153,7 +25205,9 @@ function MessagesPage({
) : (
<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 className="messages-chat-listing">
@@ -25222,9 +25276,11 @@ function MessagesPage({
sendMessage();
}
}}
// Puste pole do pisania; podpowiedz zostaje tylko przy blokadzie, zeby bylo jasne, czemu nie da sie pisac.
placeholder={isActiveThreadBlocked
? (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}
/>
<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.
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;
phone: string;
lastSentAt?: number | null;
onCodeSent?: () => void;
onClose: () => void;
onVerified: () => void;
}) {
@@ -140,7 +146,11 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
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(() => {
if (cooldown <= 0) {
@@ -167,13 +177,20 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
const resend = async () => {
setError(null);
setCooldownRejected(false);
try {
await resendPhoneOtp(email);
setInfo('Wysłaliśmy nowy kod SMS.');
// Zgodne z odstepem wymuszanym przez backend (RESEND_COOLDOWN_SECONDS).
setCooldown(60);
setCooldown(OTP_RESEND_COOLDOWN_SECONDS);
onCodeSent?.();
} 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
/>
{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>}
<button className="auth-status-btn" type="submit" disabled={busy || code.length < 6}>{busy ? 'Sprawdzanie...' : 'Potwierdź numer'}</button>
</form>
+9 -1
View File
@@ -844,11 +844,15 @@ function SmtpCard({ Icon }: { Icon: IconComponent }) {
<label>Login SMTP / adres nadawcy
<input value={config.username ?? ''} onChange={(e) => set({ username: e.target.value })} placeholder="np. marketing@twojadomena.pl" />
</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' })}>
<option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option>
</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>
</div>
{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="on">Włączone</option>
</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>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" />
+25 -10
View File
@@ -16791,15 +16791,6 @@ a.city-card:hover {
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 {
color: #5f748a;
font-size: 11px;
@@ -16888,11 +16879,16 @@ a.city-card:hover {
.messages-chat-user strong {
color: #162841;
display: block;
display: inline-block;
font-size: 15px;
line-height: 1;
}
/* "Nazwa - opis" w jednej linii, np. "Piotr Nowak - Sprzedający". */
.messages-chat-status-sep {
white-space: pre;
}
.messages-chat-user small {
color: #6e8199;
font-size: 11px;
@@ -23369,6 +23365,24 @@ a.city-card:hover {
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 {
align-items: center;
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; } }
.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 .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 svg { width: 12px; height: 12px; }
.mkt-config-save { display: flex; justify-content: flex-end; }