zmiany w ustawieniach

This commit is contained in:
2026-08-12 17:03:22 +02:00
parent 1e361bde93
commit fbb7ab2d90
32 changed files with 2784 additions and 1194 deletions
+1238 -1156
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -170,7 +170,8 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
try {
await resendPhoneOtp(email);
setInfo('Wysłaliśmy nowy kod SMS.');
setCooldown(30);
// Zgodne z odstepem wymuszanym przez backend (RESEND_COOLDOWN_SECONDS).
setCooldown(60);
} catch (e) {
setError(errMsg(e));
}
+88 -12
View File
@@ -7,10 +7,45 @@ export type AccountType = 'PERSONAL' | 'COMPANY';
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
export type Currency = 'PLN' | 'EUR' | 'USD';
export type AreaUnit = 'M2' | 'FT2';
export type ProfileVisibility = 'PUBLIC' | 'CONTACTS' | 'PRIVATE';
/**
* Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza -
* ich jedynym zrodlem jest /api/me/marketing, bo steruja kwalifikacja do kampanii.
*/
export type UserSettings = {
bio: string | null;
profileVisibility: ProfileVisibility;
avatarImage: string | null;
coverImage: string | null;
currency: Currency;
areaUnit: AreaUnit;
directOffersOnly: boolean;
hideInactiveOffers: boolean;
saveSearchesOnHome: boolean;
notifySavedSearches: boolean;
notifyPriceAlerts: boolean;
notifyMessages: boolean;
notifyProductNews: boolean;
searchLocations: string | null;
searchPropertyType: string | null;
searchBudgetMax: number | null;
searchAreaMin: number | null;
searchAreaMax: number | null;
searchRoomsMin: number | null;
searchRoomsMax: number | null;
};
// Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje.
export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>;
export type AuthUser = {
id: number;
email: string;
fullName: string;
nick: string | null;
role: Role;
provider: AuthProviderName;
accountType: AccountType;
@@ -52,11 +87,29 @@ type AuthContextValue = {
resetPassword: (token: string, password: string) => Promise<void>;
verifyPhone: (email: string, code: string) => Promise<void>;
resendPhoneOtp: (email: string) => Promise<void>;
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
deleteAccount: (password: string) => Promise<void>;
loadSettings: () => Promise<UserSettings>;
saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>;
refreshUser: () => Promise<AuthUser | null>;
logout: () => void;
};
/**
* PUT /auth/me nadpisuje caly profil, wiec wysylamy komplet pol, a nie tylko zmienione.
* Pusty nick oznacza "zostaw dotychczasowy" - backend nie zmienia wtedy nazwy uzytkownika.
*/
export type ProfileUpdate = {
fullName: string;
nick?: string;
phone?: string;
birthDate?: string;
address?: string;
contactPreference?: ContactPreference;
preferredLanguage?: PreferredLanguage;
};
const TOKEN_KEY = 'polskalokalnie-auth-token';
const API_BASE = '/api';
@@ -217,15 +270,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
}, []);
const updateProfile = useCallback(
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
method: 'PUT',
body: JSON.stringify({ fullName, phone, birthDate, address, contactPreference, preferredLanguage }),
});
setUser(updated);
return updated;
},
const updateProfile = useCallback(async (profile: ProfileUpdate) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
method: 'PUT',
body: JSON.stringify(profile),
});
setUser(updated);
return updated;
}, []);
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
await apiFetch<void>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
}, []);
// Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401.
const deleteAccount = useCallback(async (password: string) => {
await apiFetch<void>('/auth/me', {
method: 'DELETE',
body: JSON.stringify({ password }),
});
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []);
const saveSettings = useCallback(
async (patch: UserSettingsPatch) =>
apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }),
[],
);
@@ -253,11 +329,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({
user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout,
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout,
}),
[user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout],
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+8
View File
@@ -39,6 +39,8 @@ export const ROUTES = {
admin: '/admin',
listingDetail: '/oferta/:id',
publicProfile: '/profil/:id',
// Ten sam profil pod nazwą użytkownika - adres pokazywany w ustawieniach konta.
publicProfileByNick: '/u/:nick',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
@@ -58,6 +60,12 @@ export function listingEditPath(id: number): string {
return `/edytuj-ogloszenie/${id}`;
}
// Profil pod nazwą użytkownika. Nick jest unikalny w skali serwisu, więc jednoznacznie
// wskazuje osobę - imię i nazwisko może się powtarzać u wielu kont.
export function publicProfileNickPath(nick: string): string {
return `/u/${encodeURIComponent(nick)}`;
}
// Negocjacje dotyczą konkretnej oferty - bez identyfikatora pokazujemy pierwszą z listy.
export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
+280 -4
View File
@@ -21712,10 +21712,6 @@ svg {
grid-template-columns: 1fr;
}
.input-action-row {
grid-template-columns: minmax(0, 1fr) 106px;
}
.phone-row {
grid-template-columns: 132px minmax(0, 1fr) auto;
}
@@ -29892,3 +29888,283 @@ a.listing-detail-back {
font-weight: 800;
min-height: 30px;
}
/* --------------------------------------------------------------------------
/konto/ustawienia - bloki dodane przy scaleniu edycji profilu i bezpieczenstwa
w jedna strone. Reszta wyglądu korzysta z istniejacych klas settings-*.
-------------------------------------------------------------------------- */
/* Karta danych osobowych zajmuje cala szerokosc siatki. */
.settings-card-wide {
grid-column: 1 / -1;
}
/* Bezpieczenstwo stoi obok jednokolumnowych powiadomien i domyka rzad zamiast zostawiac luke. */
.settings-card-double {
grid-column: span 2;
}
.settings-banner {
border-radius: 8px;
font-size: 12px;
font-weight: 800;
margin: 12px 0 0;
padding: 9px 12px;
}
.settings-banner.success {
background: #eaf7f0;
border: 1px solid #bfe3d0;
color: #1f6b47;
}
.settings-banner.error {
background: #fdeced;
border: 1px solid #f3c2c6;
color: #a32431;
}
.settings-banner.info {
background: #eef4fd;
border: 1px solid #c8dbf5;
color: #2c4f80;
}
.settings-list .toggle-hint {
color: #6c7b8e;
display: block;
font-size: 10px;
font-weight: 700;
margin-top: 2px;
}
/* Wiersz z lista rozwijana (waluta, jednostki) - ten sam uklad co wiersz z przelacznikiem. */
.settings-list .settings-select-row {
align-items: center;
}
.settings-list .settings-select-row select {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 6px;
color: #30445d;
font-size: 11px;
font-weight: 800;
min-height: 30px;
padding: 0 8px;
}
.settings-search-form,
.settings-password-form {
display: grid;
gap: 10px;
}
.settings-password-form h3 {
color: #1a2c46;
font-size: 14px;
margin: 0;
}
.settings-range-row {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
/* Potwierdzenie numeru pod polem telefonu - pokazuje sie dopiero dla zapisanego numeru. */
.phone-verify-inline {
margin-top: 6px;
}
.phone-verify-inline small {
color: #6c7b8e;
font-size: 11px;
font-weight: 700;
}
/* Wyglad przycisku potwierdzenia byl przypiety do .phone-action-buttons ze starego,
dwuprzyciskowego ukladu. Tutaj przycisk stoi sam pod polem, wiec styl jest wlasny. */
.phone-verify-inline .phone-confirm-button {
align-items: center;
background: #edf5ff;
border: 1px solid #d6e4f5;
border-radius: 6px;
color: #2b4e74;
display: inline-flex;
font-size: 11px;
font-weight: 900;
gap: 6px;
justify-content: center;
min-height: 32px;
padding: 0 14px;
}
.phone-verify-inline .phone-confirm-button:hover:not(:disabled) {
background: #e2eefc;
border-color: #bcd5ef;
}
.phone-verify-inline .phone-confirm-button:disabled {
cursor: default;
opacity: 0.6;
}
.security-status-list article {
align-items: center;
}
.status-pill {
border-radius: 999px;
font-size: 10px;
font-weight: 900;
padding: 3px 9px;
white-space: nowrap;
}
/* Znacznik statusu jest spanem, wiec bez tego lapie sie na regule slotu ikony
(.settings-list article > span => 22x22 px) i tekst wychodzi poza karte. */
.settings-list article > span.status-pill {
align-items: center;
display: inline-flex;
height: auto;
justify-self: end;
line-height: 1.3;
place-items: center;
width: auto;
}
.status-pill.ok {
background: #eaf7f0;
color: #1f6b47;
}
.status-pill.warn {
background: #fdf3e3;
color: #8a5a12;
}
/* Link podgladu profilu jest teraz czwartym dzieckiem karty widocznosci - bez tego
wpadalby do waskiej kolumny z ikona i wychodzil poza karte. */
.settings-list article.profile-visibility-card > .profile-preview-cta {
grid-column: 2;
justify-self: start;
margin-top: 4px;
}
.settings-danger-zone {
align-items: center;
background: #fdf6f6;
border: 1px solid #f3d2d5;
border-radius: 8px;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 12px;
}
.settings-danger-zone strong {
align-items: center;
color: #a32431;
display: flex;
font-size: 13px;
gap: 6px;
}
.settings-danger-zone small {
color: #6c7b8e;
display: block;
font-size: 11px;
font-weight: 700;
margin-top: 3px;
}
/* W strefie niebezpiecznej przycisk stoi obok opisu, wiec nie rozciaga sie na cala szerokosc. */
.settings-danger-zone .delete-account-button {
padding: 0 18px;
width: auto;
}
.auth-status-btn.danger {
background: #c0392b;
}
@media (max-width: 900px) {
.settings-range-row {
grid-template-columns: 1fr;
}
/* Siatka ustawien ma tu jedna kolumne - bez tego span 2 dorobilby druga, pusta. */
.settings-card-double {
grid-column: auto;
}
.settings-danger-zone {
align-items: flex-start;
flex-direction: column;
}
}
/* Zdjecie profilowe i opis "o mnie" na profilu publicznym - dane z /konto/ustawienia. */
.public-profile-avatar img {
border-radius: 999px;
height: 100%;
object-fit: cover;
width: 100%;
}
.public-profile-bio {
color: #34495f;
font-size: 13px;
font-weight: 700;
line-height: 1.5;
margin: 8px 0 0;
}
.public-profile-bio-empty {
color: #8593a5;
font-weight: 700;
}
/* Ostrzezenie o zmianie adresu profilu - nick jest linkiem publicznym, wiec zmiana musi byc swiadoma. */
.nick-change-warning {
align-items: center;
background: #fdf6e7;
border: 1px solid #f0dcae;
border-radius: 6px;
color: #8a5a12;
display: flex;
font-size: 11px;
font-weight: 750;
gap: 6px;
margin-top: 6px;
padding: 7px 9px;
}
.nick-change-warning svg {
flex-shrink: 0;
height: 13px;
width: 13px;
}
/* Nick nadaje system - pole jest do odczytu, wiec nie udaje edytowalnego. */
.input-action-row > input[readonly] {
background: #f7f9fc;
color: #4a5b70;
cursor: default;
}
/* Druga kolumna byla na sztywno 106 px - pod krotkie etykiety w stylu "Zmien nick". Dluzszy
napis z white-space: nowrap nie mial sie gdzie zmiescic i wychodzil poza kartę.
Kolumna dopasowuje sie teraz do tresci przycisku. */
.input-action-row {
grid-template-columns: minmax(0, 1fr) max-content;
}
@media (max-width: 720px) {
/* Na waskim ekranie przycisk pod polem - inaczej input zostaje z paroma pikselami. */
.input-action-row {
grid-template-columns: 1fr;
}
}
+27 -1
View File
@@ -262,7 +262,6 @@ test.describe('Layout konta', () => {
ROUTES.accountListings,
ROUTES.accountSettings,
ROUTES.notifications,
ROUTES.accountSecurity,
ROUTES.accountHelpContact,
];
@@ -307,6 +306,33 @@ test.describe('Layout konta', () => {
}
});
// Bezpieczenstwo i edycja profilu byly osobnymi stronami powtarzajacymi tresc ustawien.
// Teraz sa sekcjami /konto/ustawienia, a stare adresy maja przekierowywac.
test('stare adresy profilu i bezpieczenstwa przekierowuja do ustawien', async ({ page }) => {
await login(page);
for (const path of [ROUTES.accountSecurity, ROUTES.accountProfileEdit]) {
await goto(page, path);
expect(new URL(page.url()).pathname, `${path} nie przekierowal`).toBe(ROUTES.accountSettings);
}
});
test('ustawienia pokazuja dane podane przy rejestracji i nie dubluja sekcji', async ({ page }) => {
await login(page);
await goto(page, ROUTES.accountSettings);
// E-mail konta jest wypelniony automatycznie i nie da sie go tu zmienic.
const emailInput = page.locator('.settings-card input[disabled]').first();
await expect(emailInput).toHaveValue(ADMIN.email);
// Nick nadany przy zakladaniu konta jest widoczny w formularzu.
await expect(page.getByText('polskalokalnie.pl/u/', { exact: false }).first()).toBeVisible();
// Jezyk komunikacji wystepowal wczesniej dwa razy na jednym ekranie.
await expect(page.getByText('Język komunikacji', { exact: true })).toHaveCount(1);
// Bezpieczenstwo jest dokladnie jedna sekcja, nie karta + osobna strona.
await expect(page.locator('section[aria-label="Bezpieczeństwo"]')).toHaveCount(1);
});
test('menu uzytkownika w naglowku ma pozycje ulozone w jednej linii', async ({ page }) => {
await login(page);
await goto(page, ROUTES.home);