jobs/page.tsx: - Edit dialog now exposes all UpdateJobRequest fields: employmentType, workMode, salaryMin/max/type/currency, salaryNegotiable, languageLevel, visaSupport, location, status, isFeatured, description - Fix AdminJob type to include all JobWithCompany fields returned by API - Fix jobRows mapping that was hardcoding location/type/workMode/isFeatured - Add isFeatured to CreateJobPayload type applications/page.tsx: - Fix status mismatch: reviewing→reviewed, interview→shortlisted, accepted→hired - Align statusConfig labels/keys with backend constraint (pending/reviewed/ shortlisted/rejected/hired) - Update stats counters to use corrected status keys companies/page.tsx: - Add logoUrl and yearsInMarket to create and edit forms - Populate editFormData from company object on edit open - Send logoUrl/yearsInMarket in update payload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1077 lines
32 KiB
TypeScript
1077 lines
32 KiB
TypeScript
import { toast } from "sonner";
|
|
import { Job } from "./types";
|
|
import { getApiUrl, getBackofficeUrl, initConfig } from "./config";
|
|
|
|
// Track if we've shown an auth error toast to avoid spam
|
|
let hasShownAuthError = false;
|
|
|
|
/**
|
|
* Helper to log CRUD actions for the 'Activity Log' or console
|
|
*/
|
|
function logCrudAction(action: string, entity: string, details?: any) {
|
|
// Only log in development
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log(`[CRUD] ${action.toUpperCase()} ${entity}`, details);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generic API Request Wrapper
|
|
*/
|
|
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options.headers as Record<string, string>,
|
|
};
|
|
|
|
if (options.body) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
const response = await fetch(`${getApiUrl()}${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Handle 401 silently - it's expected for unauthenticated users
|
|
if (response.status === 401) {
|
|
// Clear any stale auth data
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem("job-portal-auth");
|
|
}
|
|
// Throw a specific error that can be caught and handled silently
|
|
const error = new Error('Unauthorized');
|
|
(error as any).status = 401;
|
|
(error as any).silent = true;
|
|
throw error;
|
|
}
|
|
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return {} as T;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// --- Types ---
|
|
|
|
export interface ApiUser {
|
|
id: string;
|
|
name: string;
|
|
fullName?: string;
|
|
email: string;
|
|
role: "superadmin" | "admin" | "recruiter" | "candidate" | string;
|
|
status: string;
|
|
created_at: string;
|
|
avatarUrl?: string;
|
|
phone?: string;
|
|
bio?: string;
|
|
companyId?: string;
|
|
}
|
|
|
|
export interface ApiJob {
|
|
id: string;
|
|
title: string;
|
|
companyName?: string;
|
|
companyLogoUrl?: string;
|
|
companyId: string;
|
|
location?: string | null;
|
|
type?: string; // Legacy alias
|
|
employmentType?: string;
|
|
workMode?: string | null; // "remote", etc.
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
salaryType?: string;
|
|
currency?: string;
|
|
description: string;
|
|
requirements?: unknown;
|
|
status: string;
|
|
createdAt: string;
|
|
datePosted?: string;
|
|
isFeatured: boolean;
|
|
applicationCount?: number;
|
|
}
|
|
|
|
export interface ApiCompany {
|
|
id: string;
|
|
name: string;
|
|
description?: string | null;
|
|
website?: string | null;
|
|
logoUrl?: string | null;
|
|
employeeCount?: string | null;
|
|
foundedYear?: number | null;
|
|
active: boolean;
|
|
verified: boolean;
|
|
}
|
|
|
|
export interface AdminCompany {
|
|
id: string;
|
|
name: string;
|
|
email?: string;
|
|
slug: string;
|
|
type?: string;
|
|
document?: string;
|
|
address?: string;
|
|
regionId?: number;
|
|
cityId?: number;
|
|
phone?: string;
|
|
website?: string;
|
|
logoUrl?: string;
|
|
description?: string;
|
|
active: boolean;
|
|
verified: boolean;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface AdminJob {
|
|
id: string;
|
|
companyId: string;
|
|
title: string;
|
|
description?: string;
|
|
companyName: string;
|
|
location?: string;
|
|
employmentType?: string;
|
|
workMode?: string;
|
|
workingHours?: string;
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
salaryType?: string;
|
|
currency?: string;
|
|
salaryNegotiable?: boolean;
|
|
languageLevel?: string;
|
|
visaSupport?: boolean;
|
|
status: string;
|
|
isFeatured?: boolean;
|
|
applicationsCount?: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AdminRoleAccess {
|
|
role: string;
|
|
description: string;
|
|
actions: string[];
|
|
}
|
|
|
|
export interface AdminLoginAudit {
|
|
id: string;
|
|
identifier: string;
|
|
roles: string;
|
|
ipAddress: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AdminTag {
|
|
id: number;
|
|
name: string;
|
|
category: "area" | "level" | "stack";
|
|
active: boolean;
|
|
}
|
|
|
|
export interface AdminCandidate {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
phone: string;
|
|
location: string;
|
|
title?: string;
|
|
experience?: string;
|
|
avatarUrl?: string;
|
|
bio?: string;
|
|
skills: string[];
|
|
applications: {
|
|
id: string;
|
|
jobTitle: string;
|
|
company: string;
|
|
status: string;
|
|
}[];
|
|
}
|
|
|
|
export interface AdminCandidateStats {
|
|
totalCandidates: number;
|
|
newCandidates: number;
|
|
activeApplications: number;
|
|
hiringRate: number;
|
|
}
|
|
|
|
// --- Auth API ---
|
|
export const authApi = {
|
|
login: (data: any) => {
|
|
logCrudAction("login", "auth", { email: data.email });
|
|
return apiRequest<any>("/api/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
register: (data: any) => {
|
|
logCrudAction("register", "auth", { email: data.email });
|
|
return apiRequest<any>("/api/v1/auth/register", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
getCurrentUser: () => {
|
|
return apiRequest<ApiUser>("/api/v1/users/me");
|
|
},
|
|
};
|
|
|
|
// --- Users API (General/Admin) ---
|
|
export const usersApi = {
|
|
list: (params: { page: number; limit: number }) => {
|
|
const query = new URLSearchParams({
|
|
page: params.page.toString(),
|
|
limit: params.limit.toString(),
|
|
});
|
|
return apiRequest<{
|
|
data: ApiUser[];
|
|
pagination: { total: number; page: number; limit: number };
|
|
}>(`/api/v1/users?${query.toString()}`);
|
|
},
|
|
create: (data: any) => {
|
|
logCrudAction("create", "users", data);
|
|
return apiRequest<any>("/api/v1/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
update: (id: string, data: any) => {
|
|
logCrudAction("update", "users", { id, ...data });
|
|
return apiRequest<any>(`/api/v1/users/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
delete: (id: string) => {
|
|
logCrudAction("delete", "users", { id });
|
|
return apiRequest<void>(`/api/v1/users/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
getMe: () => apiRequest<ApiUser & { bio?: string; skills?: string[]; experience?: any[]; education?: any[]; profilePictureUrl?: string; }>("/api/v1/users/me"),
|
|
|
|
updateMe: (data: any) =>
|
|
apiRequest<ApiUser>("/api/v1/users/me", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
};
|
|
export const adminUsersApi = usersApi;
|
|
|
|
// --- Admin Backoffice API ---
|
|
export const adminAccessApi = {
|
|
listRoles: () => apiRequest<AdminRoleAccess[]>("/api/v1/users/roles"),
|
|
};
|
|
|
|
export const adminAuditApi = {
|
|
listLogins: (limit = 20) => apiRequest<AdminLoginAudit[]>(`/api/v1/audit/logins?limit=${limit}`),
|
|
};
|
|
|
|
export const adminJobsApi = {
|
|
list: (params: { status?: string; page?: number; limit?: number }) => {
|
|
const query = new URLSearchParams();
|
|
if (params.status) query.append("status", params.status);
|
|
if (params.page) query.append("page", params.page.toString());
|
|
if (params.limit) query.append("limit", params.limit.toString());
|
|
|
|
return apiRequest<{ data: AdminJob[]; pagination: any }>(`/api/v1/jobs/moderation?${query.toString()}`);
|
|
},
|
|
updateStatus: (id: string, status: string) =>
|
|
apiRequest<void>(`/api/v1/jobs/${id}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status }),
|
|
}),
|
|
duplicate: (id: string) =>
|
|
apiRequest<void>(`/api/v1/jobs/${id}/duplicate`, {
|
|
method: "POST",
|
|
}),
|
|
};
|
|
|
|
export const adminTagsApi = {
|
|
list: (category?: string) => {
|
|
const query = category ? `?category=${category}` : "";
|
|
return apiRequest<AdminTag[]>(`/api/v1/tags${query}`);
|
|
},
|
|
create: (data: { name: string; category: string }) =>
|
|
apiRequest<AdminTag>("/api/v1/tags", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: number, data: { name?: string; active?: boolean }) =>
|
|
apiRequest<AdminTag>(`/api/v1/tags/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
};
|
|
|
|
export const adminCandidatesApi = {
|
|
list: () => apiRequest<{ candidates: AdminCandidate[]; stats: AdminCandidateStats }>("/api/v1/candidates"),
|
|
};
|
|
|
|
// --- Companies (Admin) ---
|
|
export const adminCompaniesApi = {
|
|
list: (verified?: boolean, page = 1, limit = 10) => {
|
|
const query = new URLSearchParams({
|
|
page: page.toString(),
|
|
limit: limit.toString(),
|
|
...(verified !== undefined && { verified: verified.toString() })
|
|
});
|
|
return apiRequest<{
|
|
data: AdminCompany[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
}
|
|
}>(`/api/v1/companies?${query.toString()}`);
|
|
},
|
|
create: (data: any) => {
|
|
logCrudAction("create", "admin/companies", data);
|
|
return apiRequest<any>("/api/v1/companies", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
update: (id: string, data: Partial<AdminCompany>) => {
|
|
logCrudAction("update", "admin/companies", { id, ...data });
|
|
return apiRequest<AdminCompany>(`/api/v1/companies/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
updateStatus: (id: string, data: { active?: boolean; verified?: boolean }) => {
|
|
logCrudAction("update", "admin/companies", { id, ...data });
|
|
return apiRequest<void>(`/api/v1/companies/${id}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
delete: (id: string) => {
|
|
logCrudAction("delete", "admin/companies", { id });
|
|
return apiRequest<void>(`/api/v1/companies/${id}`, {
|
|
method: "DELETE"
|
|
});
|
|
}
|
|
};
|
|
|
|
// Companies API (Public/Shared)
|
|
export const companiesApi = {
|
|
list: () => apiRequest<AdminCompany[]>("/api/v1/companies"),
|
|
getById: (id: string) => apiRequest<ApiCompany>(`/api/v1/companies/${id}`),
|
|
|
|
create: (data: { name: string; slug: string; email?: string }) =>
|
|
apiRequest<AdminCompany>("/api/v1/companies", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
};
|
|
|
|
// --- Jobs API (Public/Candidate) ---
|
|
export interface CreateJobPayload {
|
|
companyId: string;
|
|
title: string;
|
|
description: string;
|
|
location?: string;
|
|
cityId?: number;
|
|
regionId?: number;
|
|
employmentType?: 'full-time' | 'part-time' | 'dispatch' | 'contract' | 'temporary' | 'training' | 'voluntary' | 'permanent';
|
|
workMode?: 'onsite' | 'hybrid' | 'remote';
|
|
workingHours?: string;
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
salaryType?: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
|
currency?: 'BRL' | 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CNY' | 'AED' | 'CAD' | 'AUD' | 'CHF';
|
|
salaryNegotiable?: boolean;
|
|
languageLevel?: string;
|
|
visaSupport?: boolean;
|
|
requirements?: {
|
|
resumeRequirement?: string;
|
|
applicationChannel?: string;
|
|
applicationEmail?: string | null;
|
|
applicationUrl?: string | null;
|
|
applicationPhone?: string | null;
|
|
};
|
|
status: 'draft' | 'review' | 'open' | 'paused' | 'closed' | 'published';
|
|
isFeatured?: boolean;
|
|
}
|
|
|
|
export const jobsApi = {
|
|
list: (params: {
|
|
page?: number;
|
|
limit?: number;
|
|
q?: string;
|
|
location?: string;
|
|
type?: string;
|
|
workMode?: string;
|
|
companyId?: string;
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
currency?: string;
|
|
visaSupport?: boolean;
|
|
sortBy?: string;
|
|
datePosted?: string;
|
|
}) => {
|
|
const query = new URLSearchParams();
|
|
if (params.page) query.append("page", params.page.toString());
|
|
if (params.limit) query.append("limit", params.limit.toString());
|
|
if (params.q) query.append("q", params.q);
|
|
if (params.location) query.append("location", params.location);
|
|
if (params.type) query.append("employmentType", params.type);
|
|
if (params.workMode) query.append("workMode", params.workMode);
|
|
if (params.companyId) query.append("companyId", params.companyId);
|
|
if (params.salaryMin) query.append("salaryMin", params.salaryMin.toString());
|
|
if (params.salaryMax) query.append("salaryMax", params.salaryMax.toString());
|
|
if (params.currency) query.append("currency", params.currency);
|
|
if (params.visaSupport) query.append("visaSupport", "true");
|
|
if (params.sortBy) query.append("sortBy", params.sortBy);
|
|
if (params.datePosted) query.append("datePosted", params.datePosted);
|
|
|
|
return apiRequest<{
|
|
data: ApiJob[];
|
|
pagination: { total: number; page: number; limit: number };
|
|
}>(`/api/v1/jobs?${query.toString()}`);
|
|
},
|
|
getById: (id: string) => apiRequest<ApiJob>(`/api/v1/jobs/${id}`),
|
|
create: (data: CreateJobPayload) => {
|
|
logCrudAction("create", "jobs", data);
|
|
return apiRequest<ApiJob>("/api/v1/jobs", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
update: (id: string, data: Partial<CreateJobPayload>) => {
|
|
logCrudAction("update", "jobs", { id, ...data });
|
|
return apiRequest<ApiJob>(`/api/v1/jobs/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
delete: (id: string) => {
|
|
logCrudAction("delete", "jobs", { id });
|
|
return apiRequest<void>(`/api/v1/jobs/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
|
|
// Job Alerts
|
|
createAlert: (data: {
|
|
searchQuery?: string;
|
|
location?: string;
|
|
employmentType?: string;
|
|
workMode?: string;
|
|
salaryMin?: number;
|
|
salaryMax?: number;
|
|
currency?: string;
|
|
frequency?: string;
|
|
email?: string;
|
|
}) => apiRequest<{ id: string }>("/api/v1/alerts", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getMyAlerts: () => apiRequest<Array<{
|
|
id: string;
|
|
searchQuery?: string;
|
|
location?: string;
|
|
isActive: boolean;
|
|
frequency: string;
|
|
}>>("/api/v1/alerts/me"),
|
|
deleteAlert: (id: string) => apiRequest<void>(`/api/v1/alerts/${id}`, {
|
|
method: "DELETE",
|
|
}),
|
|
toggleAlert: (id: string, active: boolean) => apiRequest<void>(`/api/v1/alerts/${id}/toggle`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ active }),
|
|
}),
|
|
|
|
// Favorites - wrap with silent error handling
|
|
getFavorites: () => apiRequest<Array<{
|
|
id: string;
|
|
jobId: string;
|
|
jobTitle: string;
|
|
companyName: string;
|
|
location?: string;
|
|
}>>("/api/v1/favorites"),
|
|
addFavorite: (jobId: string) => apiRequest<{ id: string }>(`/api/v1/favorites/${jobId}`, {
|
|
method: "POST",
|
|
}),
|
|
removeFavorite: (jobId: string) => apiRequest<void>(`/api/v1/favorites/${jobId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
checkFavorite: (jobId: string) => apiRequest<{ isFavorite: boolean }>(`/api/v1/favorites/${jobId}/check`),
|
|
|
|
// Companies
|
|
getFollowing: () => apiRequest<Array<{
|
|
companyId: string;
|
|
companyName: string;
|
|
jobsCount: number;
|
|
}>>("/api/v1/companies/following"),
|
|
followCompany: (companyId: string) => apiRequest<{ id: string }>(`/api/v1/companies/follow/${companyId}`, {
|
|
method: "POST",
|
|
}),
|
|
unfollowCompany: (companyId: string) => apiRequest<void>(`/api/v1/companies/follow/${companyId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
checkFollowing: (companyId: string) => apiRequest<{ isFollowing: boolean }>(`/api/v1/companies/followed/check/${companyId}`),
|
|
getCompaniesWithJobs: () => apiRequest<Array<{
|
|
companyId: string;
|
|
companyName: string;
|
|
companyLogoUrl?: string;
|
|
jobsCount: number;
|
|
}>>("/api/v1/companies/with-jobs"),
|
|
};
|
|
|
|
// Applications API
|
|
export interface ApiApplication {
|
|
id: number;
|
|
jobId: number;
|
|
userId?: number;
|
|
name?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
resumeUrl?: string;
|
|
status: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export const applicationsApi = {
|
|
create: (data: any) =>
|
|
apiRequest<ApiApplication>("/api/v1/applications", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
list: (params: { jobId?: string; companyId?: string }) => {
|
|
const query = new URLSearchParams();
|
|
if (params.jobId) query.append("jobId", params.jobId);
|
|
if (params.companyId) query.append("companyId", params.companyId);
|
|
return apiRequest<any[]>(`/api/v1/applications?${query.toString()}`);
|
|
},
|
|
listMyApplications: () => {
|
|
return apiRequest<ApiApplication[]>("/api/v1/applications/me");
|
|
},
|
|
delete: (id: string) => {
|
|
return apiRequest<void>(`/api/v1/applications/${id}`, {
|
|
method: "DELETE"
|
|
});
|
|
},
|
|
};
|
|
|
|
// Storage API
|
|
export const storageApi = {
|
|
getUploadUrl: (filename: string, contentType: string) =>
|
|
apiRequest<{ uploadUrl: string; key: string; publicUrl: string }>(
|
|
"/api/v1/storage/upload-url",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ filename, contentType })
|
|
}
|
|
),
|
|
|
|
uploadFile: async (file: File, folder = "uploads") => {
|
|
const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('folder', folder);
|
|
|
|
const response = await fetch(`${getApiUrl()}/api/v1/storage/upload`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to upload file to storage: ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return {
|
|
key: data.key,
|
|
publicUrl: data.publicUrl || data.url
|
|
};
|
|
},
|
|
|
|
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
|
|
method: "POST"
|
|
}),
|
|
};
|
|
|
|
// --- Helper Functions ---
|
|
export function transformApiJobToFrontend(apiJob: ApiJob): Job {
|
|
let reqs: string[] = [];
|
|
if (apiJob.requirements) {
|
|
if (Array.isArray(apiJob.requirements)) {
|
|
reqs = apiJob.requirements.map(String).filter(Boolean);
|
|
} else if (typeof apiJob.requirements === 'string') {
|
|
if (apiJob.requirements.startsWith('[')) {
|
|
try {
|
|
reqs = JSON.parse(apiJob.requirements);
|
|
} catch (e) {
|
|
reqs = apiJob.requirements.split('\n').filter(Boolean);
|
|
}
|
|
} else {
|
|
reqs = apiJob.requirements.split('\n').filter(Boolean);
|
|
}
|
|
}
|
|
}
|
|
|
|
let salaryLabel: string | undefined;
|
|
if (apiJob.salaryMin && apiJob.salaryMax) {
|
|
salaryLabel = `R$ ${apiJob.salaryMin.toLocaleString('pt-BR')} - R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
|
|
} else if (apiJob.salaryMin) {
|
|
salaryLabel = `A partir de R$ ${apiJob.salaryMin.toLocaleString('pt-BR')}`;
|
|
} else if (apiJob.salaryMax) {
|
|
salaryLabel = `Até R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
|
|
}
|
|
|
|
return {
|
|
id: apiJob.id,
|
|
title: apiJob.title,
|
|
company: apiJob.companyName || "Unknown Company",
|
|
location: apiJob.location ?? '',
|
|
type: (apiJob.type as any) || "full-time",
|
|
workMode: (apiJob.workMode as any) || "onsite",
|
|
salary: salaryLabel,
|
|
description: apiJob.description,
|
|
requirements: reqs,
|
|
postedAt: apiJob.createdAt,
|
|
isFeatured: apiJob.isFeatured,
|
|
};
|
|
}
|
|
|
|
|
|
// --- Notifications ---
|
|
export interface Notification {
|
|
id: string;
|
|
userId: number;
|
|
type: 'info' | 'success' | 'warning' | 'error';
|
|
title: string;
|
|
message: string;
|
|
link?: string;
|
|
read: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export const notificationsApi = {
|
|
list: () => {
|
|
return apiRequest<Notification[]>("/api/v1/notifications");
|
|
},
|
|
markAsRead: (id: string) => {
|
|
return apiRequest<void>(`/api/v1/notifications/${id}/read`, {
|
|
method: "PATCH",
|
|
});
|
|
},
|
|
markAllAsRead: () => {
|
|
return apiRequest<void>("/api/v1/notifications/read-all", {
|
|
method: "PATCH",
|
|
});
|
|
},
|
|
};
|
|
|
|
// --- Support Tickets ---
|
|
export interface Ticket {
|
|
id: string;
|
|
userId: number;
|
|
subject: string;
|
|
status: 'open' | 'in_progress' | 'closed';
|
|
priority: 'low' | 'medium' | 'high';
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface TicketMessage {
|
|
id: string;
|
|
ticketId: string;
|
|
userId: number;
|
|
message: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export const ticketsApi = {
|
|
create: (data: { subject: string; priority: string; message?: string }) => {
|
|
return apiRequest<Ticket>("/api/v1/support/tickets", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
list: () => {
|
|
return apiRequest<Ticket[]>("/api/v1/support/tickets");
|
|
},
|
|
get: (id: string) => {
|
|
return apiRequest<{ ticket: Ticket; messages: TicketMessage[] }>(`/api/v1/support/tickets/${id}`);
|
|
},
|
|
sendMessage: (id: string, message: string) => {
|
|
return apiRequest<TicketMessage>(`/api/v1/support/tickets/${id}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
},
|
|
listAll: () => {
|
|
return apiRequest<Ticket[]>("/api/v1/support/tickets/all");
|
|
},
|
|
update: (id: string, data: { status?: string; priority?: string }) => {
|
|
return apiRequest<Ticket>(`/api/v1/support/tickets/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
delete: (id: string) => {
|
|
return apiRequest<void>(`/api/v1/support/tickets/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
};
|
|
|
|
// --- Profile ---
|
|
export const profileApi = {
|
|
update: (data: { name?: string; email?: string; phone?: string; bio?: string }) => {
|
|
return apiRequest<any>("/api/v1/users/me/profile", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
updatePassword: (data: { currentPassword: string; newPassword: string }) => {
|
|
return apiRequest<void>("/api/v1/users/me/password", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
},
|
|
async uploadAvatar(file: File) {
|
|
const { url, key, publicUrl } = await apiRequest<{ url: string; key: string; publicUrl?: string }>(
|
|
`/api/v1/storage/upload-url?filename=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(file.type)}&folder=avatars`
|
|
);
|
|
|
|
const uploadRes = await fetch(url, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": file.type,
|
|
},
|
|
body: file,
|
|
});
|
|
|
|
if (!uploadRes.ok) {
|
|
throw new Error("Failed to upload image to storage");
|
|
}
|
|
|
|
const avatarUrl = publicUrl || key;
|
|
|
|
const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({ avatarUrl })
|
|
});
|
|
|
|
if (!res.ok) throw new Error("Failed to update profile avatar");
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
// =============================================================================
|
|
// BACKOFFICE API (Stripe, Admin Dashboard, etc.)
|
|
// =============================================================================
|
|
|
|
async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options.headers as Record<string, string>,
|
|
};
|
|
|
|
if (options.body) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
const error = new Error('Unauthorized');
|
|
(error as any).status = 401;
|
|
(error as any).silent = true;
|
|
throw error;
|
|
}
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return {} as T;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export interface DashboardStats {
|
|
totalCompanies: number;
|
|
activeSubscriptions: number;
|
|
monthlyRevenue: number;
|
|
newCompaniesThisMonth: number;
|
|
}
|
|
|
|
export interface RevenueByMonth {
|
|
month: string;
|
|
revenue: number;
|
|
}
|
|
|
|
export interface SubscriptionsByPlan {
|
|
plan: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface CheckoutSessionRequest {
|
|
priceId: string;
|
|
successUrl: string;
|
|
cancelUrl: string;
|
|
}
|
|
|
|
export interface CheckoutSessionResponse {
|
|
url: string;
|
|
sessionId: string;
|
|
}
|
|
|
|
export interface Plan {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
monthlyPrice: number;
|
|
yearlyPrice: number;
|
|
features: string[];
|
|
popular?: boolean;
|
|
}
|
|
|
|
export const plansApi = {
|
|
getAll: () => backofficeRequest<Plan[]>("/plans"),
|
|
getById: (id: string) => backofficeRequest<Plan>(`/plans/${id}`),
|
|
create: (data: Omit<Plan, "id">) => backofficeRequest<Plan>("/plans", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: string, data: Partial<Plan>) => {
|
|
const { id: _, ...updateData } = data;
|
|
return backofficeRequest<Plan>(`/plans/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updateData),
|
|
});
|
|
},
|
|
delete: (id: string) => backofficeRequest<void>(`/plans/${id}`, {
|
|
method: "DELETE",
|
|
}),
|
|
};
|
|
|
|
export const backofficeApi = {
|
|
admin: {
|
|
getStats: () => backofficeRequest<DashboardStats>("/admin/stats"),
|
|
getRevenue: () => backofficeRequest<RevenueByMonth[]>("/admin/revenue"),
|
|
getSubscriptionsByPlan: () => backofficeRequest<SubscriptionsByPlan[]>("/admin/subscriptions-by-plan"),
|
|
},
|
|
stripe: {
|
|
createCheckoutSession: (data: CheckoutSessionRequest) =>
|
|
backofficeRequest<CheckoutSessionResponse>("/stripe/checkout", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
createBillingPortal: (returnUrl: string) =>
|
|
backofficeRequest<{ url: string }>("/stripe/portal", {
|
|
method: "POST",
|
|
body: JSON.stringify({ returnUrl }),
|
|
}),
|
|
listSubscriptions: (customerId: string) =>
|
|
backofficeRequest<any>(`/stripe/subscriptions/${customerId}`),
|
|
},
|
|
externalServices: {
|
|
purgeCloudflareCache: () => backofficeRequest<void>("/external-services/cloudflare/purge", { method: "POST" })
|
|
}
|
|
};
|
|
|
|
export const fcmApi = {
|
|
saveToken: (token: string, platform: 'web' | 'android' | 'ios' = 'web') => {
|
|
return apiRequest<void>("/api/v1/tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify({ token, platform }),
|
|
});
|
|
},
|
|
};
|
|
|
|
// --- Chat ---
|
|
export interface Message {
|
|
id: string;
|
|
conversationId: string;
|
|
senderId: string;
|
|
content: string;
|
|
createdAt: string;
|
|
isMine: boolean;
|
|
}
|
|
|
|
export interface Conversation {
|
|
id: string;
|
|
lastMessage: string;
|
|
lastMessageAt: string;
|
|
participantName: string;
|
|
participantAvatar?: string;
|
|
unreadCount?: number;
|
|
}
|
|
|
|
|
|
export const chatApi = {
|
|
listConversations: () => apiRequest<Conversation[]>("/api/v1/conversations"),
|
|
listMessages: (conversationId: string) => apiRequest<Message[]>(`/api/v1/conversations/${conversationId}/messages`),
|
|
sendMessage: (conversationId: string, content: string) => apiRequest<Message>(`/api/v1/conversations/${conversationId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content }),
|
|
}),
|
|
};
|
|
|
|
export const settingsApi = {
|
|
get: async (key: string): Promise<any> => {
|
|
const res = await apiRequest<any>(`/api/v1/system/settings/${key}`)
|
|
return res
|
|
},
|
|
save: async (key: string, value: any): Promise<void> => {
|
|
await apiRequest<void>(`/api/v1/system/settings/${key}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(value),
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- System Credentials ---
|
|
export interface ConfiguredService {
|
|
service_name: string;
|
|
updated_at: string;
|
|
updated_by: string;
|
|
is_configured: boolean;
|
|
}
|
|
|
|
export const credentialsApi = {
|
|
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
|
|
save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", {
|
|
method: "POST",
|
|
body: JSON.stringify({ serviceName, payload }),
|
|
}),
|
|
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
|
|
method: "DELETE",
|
|
}),
|
|
};
|
|
|
|
|
|
// --- Email Templates & Settings ---
|
|
export interface EmailTemplate {
|
|
id: string;
|
|
slug: string;
|
|
subject: string;
|
|
body_html: string;
|
|
variables: string[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface EmailSettings {
|
|
id?: string;
|
|
provider: string;
|
|
smtp_host?: string;
|
|
smtp_port?: number;
|
|
smtp_user?: string;
|
|
smtp_pass?: string;
|
|
smtp_secure: boolean;
|
|
sender_name: string;
|
|
sender_email: string;
|
|
amqp_url?: string;
|
|
is_active?: boolean;
|
|
updated_at?: string;
|
|
}
|
|
|
|
export const emailTemplatesApi = {
|
|
list: () => apiRequest<EmailTemplate[]>("/api/v1/admin/email-templates"),
|
|
get: (slug: string) => apiRequest<EmailTemplate>(`/api/v1/admin/email-templates/${slug}`),
|
|
create: (data: Partial<EmailTemplate>) => apiRequest<EmailTemplate>("/api/v1/admin/email-templates", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (slug: string, data: Partial<EmailTemplate>) => apiRequest<EmailTemplate>(`/api/v1/admin/email-templates/${slug}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
delete: (slug: string) => apiRequest<void>(`/api/v1/admin/email-templates/${slug}`, {
|
|
method: "DELETE",
|
|
}),
|
|
};
|
|
|
|
export const emailSettingsApi = {
|
|
get: () => apiRequest<EmailSettings>("/api/v1/admin/email-settings"),
|
|
update: (data: Partial<EmailSettings>) => apiRequest<EmailSettings>("/api/v1/admin/email-settings", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
};
|
|
|
|
// --- Location API ---
|
|
export interface Country {
|
|
id: number;
|
|
name: string;
|
|
iso2: string;
|
|
iso3: string;
|
|
phone_code: string;
|
|
currency: string;
|
|
emoji: string;
|
|
emojiU: string;
|
|
}
|
|
|
|
export interface State {
|
|
id: number;
|
|
name: string;
|
|
country_id: number;
|
|
country_code: string;
|
|
iso2: string;
|
|
type: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
export interface City {
|
|
id: number;
|
|
name: string;
|
|
state_id: number;
|
|
country_id: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
export interface LocationSearchResult {
|
|
id: number;
|
|
name: string;
|
|
type: 'state' | 'city';
|
|
country_id: number;
|
|
state_id?: number;
|
|
region_name?: string;
|
|
}
|
|
|
|
export const locationsApi = {
|
|
listCountries: () => apiRequest<Country[]>("/api/v1/locations/countries"),
|
|
listStates: (countryId: number | string) => apiRequest<State[]>(`/api/v1/locations/countries/${countryId}/states`),
|
|
listCities: (stateId: number | string) => apiRequest<City[]>(`/api/v1/locations/states/${stateId}/cities`),
|
|
search: async (query: string, countryId: number | string) => {
|
|
const res = await apiRequest<LocationSearchResult[]>(`/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`);
|
|
return res || [];
|
|
},
|
|
};
|