import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; export type Role = 'USER' | 'ADMIN'; export type AuthProviderName = 'LOCAL' | 'GOOGLE' | 'FACEBOOK'; 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>; export type AuthUser = { id: number; email: string; fullName: string; nick: string | null; role: Role; provider: AuthProviderName; accountType: AccountType; phone: string | null; address: string | null; contactPreference: ContactPreference; preferredLanguage: PreferredLanguage; nip: string | null; birthDate: string | null; verified: boolean; phoneVerified: boolean; blocked: boolean; promotionCredits: number; createdAt: string; }; type AuthResponse = { token: string; user: AuthUser }; // Po rejestracji konto wymaga aktywacji linkiem e-mail - nie logujemy od razu. export type RegisterPending = { email: string; phoneVerificationRequired: boolean }; export type RegisterDetails = { accountType: AccountType; phone?: string; nip?: string; address?: string; }; type AuthContextValue = { user: AuthUser | null; token: string | null; loading: boolean; login: (email: string, password: string) => Promise; register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise; socialLogin: (provider: Exclude) => Promise; activateAccount: (token: string) => Promise; resendActivation: (email: string) => Promise; forgotPassword: (email: string) => Promise; resetPassword: (token: string, password: string) => Promise; verifyPhone: (email: string, code: string) => Promise; resendPhoneOtp: (email: string) => Promise; updateProfile: (profile: ProfileUpdate) => Promise; changePassword: (currentPassword: string, newPassword: string) => Promise; deleteAccount: (password: string) => Promise; loadSettings: () => Promise; saveSettings: (patch: UserSettingsPatch) => Promise; refreshUser: () => Promise; 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'; export function getToken(): string | null { return window.localStorage.getItem(TOKEN_KEY); } /** * Cienki wrapper na fetch: dokleja token Bearer i zamienia bledy backendu (pole "message") * na wyjatek z czytelnym komunikatem po polsku. */ export async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = getToken(); const headers = new Headers(options.headers); if (options.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (token) { headers.set('Authorization', `Bearer ${token}`); } const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (!response.ok) { let message = 'Wystąpił błąd. Spróbuj ponownie.'; try { const data = await response.json(); if (data && typeof data.message === 'string' && data.message.trim()) { message = data.message; } } catch { // brak/niepoprawne cialo JSON - zostaje komunikat domyslny } const error = new Error(message) as Error & { status?: number }; error.status = response.status; throw error; } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [token, setToken] = useState(() => getToken()); const [user, setUser] = useState(null); const [loading, setLoading] = useState(() => Boolean(getToken())); // Po odswiezeniu strony: majac token w localStorage, pobierz aktualny profil. useEffect(() => { if (!token) { setUser(null); setLoading(false); return; } let cancelled = false; setLoading(true); // Sesje konczymy wylacznie wtedy, gdy backend odrzuci token (401/403). // Chwilowy blad sieci albo 5xx nie moze wylogowywac uzytkownika - taki blad // ponawiamy raz, a token zostaje, wiec kolejne wejscie na strone go odzyska. const loadProfile = async (canRetry: boolean): Promise => { try { const me = await apiFetch('/auth/me'); if (!cancelled) { setUser(me); setLoading(false); } } catch (error) { if (cancelled) { return; } const status = (error as { status?: number }).status; if (status === 401 || status === 403) { window.localStorage.removeItem(TOKEN_KEY); setToken(null); setUser(null); setLoading(false); return; } if (canRetry) { await new Promise((resolve) => { window.setTimeout(resolve, 400); }); if (!cancelled) { await loadProfile(false); } return; } setUser(null); setLoading(false); } }; void loadProfile(true); return () => { cancelled = true; }; }, [token]); const applyAuth = useCallback((auth: AuthResponse) => { window.localStorage.setItem(TOKEN_KEY, auth.token); setToken(auth.token); setUser(auth.user); return auth.user; }, []); const login = useCallback( async (email: string, password: string) => applyAuth(await apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), })), [applyAuth], ); // Rejestracja NIE loguje od razu - konto wymaga aktywacji linkiem z e-maila. const register = useCallback( async (email: string, password: string, fullName: string, details?: RegisterDetails) => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email, password, fullName, ...details }), }), [], ); const socialLogin = useCallback( async (provider: Exclude) => applyAuth(await apiFetch('/auth/social', { method: 'POST', body: JSON.stringify({ provider }), })), [applyAuth], ); const activateAccount = useCallback(async (token: string) => { await apiFetch('/auth/activate', { method: 'POST', body: JSON.stringify({ token }) }); }, []); const resendActivation = useCallback(async (email: string) => { await apiFetch('/auth/resend-activation', { method: 'POST', body: JSON.stringify({ email }) }); }, []); const forgotPassword = useCallback(async (email: string) => { await apiFetch('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) }); }, []); const resetPassword = useCallback(async (token: string, password: string) => { await apiFetch('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) }); }, []); const verifyPhone = useCallback(async (email: string, code: string) => { await apiFetch('/auth/verify-phone', { method: 'POST', body: JSON.stringify({ email, code }) }); }, []); const resendPhoneOtp = useCallback(async (email: string) => { await apiFetch('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) }); }, []); const updateProfile = useCallback(async (profile: ProfileUpdate) => { const updated = await apiFetch('/auth/me', { method: 'PUT', body: JSON.stringify(profile), }); setUser(updated); return updated; }, []); const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { await apiFetch('/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('/auth/me', { method: 'DELETE', body: JSON.stringify({ password }), }); window.localStorage.removeItem(TOKEN_KEY); setToken(null); setUser(null); }, []); const loadSettings = useCallback(async () => apiFetch('/me/settings'), []); const saveSettings = useCallback( async (patch: UserSettingsPatch) => apiFetch('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }), [], ); // Ponowne pobranie danych uzytkownika (np. po zakupie promowania - odswieza saldo kredytow). const refreshUser = useCallback(async () => { if (!getToken()) { return null; } try { const fresh = await apiFetch('/auth/me'); setUser(fresh); return fresh; } catch { return null; } }, []); const logout = useCallback(() => { window.localStorage.removeItem(TOKEN_KEY); setToken(null); setUser(null); }, []); const value = useMemo( () => ({ user, token, loading, login, register, socialLogin, activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp, updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout, }), [user, token, loading, login, register, socialLogin, activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp, updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout], ); return {children}; } export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) { throw new Error('useAuth must be used within an AuthProvider'); } return ctx; }