---
name: react-feature-frontend
description: >
  Use this skill whenever creating or modifying frontend code in the my-schedule project.
  Covers feature-based folder structure, React Query, Zustand, component patterns,
  and service layer. Trigger on: "create page", "create component", "create feature",
  "add hook", "add form", or any task touching apps/web/src/.
---

# React Frontend — my-schedule

This skill defines how all frontend code must be written in this project.
Stack: React 18 + TypeScript + Vite + Tailwind CSS + React Query + Zustand.

---

## Folder Structure — Feature-based

All code lives inside a feature folder. Features map 1:1 to domain concepts.

```
src/
├── features/
│   ├── appointments/
│   │   ├── components/          ← componentes exclusivos desta feature
│   │   │   ├── AppointmentCard.tsx
│   │   │   ├── AppointmentStatusBadge.tsx
│   │   │   └── CancelAppointmentModal.tsx
│   │   ├── hooks/               ← React Query hooks desta feature
│   │   │   ├── useAppointments.ts
│   │   │   └── useCancelAppointment.ts
│   │   ├── services/            ← chamadas HTTP desta feature
│   │   │   └── appointments.service.ts
│   │   ├── types.ts             ← tipos locais da feature
│   │   └── index.ts             ← exporta apenas o que outras features precisam
│   │
│   ├── booking/                 ← fluxo público de agendamento (multi-step)
│   │   ├── components/
│   │   │   ├── StepProfessional.tsx
│   │   │   ├── StepService.tsx
│   │   │   ├── StepSlot.tsx
│   │   │   └── StepIdentify.tsx
│   │   ├── hooks/
│   │   │   ├── useAvailableSlots.ts
│   │   │   └── useCreateBooking.ts
│   │   ├── services/
│   │   │   └── booking.service.ts
│   │   ├── store.ts             ← Zustand store local da feature (multi-step state)
│   │   └── index.ts
│   │
│   ├── professionals/
│   ├── services/                ← (serviços do salão)
│   ├── availability/
│   └── auth/
│
├── pages/                       ← apenas composição de features, sem lógica
│   ├── admin/
│   │   ├── AppointmentsPage.tsx
│   │   └── ProfessionalsPage.tsx
│   ├── public/
│   │   └── BookingPage.tsx
│   └── customer/
│       └── MyAppointmentsPage.tsx
│
├── shared/                      ← código genuinamente compartilhado
│   ├── components/
│   │   ├── ui/                  ← Button, Input, Modal, Badge, Avatar
│   │   └── layout/              ← AdminLayout, PublicLayout, Sidebar
│   ├── hooks/
│   │   └── useDebounce.ts
│   ├── lib/
│   │   ├── api.ts               ← instância Axios configurada
│   │   └── formatters.ts        ← moeda, data, telefone
│   └── types/
│       └── common.ts            ← Paginated<T>, ApiError, etc.
│
└── stores/
    └── auth.store.ts            ← único store global: token + usuário
```

The rule: if a component, hook, or service is used by only one feature, it lives inside that feature. It only moves to `shared/` when a second feature needs it.

---

## Service Layer

Each feature has its own service file that wraps the API calls. No `fetch` or `axios` calls inside components or hooks directly.

```typescript
// features/appointments/services/appointments.service.ts
import { api } from '@/shared/lib/api';
import type { Appointment, CreateAppointmentData } from '../types';
import type { Paginated } from '@/shared/types/common';

export const appointmentsService = {
  getMany: async (params: { page?: number; limit?: number }): Promise<Paginated<Appointment>> => {
    const { data } = await api.get('/appointments', { params });
    return data;
  },

  getById: async (id: string): Promise<Appointment> => {
    const { data } = await api.get(`/appointments/${id}`);
    return data;
  },

  create: async (payload: CreateAppointmentData): Promise<Appointment> => {
    const { data } = await api.post('/appointments', payload);
    return data;
  },

  confirm: async (id: string): Promise<Appointment> => {
    const { data } = await api.patch(`/appointments/${id}/confirm`);
    return data;
  },

  cancel: async (id: string, reason?: string): Promise<Appointment> => {
    const { data } = await api.patch(`/appointments/${id}/cancel`, { reason });
    return data;
  },
};
```

Rules:
- Services are plain objects with async methods — not classes, not hooks.
- Always destructure `{ data }` from axios response — never return the full AxiosResponse.
- URL paths match the backend exactly — no ad-hoc string interpolation outside service files.

---

## React Query Hooks

Each feature has one file per query or mutation. Hooks wrap the service and configure caching.

```typescript
// features/appointments/hooks/useAppointments.ts
import { useQuery } from '@tanstack/react-query';
import { appointmentsService } from '../services/appointments.service';

export const appointmentKeys = {
  all: ['appointments'] as const,
  lists: () => [...appointmentKeys.all, 'list'] as const,
  list: (params: object) => [...appointmentKeys.lists(), params] as const,
  detail: (id: string) => [...appointmentKeys.all, 'detail', id] as const,
};

export function useAppointments(params: { page?: number; limit?: number } = {}) {
  return useQuery({
    queryKey: appointmentKeys.list(params),
    queryFn: () => appointmentsService.getMany(params),
  });
}

export function useAppointment(id: string) {
  return useQuery({
    queryKey: appointmentKeys.detail(id),
    queryFn: () => appointmentsService.getById(id),
    enabled: !!id,
  });
}
```

```typescript
// features/appointments/hooks/useCancelAppointment.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { appointmentsService } from '../services/appointments.service';
import { appointmentKeys } from './useAppointments';

export function useCancelAppointment() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
      appointmentsService.cancel(id, reason),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: appointmentKeys.lists() });
    },
  });
}
```

Rules:
- Always export a `keys` object from the hook file — used to invalidate queries correctly.
- `enabled: !!id` on queries that depend on a param that might be undefined.
- `staleTime` is set explicitly when the data has a clear freshness requirement:
  - Slots disponíveis → `staleTime: 30_000` (30s — mudam rápido)
  - Lista de profissionais → `staleTime: 5 * 60_000` (5min — quase estática)
- Mutations always invalidate the relevant list queries on `onSuccess`.
- One hook file per concern — don't mix queries and mutations in the same file.

---

## Component Pattern

```typescript
// features/appointments/components/AppointmentCard.tsx
import { formatDate, formatCurrency } from '@/shared/lib/formatters';
import { AppointmentStatusBadge } from './AppointmentStatusBadge';
import { useCancelAppointment } from '../hooks/useCancelAppointment';
import type { Appointment } from '../types';

interface AppointmentCardProps {
  appointment: Appointment;
  onCancelSuccess?: () => void;
}

export function AppointmentCard({ appointment, onCancelSuccess }: AppointmentCardProps) {
  const { mutate: cancel, isPending } = useCancelAppointment();

  function handleCancel() {
    cancel(
      { id: appointment.id },
      { onSuccess: onCancelSuccess },
    );
  }

  return (
    <div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm">
      <div className="flex items-start justify-between gap-4">
        <div>
          <p className="font-medium text-zinc-900">{appointment.professional.name}</p>
          <p className="text-sm text-zinc-500">{appointment.service.name}</p>
        </div>
        <AppointmentStatusBadge status={appointment.status} />
      </div>

      <div className="mt-3 flex items-center justify-between">
        <p className="text-sm text-zinc-600">{formatDate(appointment.startTime)}</p>
        <p className="text-sm font-medium text-zinc-900">
          {formatCurrency(appointment.service.price)}
        </p>
      </div>

      {appointment.status === 'pending' || appointment.status === 'confirmed' ? (
        <button
          onClick={handleCancel}
          disabled={isPending}
          className="mt-3 w-full rounded-lg border border-red-200 py-1.5 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
        >
          {isPending ? 'Cancelando...' : 'Cancelar agendamento'}
        </button>
      ) : null}
    </div>
  );
}
```

Rules:
- Components are named exports — never default export.
- Props interface always named `<ComponentName>Props`, defined in the same file.
- Hooks are called at the top of the component — never inside conditionals.
- No `any` types — use the feature's `types.ts` or `@/shared/types/common.ts`.
- Tailwind only — no inline `style={{}}` except for dynamic values Tailwind can't handle.
- Conditional rendering uses ternary or `condition && <El />` — never nested `if/else` inside JSX.

---

## Feature-local Zustand Store

Only use a local Zustand store when a feature has multi-step or complex UI state that doesn't belong on the server. The booking flow is the main example.

```typescript
// features/booking/store.ts
import { create } from 'zustand';
import type { Professional } from '@/features/professionals/types';
import type { Service } from '@/features/services/types';

type BookingStep = 'professional' | 'service' | 'slot' | 'identify' | 'confirm';

interface BookingSlot {
  startTime: string;
  endTime: string;
}

interface BookingState {
  step: BookingStep;
  professional: Professional | null;
  service: Service | null;
  slot: BookingSlot | null;

  setStep: (step: BookingStep) => void;
  selectProfessional: (professional: Professional) => void;
  selectService: (service: Service) => void;
  selectSlot: (slot: BookingSlot) => void;
  reset: () => void;
}

const initialState = {
  step: 'professional' as BookingStep,
  professional: null,
  service: null,
  slot: null,
};

export const useBookingStore = create<BookingState>((set) => ({
  ...initialState,

  setStep: (step) => set({ step }),
  selectProfessional: (professional) => set({ professional, step: 'service' }),
  selectService: (service) => set({ service, step: 'slot' }),
  selectSlot: (slot) => set({ slot, step: 'identify' }),
  reset: () => set(initialState),
}));
```

Rules:
- Zustand stores for UI state only — server data always in React Query.
- Store files are named `store.ts` inside the feature folder.
- The single global store (`src/stores/auth.store.ts`) holds only auth state: token, user, role.
- Actions that advance a multi-step flow (like `selectProfessional`) also set the next step.
- Always export a `reset` action to clear state on unmount or flow completion.

---

## Page Files

Pages only compose features — they contain no business logic, no hooks beyond layout concerns.

```typescript
// pages/admin/AppointmentsPage.tsx
import { AdminLayout } from '@/shared/components/layout/AdminLayout';
import { AppointmentsList } from '@/features/appointments/components/AppointmentsList';
import { AppointmentsFilters } from '@/features/appointments/components/AppointmentsFilters';

export function AppointmentsPage() {
  return (
    <AdminLayout title="Agendamentos">
      <div className="flex flex-col gap-4">
        <AppointmentsFilters />
        <AppointmentsList />
      </div>
    </AdminLayout>
  );
}
```

---

## Shared Utilities

```typescript
// shared/lib/formatters.ts
export function formatDate(date: string | Date): string {
  return new Intl.DateTimeFormat('pt-BR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  }).format(new Date(date));
}

export function formatCurrency(value: number): string {
  return new Intl.NumberFormat('pt-BR', {
    style: 'currency',
    currency: 'BRL',
  }).format(value);
}

export function formatPhone(phone: string): string {
  return phone.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
}
```

```typescript
// shared/types/common.ts
export interface Paginated<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
  };
}

export interface ApiError {
  message: string;
  statusCode: number;
  error?: string;
}
```

---

## Checklist — Before Finishing a Feature

- [ ] Service file wraps all HTTP calls, returns typed data (no `any`)
- [ ] Query hooks export a `keys` object for cache invalidation
- [ ] Mutations invalidate related list queries on `onSuccess`
- [ ] Components are named exports with typed `Props` interface
- [ ] UI state uses Zustand store local to the feature
- [ ] Server state uses React Query only — no `useState` for fetched data
- [ ] Formatters from `shared/lib/formatters.ts` for dates, currency, phone
- [ ] Feature exports only what other features need via `index.ts`
- [ ] Page file has no business logic — only layout composition
