# Patterns

Code snippets for common UI patterns in humand-create-app projects. These are ready-to-adapt references — read the relevant pattern and pass it to the implementation subagent.

All imports use `@material-hu/components/...`. Never import from `@mui/material` directly.

---

## Page layout with Title header

```tsx
import Stack from '@material-hu/mui/Stack';
import Button from '@material-hu/components/design-system/Buttons/Button';
import Title from '@material-hu/components/design-system/Title';
import { IconPlus } from '@material-hu/icons/tabler';

import { DashboardLayout } from '../../layouts/DashboardLayout';

export const MyPage = () => {
  return (
    <DashboardLayout>
      <Stack sx={{ gap: 3 }}>
        <Stack
          sx={{
            flexDirection: 'row',
            justifyContent: 'space-between',
            alignItems: 'flex-start',
          }}
        >
          <Title
            title="Page title"
            description="Page description text."
            variant="L"
          />
          <Button
            variant="primary"
            size="large"
            startIcon={<IconPlus size={20} />}
          >
            Action label
          </Button>
        </Stack>
        {/* page content */}
      </Stack>
    </DashboardLayout>
  );
};
```

---

## Empty state

Uses the **composed** StateCard (not the design-system one).

```tsx
import StateCard from '@material-hu/components/composed-components/StateCard';
import { IconInfoCircle } from '@material-hu/icons/tabler';

<StateCard
  slotProps={{
    title: {
      title: 'No hay elementos aún',
      description: 'Al crearlos podrás verlos listados aquí',
      variant: 'M',
    },
    avatar: {
      Icon: IconInfoCircle,
      color: 'default',
    },
  }}
/>
```

---

## Table with pagination

```tsx
import Table from '@material-hu/components/design-system/Table';
import TableContainer from '@material-hu/components/design-system/Table/components/TableContainer';
import TableHead from '@material-hu/components/design-system/Table/components/TableHead';
import TableBody from '@material-hu/components/design-system/Table/components/TableBody';
import TableRow from '@material-hu/components/design-system/Table/components/TableRow';
import TableCell from '@material-hu/components/design-system/Table/components/TableCell';
import Pagination from '@material-hu/components/design-system/Inputs/Pagination';

const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const totalPages = Math.ceil(items.length / limit);
const paginatedItems = items.slice((page - 1) * limit, page * limit);

<TableContainer>
  <Table>
    <TableHead>
      <TableRow headerRow>
        <TableCell headerCell>Nombre</TableCell>
        <TableCell headerCell>Estado</TableCell>
        {/* more columns */}
      </TableRow>
    </TableHead>
    <TableBody>
      {paginatedItems.map(item => (
        <TableRow key={item.id}>
          <TableCell>{item.name}</TableCell>
          <TableCell>{/* content */}</TableCell>
        </TableRow>
      ))}
    </TableBody>
  </Table>
</TableContainer>
<Pagination
  type="changer"
  page={page}
  totalPages={totalPages}
  limit={limit}
  limitOptions={[10, 25, 50]}
  onChangePage={setPage}
  onChangeLimit={newLimit => {
    setLimit(newLimit);
    setPage(1);
  }}
/>
```

---

## Status badges with Pills

```tsx
import Pills from '@material-hu/components/design-system/Pills';

type StatusType = 'active' | 'paused' | 'draft';

const STATUS_CONFIG: Record<StatusType, { label: string; type: PillsProps['type'] }> = {
  active: { label: 'ACTIVO', type: 'success' },
  paused: { label: 'PAUSADO', type: 'warning' },
  draft: { label: 'BORRADOR', type: 'neutral' },
};

const { label, type } = STATUS_CONFIG[item.status];
<Pills label={label} type={type} size="small" />
```

---

## Menu via useMenuLayer

Always use the layer hook. Never use MenuList or Menu directly.

```tsx
import { useMenuLayer } from '@material-hu/components/layers/Menus';
import IconButton from '@material-hu/components/design-system/Buttons/IconButton';
import { IconDotsVertical, IconEdit, IconCopy, IconTrash } from '@material-hu/icons/tabler';

const { openMenu } = useMenuLayer();

const handleOpenMenu = (e: React.MouseEvent<HTMLElement>, item: MyItem) => {
  openMenu({
    anchorEl: e.currentTarget,
    items: [
      {
        id: 'edit',
        title: 'Editar',
        icon: IconEdit,
        onSelect: () => handleEdit(item),
      },
      {
        id: 'duplicate',
        title: 'Duplicar',
        icon: IconCopy,
        onSelect: () => handleDuplicate(item),
      },
      {
        id: 'delete',
        title: 'Eliminar',
        icon: IconTrash,
        onSelect: () => handleDelete(item),
      },
    ],
  });
};

<IconButton
  variant="tertiary"
  onClick={e => handleOpenMenu(e, item)}
>
  <IconDotsVertical size={20} />
</IconButton>
```

---

## Dialog confirmation via useDialogLayer

Always use the layer hook. Never use Dialog directly.

```tsx
import { useDialogLayer } from '@material-hu/components/layers/Dialogs';

const { openDialog, closeDialog } = useDialogLayer();

const handleDelete = (item: MyItem) => {
  openDialog({
    title: '¿Eliminar proceso?',
    textBody: `Se eliminará "${item.name}" de forma permanente.`,
    primaryButtonProps: {
      children: 'Eliminar',
      color: 'error',
      onClick: () => {
        // perform delete
        closeDialog();
      },
    },
    secondaryButtonProps: {
      children: 'Cancelar',
      onClick: () => closeDialog(),
    },
  });
};
```

---

## Drawer form via useDrawerLayer

Always use the layer hook. Never use Drawer directly.

```tsx
import { useDrawerLayer } from '@material-hu/components/layers/Drawers';

const { openDrawer, closeDrawer } = useDrawerLayer();

const handleOpenDrawer = () => {
  openDrawer({
    title: 'Editar paso',
    size: 'medium',
    children: <MyDrawerForm onClose={closeDrawer} />,
    footerProps: {
      primaryButtonProps: {
        children: 'Guardar',
        form: 'my-form-id',
        type: 'submit',
      },
      secondaryButtonProps: {
        children: 'Cancelar',
        onClick: () => closeDrawer(),
      },
    },
  });
};
```

---

## Search input

```tsx
import Search from '@material-hu/components/design-system/Inputs/Search';

const [search, setSearch] = useState('');

<Search
  value={search}
  onChange={e => setSearch(e.target.value)}
  placeholder="Buscar"
  variant="classic"
/>
```

---

## Stepper

```tsx
import Stepper from '@material-hu/components/design-system/Stepper';
import Step from '@material-hu/components/design-system/Stepper/components/Step';

const [activeStep, setActiveStep] = useState(0);
const steps = ['Información básica', 'Contenido', 'Asignación', 'Revisión'];

<Stepper>
  {steps.map((label, index) => (
    <Step
      key={label}
      label={label}
      current={index === activeStep}
      completed={index < activeStep}
      last={index === steps.length - 1}
    />
  ))}
</Stepper>
```

---

## Form with React Hook Form + Zod

```tsx
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import FormInputClassic from '@material-hu/components/design-system/Inputs/Classic/form';
import FormTextArea from '@material-hu/components/design-system/Inputs/TextArea/form';

const schema = z.object({
  name: z.string().min(1, 'Requerido').max(100),
  description: z.string().max(256).optional(),
});

type FormValues = z.infer<typeof schema>;

const methods = useForm<FormValues>({
  resolver: zodResolver(schema),
  defaultValues: { name: '', description: '' },
});

<FormProvider {...methods}>
  <form id="my-form" onSubmit={methods.handleSubmit(onSubmit)}>
    <FormInputClassic
      name="name"
      inputProps={{
        label: 'Nombre',
        placeholder: 'Ingresá un nombre',
        hasCounter: true,
        maxLength: 100,
      }}
    />
    <FormTextArea
      name="description"
      inputProps={{
        label: 'Descripción (opcional)',
        placeholder: 'Ingresá una descripción',
      }}
    />
  </form>
</FormProvider>
```

---

## Vercel API proxy route (external service)

Server-side proxy that keeps API keys out of the browser. Each operation is an explicit route — no open passthrough.

```typescript
// api/<service-name>/[...path].ts
import type { VercelRequest, VercelResponse } from '@vercel/node';

const ROUTES: Record<string, (req: VercelRequest) => Promise<unknown>> = {
  'GET /contacts': handleGetContacts,
  'POST /contacts': handleCreateContact,
};

export default async function handler(req: VercelRequest, res: VercelResponse) {
  const path = Array.isArray(req.query.path)
    ? req.query.path.join('/')
    : req.query.path ?? '';

  const routeKey = `${req.method} /${path}`;
  const routeHandler = ROUTES[routeKey];

  if (!routeHandler) {
    return res.status(404).json({ error: 'Not found' });
  }

  try {
    const data = await routeHandler(req);
    return res.status(200).json(data);
  } catch (error) {
    console.error(`[api/<service-name>] ${routeKey} failed:`, error);
    return res.status(500).json({ error: 'Internal server error' });
  }
}

async function handleGetContacts(_req: VercelRequest) {
  const API_KEY = process.env.SERVICE_API_KEY;
  if (!API_KEY) throw new Error('Missing API key');

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch('https://api.example.com/contacts', {
      headers: { Authorization: `Bearer ${API_KEY}` },
      signal: controller.signal,
    });
    if (!response.ok) throw new Error(`Upstream ${response.status}`);
    const raw = await response.json();
    return raw.results.map((c: any) => ({ id: c.id, name: c.name, email: c.email }));
  } finally {
    clearTimeout(timeout);
  }
}
```

---

## react-query: query hook

Wraps a service call with `useQuery`. Always use single-object syntax.

```tsx
import { useQuery } from '@tanstack/react-query';
import { contactsService } from '../services/contacts';

// Query keys factory
export const contactsKeys = {
  all: () => ['contacts'] as const,
  lists: () => [...contactsKeys.all(), 'list'] as const,
  list: (params?: unknown) => [...contactsKeys.lists(), params] as const,
};

export const useGetContacts = (params?: ContactFilters) => {
  const { data, ...query } = useQuery({
    queryKey: contactsKeys.list(params),
    queryFn: () => contactsService.getContacts(params),
  });

  return { contacts: data ?? [], ...query };
};
```

---

## react-query: mutation hook

Wraps a service call with `useMutation`. Invalidates related queries on success.

```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { contactsService } from '../services/contacts';

export const useCreateContactMutation = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: contactsService.createContact,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: contactsKeys.lists() });
    },
  });
};
```

---

## react-query: using hooks in a component

```tsx
import { useGetContacts, useCreateContactMutation } from '../../services/contacts.hooks';

export const ContactList = () => {
  const { contacts, isLoading } = useGetContacts();
  const createContact = useCreateContactMutation();

  const handleCreate = (data: CreateContactInput) => {
    createContact.mutate(data, {
      onSuccess: () => {
        // UI feedback: toast, close drawer, etc.
      },
    });
  };

  if (isLoading) return <CircularProgress />;

  return (
    // render contacts...
  );
};
```
