zmiany w ustawieniach
This commit is contained in:
+1238
-1156
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user