feat(frontend): Setup Next.js with Zustand stores and API client - Phase 2 initialization
This commit is contained in:
87
frontend-next/lib/api.ts
Normal file
87
frontend-next/lib/api.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 🔌 API Client Configuration
|
||||
* Cliente HTTP para comunicação com backend
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import { ApiError, TaskApiError } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
|
||||
const API_TIMEOUT = parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '10000');
|
||||
|
||||
/**
|
||||
* Instância Axios configurada
|
||||
*/
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
timeout: API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Interceptor: Adicionar token JWT às requisições
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// Obter token do localStorage
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Interceptor: Tratar respostas e erros
|
||||
*/
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data as any;
|
||||
|
||||
// Logout em caso de token inválido
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
}
|
||||
|
||||
// Lançar erro customizado
|
||||
throw new TaskApiError(
|
||||
status || 500,
|
||||
data?.message || error.message || 'Erro na API',
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper: tratamento de erros
|
||||
*/
|
||||
export const handleApiError = (error: any): string => {
|
||||
if (error instanceof TaskApiError) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error instanceof axios.AxiosError) {
|
||||
return (
|
||||
(error.response?.data as any)?.message ||
|
||||
error.message ||
|
||||
'Erro na requisição'
|
||||
);
|
||||
}
|
||||
|
||||
return 'Erro desconhecido';
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
220
frontend-next/lib/stores/auth.store.ts
Normal file
220
frontend-next/lib/stores/auth.store.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 🔐 Auth Store (Zustand)
|
||||
* Gerenciador de estado de autenticação
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import apiClient, { handleApiError } from '../api';
|
||||
import {
|
||||
AuthState,
|
||||
AuthUser,
|
||||
SignupPayload,
|
||||
LoginPayload,
|
||||
AuthResponse,
|
||||
} from '../types';
|
||||
|
||||
interface AuthStore extends AuthState {
|
||||
// ações
|
||||
signup: (payload: SignupPayload) => Promise<void>;
|
||||
login: (payload: LoginPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
getProfile: () => Promise<AuthUser | null>;
|
||||
setError: (error: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodificar JWT para pegar o userId
|
||||
*/
|
||||
const decodeToken = (token: string): { userId: string } | null => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Criar store de autenticação
|
||||
*/
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Estado inicial
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// ============================================================================
|
||||
// AÇÕES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Registrar novo usuário
|
||||
*/
|
||||
signup: async (payload: SignupPayload) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/auth/signup',
|
||||
payload,
|
||||
);
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
set({
|
||||
token: access_token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Salvar token no localStorage
|
||||
localStorage.setItem('auth_token', access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message, isLoading: false });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fazer login
|
||||
*/
|
||||
login: async (payload: LoginPayload) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/auth/login',
|
||||
payload,
|
||||
);
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
set({
|
||||
token: access_token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Salvar no localStorage
|
||||
localStorage.setItem('auth_token', access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message, isLoading: false });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fazer logout
|
||||
*/
|
||||
logout: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
// Chamar endpoint de logout (opcional)
|
||||
await apiClient.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer logout:', error);
|
||||
} finally {
|
||||
// Limpar estado e localStorage
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
localStorage.removeItem('auth_store');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Obter perfil do usuário
|
||||
*/
|
||||
getProfile: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<AuthUser>('/auth/me');
|
||||
const user = response.data;
|
||||
|
||||
set({ user, isAuthenticated: true });
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message, isAuthenticated: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Setar erro
|
||||
*/
|
||||
setError: (error: string | null) => {
|
||||
set({ error });
|
||||
},
|
||||
|
||||
/**
|
||||
* Setar loading
|
||||
*/
|
||||
setLoading: (isLoading: boolean) => {
|
||||
set({ isLoading });
|
||||
},
|
||||
}),
|
||||
|
||||
// Configurar persistência
|
||||
{
|
||||
name: 'auth_store',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
|
||||
// Validar token ao recuperar do storage
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state?.token) {
|
||||
const decoded = decodeToken(state.token);
|
||||
if (!decoded) {
|
||||
state.token = null;
|
||||
state.isAuthenticated = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Exportar hook separado para debugging
|
||||
export const useAuth = () => {
|
||||
const store = useAuthStore();
|
||||
return {
|
||||
user: store.user,
|
||||
token: store.token,
|
||||
isAuthenticated: store.isAuthenticated,
|
||||
isLoading: store.isLoading,
|
||||
error: store.error,
|
||||
login: store.login,
|
||||
signup: store.signup,
|
||||
logout: store.logout,
|
||||
getProfile: store.getProfile,
|
||||
};
|
||||
};
|
||||
6
frontend-next/lib/stores/index.ts
Normal file
6
frontend-next/lib/stores/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 🛒 Store Exports
|
||||
*/
|
||||
|
||||
export { useAuthStore, useAuth } from './auth.store';
|
||||
export { useTasksStore, useTasks } from './tasks.store';
|
||||
248
frontend-next/lib/stores/tasks.store.ts
Normal file
248
frontend-next/lib/stores/tasks.store.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 📝 Tasks Store (Zustand)
|
||||
* Gerenciador de estado das tarefas
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import apiClient, { handleApiError } from '../api';
|
||||
import {
|
||||
TasksState,
|
||||
Task,
|
||||
CreateTaskPayload,
|
||||
UpdateTaskPayload,
|
||||
TaskFilters,
|
||||
TasksListResponse,
|
||||
TaskStatsResponse,
|
||||
} from '../types';
|
||||
|
||||
interface TasksStore extends TasksState {
|
||||
// Ações
|
||||
fetchTasks: (filters?: TaskFilters) => Promise<void>;
|
||||
fetchStats: () => Promise<void>;
|
||||
createTask: (payload: CreateTaskPayload) => Promise<Task | null>;
|
||||
updateTask: (id: string, payload: UpdateTaskPayload) => Promise<Task | null>;
|
||||
deleteTask: (id: string) => Promise<boolean>;
|
||||
setFilters: (filters: TaskFilters) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar store de tarefas
|
||||
*/
|
||||
export const useTasksStore = create<TasksStore>((set, get) => ({
|
||||
// Estado inicial
|
||||
tasks: [],
|
||||
stats: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
completed: undefined,
|
||||
sortBy: 'created_at',
|
||||
order: 'desc',
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// AÇÕES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Buscar tarefas com filtros
|
||||
*/
|
||||
fetchTasks: async (filters?: TaskFilters) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
if (filters) {
|
||||
set({ filters });
|
||||
}
|
||||
|
||||
const currentFilters = filters || get().filters;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (currentFilters.completed !== undefined) {
|
||||
params.append('completed', String(currentFilters.completed));
|
||||
}
|
||||
if (currentFilters.category) {
|
||||
params.append('category', currentFilters.category);
|
||||
}
|
||||
if (currentFilters.priority) {
|
||||
params.append('priority', currentFilters.priority);
|
||||
}
|
||||
if (currentFilters.sortBy) {
|
||||
params.append('sortBy', currentFilters.sortBy);
|
||||
}
|
||||
if (currentFilters.order) {
|
||||
params.append('order', currentFilters.order);
|
||||
}
|
||||
|
||||
const url = `/tasks${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await apiClient.get<TasksListResponse>(url);
|
||||
|
||||
set({
|
||||
tasks: response.data.data,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message, isLoading: false, tasks: [] });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Buscar estatísticas
|
||||
*/
|
||||
fetchStats: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<TaskStatsResponse>('/tasks/stats');
|
||||
set({ stats: response.data.data });
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar estatísticas:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Criar tarefa
|
||||
*/
|
||||
createTask: async (payload: CreateTaskPayload) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await apiClient.post<{ data: Task }>('/tasks', payload);
|
||||
const newTask = response.data.data;
|
||||
|
||||
// Adicionar à lista
|
||||
set({ tasks: [newTask, ...get().tasks] });
|
||||
|
||||
// Atualizar stats se houver
|
||||
if (get().stats) {
|
||||
await get().fetchStats();
|
||||
}
|
||||
|
||||
return newTask;
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message });
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Atualizar tarefa
|
||||
*/
|
||||
updateTask: async (id: string, payload: UpdateTaskPayload) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await apiClient.patch<{ data: Task }>(
|
||||
`/tasks/${id}`,
|
||||
payload,
|
||||
);
|
||||
const updatedTask = response.data.data;
|
||||
|
||||
// Atualizar na lista
|
||||
set({
|
||||
tasks: get().tasks.map((t) => (t.id === id ? updatedTask : t)),
|
||||
});
|
||||
|
||||
// Atualizar stats se houver
|
||||
if (get().stats) {
|
||||
await get().fetchStats();
|
||||
}
|
||||
|
||||
return updatedTask;
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message });
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletar tarefa
|
||||
*/
|
||||
deleteTask: async (id: string) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
await apiClient.delete(`/tasks/${id}`);
|
||||
|
||||
// Remover da lista
|
||||
set({ tasks: get().tasks.filter((t) => t.id !== id) });
|
||||
|
||||
// Atualizar stats se houver
|
||||
if (get().stats) {
|
||||
await get().fetchStats();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = handleApiError(error);
|
||||
set({ error: message });
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Setar filtros
|
||||
*/
|
||||
setFilters: (filters: TaskFilters) => {
|
||||
set({ filters });
|
||||
},
|
||||
|
||||
/**
|
||||
* Setar erro
|
||||
*/
|
||||
setError: (error: string | null) => {
|
||||
set({ error });
|
||||
},
|
||||
|
||||
/**
|
||||
* Setar loading
|
||||
*/
|
||||
setLoading: (isLoading: boolean) => {
|
||||
set({ isLoading });
|
||||
},
|
||||
|
||||
/**
|
||||
* Limpar estado
|
||||
*/
|
||||
clear: () => {
|
||||
set({
|
||||
tasks: [],
|
||||
stats: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
completed: undefined,
|
||||
sortBy: 'created_at',
|
||||
order: 'desc',
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Hook customizado
|
||||
export const useTasks = () => {
|
||||
const store = useTasksStore();
|
||||
return {
|
||||
tasks: store.tasks,
|
||||
stats: store.stats,
|
||||
isLoading: store.isLoading,
|
||||
error: store.error,
|
||||
filters: store.filters,
|
||||
fetchTasks: store.fetchTasks,
|
||||
fetchStats: store.fetchStats,
|
||||
createTask: store.createTask,
|
||||
updateTask: store.updateTask,
|
||||
deleteTask: store.deleteTask,
|
||||
setFilters: store.setFilters,
|
||||
};
|
||||
};
|
||||
152
frontend-next/lib/types.ts
Normal file
152
frontend-next/lib/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 📋 Task Manager - TypeScript Types
|
||||
* Tipos compartilhados entre frontend e backend
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 🔐 AUTENTICAÇÃO
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
email_confirmed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SignupPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 📝 TAREFAS (TASKS)
|
||||
// ============================================================================
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
completed: boolean;
|
||||
due_date: string | null;
|
||||
category: string | null;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTaskPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
category?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTaskPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
category?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
completed?: boolean;
|
||||
category?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
sortBy?: 'created_at' | 'due_date' | 'priority';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 📊 API RESPONSES
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface TasksListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
count: number;
|
||||
data: Task[];
|
||||
}
|
||||
|
||||
export interface TaskStatsResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: TaskStats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ❌ ERROS
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class TaskApiError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public originalError?: any,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TaskApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 🛒 STORE STATE
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TasksState {
|
||||
tasks: Task[];
|
||||
stats: TaskStats | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
filters: TaskFilters;
|
||||
}
|
||||
Reference in New Issue
Block a user