feat: Implement dynamic agent profile editing by introducing new services and dynamic UI components.
This commit is contained in:
66
src/services/agents.service.ts
Normal file
66
src/services/agents.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import api from './api';
|
||||
|
||||
// Types
|
||||
export interface AgentType {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
slug: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
bio: string | null;
|
||||
avatar: string | null;
|
||||
licenseNumber: string | null;
|
||||
experience: number | null;
|
||||
specializations: string[];
|
||||
languages: string[];
|
||||
serviceAreas: string[];
|
||||
rating: number | null;
|
||||
reviewCount: number;
|
||||
isVerified: boolean;
|
||||
isFeatured: boolean;
|
||||
isActive: boolean;
|
||||
agentTypeId: string | null;
|
||||
agentType: AgentType | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Agents Service for Web
|
||||
class AgentsService {
|
||||
private basePath = '/agents';
|
||||
|
||||
async getMyProfile(): Promise<AgentProfile> {
|
||||
const response = await api.get<ApiResponse<AgentProfile>>(`${this.basePath}/profile/me`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getAgentById(id: string): Promise<AgentProfile> {
|
||||
const response = await api.get<ApiResponse<AgentProfile>>(`${this.basePath}/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateProfile(data: Partial<AgentProfile>): Promise<AgentProfile> {
|
||||
const response = await api.put<ApiResponse<AgentProfile>>(`${this.basePath}/profile`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentsService = new AgentsService();
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = unknown> {
|
||||
@@ -19,6 +19,23 @@ export interface ApiErrorResponse {
|
||||
errors?: Array<{ field: string; errors: string[] }>;
|
||||
}
|
||||
|
||||
// Token refresh state
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value: AxiosResponse) => void;
|
||||
reject: (error: AxiosError) => void;
|
||||
config: InternalAxiosRequestConfig;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: AxiosError | null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1',
|
||||
@@ -45,22 +62,100 @@ api.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
// Response interceptor with token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError<ApiErrorResponse>) => {
|
||||
// Handle common errors
|
||||
if (error.response) {
|
||||
const { status } = error.response;
|
||||
async (error: AxiosError<ApiErrorResponse>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (status === 401) {
|
||||
// Handle 401 Unauthorized
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// Don't try to refresh if this was the refresh token request itself
|
||||
if (originalRequest.url?.includes('/auth/refresh')) {
|
||||
// Refresh token is also invalid, clear everything and sign out properly
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
// Use NextAuth's signout endpoint to properly clear the session
|
||||
window.location.href = '/api/auth/signout?callbackUrl=/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject, config: originalRequest });
|
||||
}).then(() => {
|
||||
// Retry with new token
|
||||
const newToken = localStorage.getItem('accessToken');
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
|
||||
|
||||
if (!refreshToken) {
|
||||
// No refresh token available, sign out properly
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
// Use NextAuth's signout endpoint to properly clear the session
|
||||
window.location.href = '/api/auth/signout?callbackUrl=/login';
|
||||
}
|
||||
isRefreshing = false;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to refresh the token
|
||||
const response = await axios.post<ApiResponse<{ accessToken: string; refreshToken: string }>>(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
|
||||
{ refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data;
|
||||
|
||||
// Save new tokens
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('accessToken', newAccessToken);
|
||||
localStorage.setItem('refreshToken', newRefreshToken);
|
||||
}
|
||||
|
||||
// Update the failed request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
}
|
||||
|
||||
// Process queued requests
|
||||
processQueue(null);
|
||||
isRefreshing = false;
|
||||
|
||||
// Retry original request
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and sign out properly
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
// Use NextAuth's signout endpoint to properly clear the session
|
||||
window.location.href = '/api/auth/signout?callbackUrl=/login';
|
||||
}
|
||||
processQueue(refreshError as AxiosError);
|
||||
isRefreshing = false;
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,20 @@ export type {
|
||||
VerifyEmailRequest,
|
||||
MessageResponse,
|
||||
} from './auth.service';
|
||||
|
||||
// Profile Sections Service
|
||||
export { profileSectionsService } from './profile-sections.service';
|
||||
export type {
|
||||
FieldType,
|
||||
FieldOption,
|
||||
RangeConfig,
|
||||
FieldValidation,
|
||||
FieldUiConfig,
|
||||
ProfileField,
|
||||
ProfileSection,
|
||||
AgentTypeSectionsResponse,
|
||||
} from './profile-sections.service';
|
||||
|
||||
// Agents Service
|
||||
export { agentsService } from './agents.service';
|
||||
export type { AgentProfile, AgentType } from './agents.service';
|
||||
|
||||
115
src/services/profile-sections.service.ts
Normal file
115
src/services/profile-sections.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import api from './api';
|
||||
|
||||
// Field Types - matching backend enum
|
||||
export type FieldType =
|
||||
| 'TEXT'
|
||||
| 'TEXTAREA'
|
||||
| 'CHECKBOX'
|
||||
| 'CHECKBOX_GROUP'
|
||||
| 'RADIO'
|
||||
| 'SELECT'
|
||||
| 'MULTI_SELECT'
|
||||
| 'RANGE'
|
||||
| 'NUMBER'
|
||||
| 'DATE'
|
||||
| 'TAG_INPUT';
|
||||
|
||||
// Types
|
||||
export interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RangeConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface FieldValidation {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export interface FieldUiConfig {
|
||||
columns?: number;
|
||||
showInPreview?: boolean;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
export interface ProfileField {
|
||||
id: string;
|
||||
sectionId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
fieldType: FieldType;
|
||||
description: string | null;
|
||||
placeholder: string | null;
|
||||
defaultValue: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
isRequired: boolean;
|
||||
isSearchableOnly: boolean;
|
||||
validation: FieldValidation | null;
|
||||
options: FieldOption[] | null;
|
||||
rangeConfig: RangeConfig | null;
|
||||
uiConfig: FieldUiConfig | null;
|
||||
}
|
||||
|
||||
export interface ProfileSection {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
isGlobal: boolean;
|
||||
isSystem: boolean;
|
||||
fields?: ProfileField[];
|
||||
isRequired?: boolean;
|
||||
effectiveSortOrder?: number;
|
||||
hasCustomOrder?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentTypeSectionsResponse {
|
||||
agentType: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
sections: ProfileSection[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Profile Sections Service for Web
|
||||
class ProfileSectionsService {
|
||||
private basePath = '/profile-sections';
|
||||
|
||||
async getSectionsForAgentType(agentTypeId: string, includeFields = true): Promise<AgentTypeSectionsResponse> {
|
||||
const url = includeFields
|
||||
? `${this.basePath}/agent-type/${agentTypeId}`
|
||||
: `${this.basePath}/agent-type/${agentTypeId}?includeFields=false`;
|
||||
const response = await api.get<ApiResponse<AgentTypeSectionsResponse>>(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getAll(includeInactive = false): Promise<ProfileSection[]> {
|
||||
const url = includeInactive
|
||||
? `${this.basePath}?includeInactive=true`
|
||||
: this.basePath;
|
||||
const response = await api.get<ApiResponse<ProfileSection[]>>(url);
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const profileSectionsService = new ProfileSectionsService();
|
||||
Reference in New Issue
Block a user