From dev-team-kit-fv
Implements React/Next.js components and pages with Zustand state management, React Query API integration, skeleton loading, and responsive Tailwind design. Use for frontend UI/UX and API tasks.
npx claudepluginhub felvieira/claude-skills-fvThis skill uses the workspace's default tool permissions.
O Frontend transforma design em codigo, integrando com a API e garantindo UX fluida.
Builds React 19 components, Next.js 15 pages, responsive layouts, and client-side state management with Zustand. Fixes performance, accessibility, and data flows.
Builds React 19 components, Next.js 15 apps, responsive layouts, and client-side state with Zustand or React Query. Optimizes performance and accessibility for UI development and fixes.
Builds React/Next.js frontends: selects React+Vite vs Next.js, structures page/feature/UI components, guides state management (useState/Context/Zustand), uses Tailwind.
Share bugs, ideas, or general feedback.
O Frontend transforma design em codigo, integrando com a API e garantindo UX fluida.
Esta skill segue GLOBAL.md, policies/execution.md, policies/handoffs.md, policies/quality-gates.md, policies/token-efficiency.md, policies/stack-flexibility.md, policies/tool-safety.md e policies/evals.md.
Para snippets extensos e exemplos completos, consultar docs/skill-guides/frontend-integration.md apenas quando a tarefa exigir.
Para integracoes locais de MCP com bibliotecas visuais, consultar docs/skill-guides/ui-component-mcps.md.
Quando a tarefa exigir navegacao real, screenshots ou verificacao visual do app rodando, esta skill pode configurar ou reutilizar Playwright MCP localmente.
Para auth, o access token fica apenas em memoria. Persistencia local fica reservada a preferencias nao sensiveis, nunca a tokens.
Stack de referencia:
Para estrutura de pastas e exemplos completos de store, auth e API client, consultar docs/skill-guides/frontend-integration.md.
accessToken apenas em memoriaEsta skill pode instalar ou configurar localmente MCPs de bibliotecas como Magic UI MCP e React Bits MCP quando isso acelerar a implementacao e o projeto nao tiver equivalente melhor.
Regras:
Para validacao visual real, esta skill pode usar Playwright MCP para:
Usar especialmente quando a mudanca visual nao puder ser validada com confianca apenas por leitura de codigo.
Para exemplos completos de store, authStore, uiStore e api client, consultar docs/skill-guides/frontend-integration.md.
src/lib/query-keys.ts
export const queryKeys = {
users: {
all: ['users'] as const,
lists: () => [...queryKeys.users.all, 'list'] as const,
list: (params: Record<string, unknown>) => [...queryKeys.users.lists(), params] as const,
details: () => [...queryKeys.users.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (params: Record<string, unknown>) => [...queryKeys.posts.lists(), params] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.posts.details(), id] as const,
},
} as const;
src/hooks/useApi.ts
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { ApiResponse, PaginatedResponse } from '@/types/api';
export function usePaginatedQuery<T>(
queryKey: readonly unknown[],
url: string,
params?: Record<string, unknown>,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: [...queryKey, params],
queryFn: async () => {
const { data } = await api.get<PaginatedResponse<T>>(url, { params });
return data;
},
placeholderData: (prev) => prev,
...options,
});
}
export function useDetailQuery<T>(
queryKey: readonly unknown[],
url: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey,
queryFn: async () => {
const { data } = await api.get<ApiResponse<T>>(url);
return data.data;
},
...options,
});
}
export function useApiMutation<TInput, TOutput = unknown>(
method: 'post' | 'patch' | 'delete',
url: string | ((variables: TInput) => string),
options?: {
invalidateKeys?: readonly unknown[][];
onSuccess?: (data: TOutput) => void;
optimistic?: {
queryKey: readonly unknown[];
updater: (old: any, variables: TInput) => any;
};
}
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (variables: TInput) => {
const endpoint = typeof url === 'function' ? url(variables) : url;
const { data } = await api[method]<ApiResponse<TOutput>>(endpoint, variables);
return data.data;
},
onMutate: options?.optimistic
? async (variables) => {
await queryClient.cancelQueries({ queryKey: options.optimistic!.queryKey });
const previous = queryClient.getQueryData(options.optimistic!.queryKey);
queryClient.setQueryData(
options.optimistic!.queryKey,
(old: any) => options.optimistic!.updater(old, variables)
);
return { previous };
}
: undefined,
onError: options?.optimistic
? (err, variables, context: any) => {
queryClient.setQueryData(options.optimistic!.queryKey, context?.previous);
}
: undefined,
onSuccess: (data) => {
options?.invalidateKeys?.forEach((key) => {
queryClient.invalidateQueries({ queryKey: key });
});
options?.onSuccess?.(data);
},
});
}
src/components/ui/Skeleton.tsx
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
variant?: 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
lines?: number;
}
export function Skeleton({ className, variant = 'text', width, height, lines = 1 }: SkeletonProps) {
const baseClass = 'animate-pulse bg-gray-200 rounded';
if (variant === 'circular') {
return (
<div
className={cn(baseClass, 'rounded-full', className)}
style={{ width: width || 40, height: height || 40 }}
/>
);
}
if (variant === 'rectangular') {
return (
<div
className={cn(baseClass, className)}
style={{ width: width || '100%', height: height || 200 }}
/>
);
}
return (
<div className="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(baseClass, 'h-4', className)}
style={{
width: i === lines - 1 ? '60%' : i % 2 === 0 ? '100%' : '80%',
}}
/>
))}
</div>
);
}
export function withSkeleton<T>(
Component: React.ComponentType<T>,
SkeletonComponent: React.ComponentType
) {
return function SkeletonWrapper({ isLoading, ...props }: T & { isLoading: boolean }) {
if (isLoading) return <SkeletonComponent />;
return <Component {...(props as T)} />;
};
}
src/components/skeletons/UserListSkeleton.tsx
import { Skeleton } from '@/components/ui/Skeleton';
export function UserListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 border rounded-lg">
<Skeleton variant="circular" width={48} height={48} />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-8 w-20" variant="rectangular" />
</div>
))}
</div>
);
}
src/lib/api-client.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore';
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const csrfToken = getCookie('csrf-token');
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
return config;
});
let isRefreshing = false;
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];
const processQueue = (error: any, token: string | null) => {
failedQueue.forEach((prom) => {
if (error) prom.reject(error);
else prom.resolve(token);
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
const { accessToken, user } = data.data;
useAuthStore.getState().setAuth(user, accessToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
if (typeof window !== 'undefined') window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? match[2] : null;
}
src/app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
</QueryClientProvider>
);
}
src/app/(dashboard)/users/page.tsx
'use client';
import { useState } from 'react';
import { usePaginatedQuery } from '@/hooks/useApi';
import { queryKeys } from '@/lib/query-keys';
import { UserListSkeleton } from '@/components/skeletons/UserListSkeleton';
import { UserCard } from '@/components/features/UserCard';
import { Pagination } from '@/components/ui/Pagination';
import { SearchInput } from '@/components/ui/SearchInput';
import { useDebounce } from '@/hooks/useDebounce';
import type { User } from '@/types/user';
export default function UsersPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const params = { page, perPage: 20, search: debouncedSearch };
const { data, isLoading, isError, error } = usePaginatedQuery<User>(
queryKeys.users.list(params),
'/api/v1/users',
params
);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
<h1 className="text-2xl font-bold">Usuários</h1>
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar usuários..."
/>
</div>
{isLoading ? (
<UserListSkeleton count={5} />
) : isError ? (
<ErrorState message={error.message} onRetry={() => {}} />
) : data?.data.length === 0 ? (
<EmptyState message="Nenhum usuário encontrado" />
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data?.data.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
{data?.meta && (
<Pagination
page={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={setPage}
/>
)}
</>
)}
</div>
);
}
Toda tela que faz fetch DEVE ter estes estados:
Retry pattern no useApi:
const RETRY_CONFIG = {
retries: 3,
retryDelay: (attempt: number) => Math.min(1000 * 2 ** attempt, 10000),
};
Entregar:
Codigo deve priorizar clareza. Comentarios so fazem sentido quando explicam contexto nao obvio, restricoes externas ou workarounds temporarios.