feat(frontend): Setup Next.js with Zustand stores and API client - Phase 2 initialization

This commit is contained in:
Erik Silva
2025-12-01 01:43:21 -03:00
parent 5ff1a4a004
commit 888e4e4d60
7 changed files with 990 additions and 20 deletions

87
frontend-next/lib/api.ts Normal file
View 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;

View 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,
};
};

View File

@@ -0,0 +1,6 @@
/**
* 🛒 Store Exports
*/
export { useAuthStore, useAuth } from './auth.store';
export { useTasksStore, useTasks } from './tasks.store';

View 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
View 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;
}