Vitrine vazia

Este host publica o scaffold. Não há catálogo aqui.

Leia-me

# Winna

Vitrine vazia da Winna na org `aranduOS`. Não há catálogo desenhado aqui. O contrato de uso está nestes documentos, e a identidade pública está em [`loja.json`](loja.json). O `npm run build` junta os textos numa página só.

O host sem domínio próprio é `winna.preview.aranduos.com`.

| Doc | Conteúdo |
|---|---|
| [`docs/loja.md`](docs/loja.md) | Tenant id, admin, preview, checkout |
| [`docs/local.md`](docs/local.md) | Instalar, testar e correr o BFF na tua máquina |
| [`docs/bff.md`](docs/bff.md) | `/api` sem o bearer no browser |
| [`docs/checkout.md`](docs/checkout.md) | Carrinho aqui, pagamento no checkout hospedado |
| [`docs/dominios.md`](docs/dominios.md) | Domínio próprio sem token de DNS |
| [`docs/cursor-claude.md`](docs/cursor-claude.md) | MCP da loja no Cursor/Claude, OAuth |
| [`docs/sdk.md`](docs/sdk.md) | Cliente, token no servidor, o que cada exemplo chama |
| [`docs/deploy.md`](docs/deploy.md) | Pull request, Pages, o que não entra neste repositório |
| [`docs/b2b.md`](docs/b2b.md) | Empresas e orçamentos |
| [`docs/audiencia.md`](docs/audiencia.md) | Tráfego, públicos e base por estágio |
| [`docs/funil.md`](docs/funil.md) | Funil de pré-venda |
| [`AGENTS.md`](AGENTS.md) | Regras para quem edita com um agente |

Exemplos, em TypeScript, sem efeito ao importar:

- [`examples/b2b.ts`](examples/b2b.ts)
- [`examples/audiencia.ts`](examples/audiencia.ts)
- [`examples/funil.ts`](examples/funil.ts)
- [`examples/checkout.ts`](examples/checkout.ts)

Catálogo e carrinho passam pelo `/api` ([`docs/bff.md`](docs/bff.md)). O pagamento é o checkout hospedado ([`docs/checkout.md`](docs/checkout.md)).

audiencia

# Audiência

Há três leituras, nenhuma delas é o catálogo.

`audienceOverview` com `source=first_party` é o tráfego medido pelos eventos da própria loja: sessões, pessoas, novos, páginas. `source=ga4` lê o que foi ingerido do GA4. Scope `reports:read`. `from` e `to` são date-time.

`listMediaAudiences` lista os públicos que a loja monta a partir de quem comprou (compradores, valor, recorrentes, em risco, sumidos, fiéis), com contagem elegível, sem anonimizado e sem opt-out. Lista de prospecção não entra: sem dado first-party com consentimento, não se sobe.

`exploreCustomerBase` é a base pela mesma régua de estágio do core: `new`, `repeat`, `loyal`, `at_risk`, `dormant`, e `never_bought` para quem se cadastrou e não comprou. Scope `customers:read`. O corpo pode ir vazio de filtros. A página traz facetas e o resumo.

O exemplo é [`examples/audiencia.ts`](../examples/audiencia.ts).

b2b

# B2B

Empresa e orçamento são da tag `b2b`. A leitura pede `companies:read`.

`listCompanies` devolve a página de empresas. `listSalesQuotes` filtra por `company_id` e por `status`. Criar empresa (`createCompany`) exige `legal_name` e `document`, scope `companies:write`, e não está no exemplo para ninguém o disparar ao importar o ficheiro.

Enviar, aceitar ou rejeitar um orçamento são comandos à parte (`sendSalesQuote`, `acceptSalesQuote`, `rejectSalesQuote`). Também são escrita, também ficam no servidor.

O exemplo é [`examples/b2b.ts`](../examples/b2b.ts).

bff

# BFF

O browser da vitrine não fala com `api.mira-dev.tech` e não vê o bearer. Fala com `/api/...` neste host. Quem acrescenta `Authorization` e `X-Tenant-ID` é a Function em [`functions/api/[[path]].js`](../functions/api/[[path]].js).

Em produção a variável `COMMERCE_STOREFRONT_BEARER` é `secret_text` do projecto Pages. Não está no repositório, não é `NEXT_PUBLIC_` e não se lê daqui. Em local, `npm run dev:bff` usa o token que tu criaste no admin. O código é o mesmo.

## Prova

No host publicado, sem token nenhum:

| Pedido | Resposta |
|---|---|
| `GET /api/members/me` | 200, com o `member_id` de `loja.json` |
| `GET /api/storefront/products?limit=1` | 200 |
| `GET /api/orders` | 403 |

`503` com `bff_token_missing` quer dizer que a publicação não tem a variável. Não se pede o valor. Abre-se uma issue neste repositório.

## O que passa

A lista é [`scripts/bff-allowlist.mjs`](../scripts/bff-allowlist.mjs). É a allowlist do core para o bearer de vitrine: catálogo, composição de checkout, sessão de checkout, frete, preço e o conteúdo público da loja. Qualquer outro path responde 403, mesmo que o token tenha scope para ele.

`..` e barra codificada no path respondem 400.

## Sessão de checkout

`POST /api/checkout/cart` e `POST /api/checkout/sessions` podem devolver `session_token` uma vez. A Function tira esse campo do JSON e grava-o no cookie `checkout_session` (`HttpOnly`, `Secure`, `SameSite=Lax`). No pedido seguinte o cookie volta como `X-Checkout-Session-Token`. Não o metas em `localStorage` nem em query string.

## O que não fazer

- Não pôr o bearer no HTML, no JavaScript do browser, nem numa variável `NEXT_PUBLIC_`.
- Não fazer de `/api` um proxy aberto.
- Não acrescentar `orders` à allowlist para "ver um pedido". Pedido do comprador é a sessão dele, no checkout hospedado.

checkout

# Checkout

O checkout da Winna não se constrói neste repositório. É a app hospedada em `loja.checkout`:

`https://winna-checkout.mira-dev.tech/compra`

O tenant sai desse hostname. Não se manda `member_id` na query.

## Da vitrine para o checkout

1. A página chama o BFF, não a API.

```json
POST /api/checkout/cart
{
  "channel": "web",
  "op": "add",
  "items": [{ "sku": "<sku>", "quantity": 1 }]
}
```

2. A resposta traz o carrinho. O `session_token` fica no cookie, não no corpo.
3. O browser segue para `https://winna-checkout.mira-dev.tech/compra`. O pagamento, a entrega e a confirmação são aquela app.

`op` é `add`, `set` ou `remove`. Preço no body é ignorado: quem calcula é o core (`composeCheckoutCart`).

O exemplo [`examples/checkout.ts`](../examples/checkout.ts) só monta este pedido. Não corre ao importar.

## O que fica neste repo

- O link para o checkout.
- A chamada de carrinho pelo BFF.
- Domínio próprio da vitrine, em `loja.json`. Isso não muda o host do checkout.

cursor-claude

# Cursor e Claude — MCP da loja, por OAuth

Isto liga o Cursor ou o Claude à **Winna**, para cadastrar produto, gravar configuração e importar ficheiro. A autenticação é OAuth 2.1 (PKCE). Não se cola API key.

O admin da loja é `https://winna.aranduos.com`. O `member_id` está em [`loja.json`](../loja.json). Não é segredo. O browser de consentimento entra nesse admin.

## O que não fazer

- Não commitar `.cursor/mcp.json`, `~/.cursor/mcp.json`, `~/.claude.json` nem `~/.claude/mcp.json`. O `.gitignore` ignora `.cursor/mcp.json`.
- Não pôr `headers`, `X-API-Key`, `X-Tenant-ID`, `mcp_live_…` nem bearer na config. O tenant já vai no path.
- Não usar o endpoint legado `https://api.mira-dev.tech/mcp`. Esse é de bot e de CI.
- Não apontar o mesmo servidor a outra loja. O nome desta é `mira-commerce-winna`.

## Configuração

O `<member_id>` é o campo `member_id` de `loja.json` (`9fed737f-7a30-4014-84c4-577a0862b451`).

```json
{
  "mcpServers": {
    "mira-commerce-winna": {
      "url": "https://api.mira-dev.tech/tenants/<member_id>/mcp"
    }
  }
}
```

No Cursor, isto fica no `mcp.json` **local** (do projecto ou do utilizador). No Claude Code, Claude Desktop ou Codex, a mesma entrada, com a chave `url`. No Windsurf, se o schema pedir, use `serverUrl` no lugar de `url`. Continua sem headers.

Reinicie o cliente. Na primeira chamada ele descobre o OAuth, regista um client público e abre o browser. Entre **já autenticado** em `https://winna.aranduos.com` e autorize **Operação**. Só leitura não cria produto, não grava configuração e não aplica importação.

O access token dura cerca de 12 horas e renova sozinho com o refresh (cerca de 30 dias). Não gere outro token a cada erro. Reconecte só se a sessão foi revogada ou se o papel no admin mudou. Sessões antigas revogam-se em `https://winna.aranduos.com/admin/settings/mcp-tokens`.

Se uma tool `api_*` responder `insufficient scope`, o token foi emitido antes do papel ter esse scope. Revogue e autorize de novo em Operação.

## Como achar a tool

O `tools/list` não traz as mil operações. Traz o núcleo e duas meta-tools:

1. `mira_search_tools` — procura pelo nome.
2. `mira_get_tool_schema` — devolve o corpo de **uma** tool.

Procure, leia o schema, chame. Não invente campos.

Para anunciar só o catálogo: `https://api.mira-dev.tech/tenants/<member_id>/mcp/x/catalog`. A suite muda o que aparece, não o que o token pode fazer.

O catálogo público, sem credencial, é `https://api.mira-dev.tech/mcp/catalog`. Tool listada ali e ausente no Cursor é scope ou suite, não uma tool em falta.

## Cadastro de produto

Preset **Operação**, scope `catalog:write`.

1. `mira_search_tools` com `produto`.
2. `mira_get_tool_schema` em `api_create_product`.
3. `api_create_product`. SKU à parte: `api_create_product_sku`. Código de barras: `api_create_product_barcode`.
4. Confirme com `search_products` ou `api_list_products` antes de criar de novo.

O prompt `operar-catalogo` (`prompts/get`) descreve a ordem. Leia `mira://docs/business-rules` antes da primeira escrita.

## Configurações

A configuração geral da loja é `api_get_tenant_settings` (`settings:read`) e `api_update_tenant_settings` (`settings:write`). O update pode pedir step-up: a pessoa confirma no admin. O agente não fabrica esse token.

Outras configurações são outras tools. Procure `settings` e leia o schema. Exemplos que existem: `api_get_presales_settings`, `api_get_product_feed_settings`, `api_get_payment_link_settings`. Não assuma que uma serve para a outra.

## Importações

A prévia não grava. Aplicar grava.

1. `api_list_import_targets` — destinos e colunas. É daqui que se sabe a planilha. Não há lista embutida neste repositório.
2. `api_create_import` — scope `imports:write`. Corpo JSON com o ficheiro em `content_base64` ou `content_text`, mais `target`. Devolve a prévia: contagens e linhas com problema. Nada foi para o razão.
3. Corrija linha a linha com `api_update_import_row` se o schema o permitir.
4. `api_apply_import` — só as linhas `ready`. Inválida, duplicada ou excluída fica de fora. O resultado diz quantas.

Não chame o apply no mesmo turno da prévia sem a pessoa ter visto as contagens.

deploy

# Deploy

A Winna publica no Cloudflare Pages, projecto `storefront-winna`, conta da Mirá. O host sem domínio próprio é `winna.preview.aranduos.com`.

Este repositório não tem workflow de deploy e não tem Actions secrets. O Pages, na conta da Mirá, corre `npm run build`. Isso gera `out/index.html` com o texto destes documentos. O token da Cloudflare não entra aqui. O `CNAME` de `winna.preview.aranduos.com` aponta para `storefront-winna.pages.dev`.

## Como pedir uma publicação

1. Fork privado.
2. Pull request do fork para `main` deste repositório.
3. O check que decide é o que corre a partir de `main`, sem segredo. Um workflow trazido pelo PR pode ficar verde. Não conta.
4. O merge em `main` é o deploy.

Não corras `deploy-k8s.yml` nem `deploy-pages.yml`. Esses ficheiros saíram. O core recusa actualizar o pod (ADR 0247).

## O que a Mirá faz, e tu não

- Gravar o bearer como `secret_text` do projecto Pages e voltar a publicar. O valor não se lê daqui. A prova está em [`bff.md`](bff.md).
- Criar o `CNAME` de `winna.preview.aranduos.com` para o projecto Pages.
- Registar o tenant no core. Sem essa linha, o host não é oficial.

## Ambiente local

Ver [`local.md`](local.md). `.env.example` lista as variáveis com valor vazio. O `.env` fica de fora do git.

Domínio próprio: [`dominios.md`](dominios.md). BFF: [`bff.md`](bff.md). Checkout: [`checkout.md`](checkout.md). A identidade da loja: [`loja.json`](../loja.json).

dominios

# Domínios

Há quatro hosts. Só um se edita neste repositório.

| Host | Quem muda | Onde |
|---|---|---|
| `winna.preview.aranduos.com` | Já é a vitrine sem domínio próprio | `loja.preview` |
| `winna.aranduos.com` | Admin. Não é a vitrine | `loja.admin` |
| `winna-checkout.mira-dev.tech` | Checkout hospedado | `loja.checkout` |
| O domínio da marca | Tu, neste ficheiro | `loja.custom_hosts` |

## Domínio próprio

Acrescenta o hostname em `custom_hosts` e abre o pull request. Sem `https://`, sem path.

```json
"custom_hosts": ["www.exemplo.com"]
```

O PR é o pedido. O registo DNS fica na zona da Mirá. Tu não recebes `CLOUDFLARE_API_TOKEN` nem entras na conta Cloudflare. Não cries o CNAME à mão e não peças o token.

Enquanto esse registo não existir, o endereço que funciona é o `preview`. O merge em `main` publica o código nesse host sem um passo extra.

Não uses o label `preview` como slug de loja. `preview.aranduos.com` é da plataforma.

funil

# Funil de vendas

`presalesFunnelReport` é o funil de pré-venda: entrada, conversão entre estágios, tempo mediano, primeira resposta e motivos de perda. Scope `presales:read`.

O corpo leva `from` e `to` como instante. O dia civil é o fuso do tenant. `group_by` recorta por `source`, `channel`, `campaign`, `owner`, `stage` ou `state`. O exemplo agrupa por `stage`.

Métrica que a base não mede volta ausente. A cobertura diz porquê. Não se trata ausência como zero.

A agenda do vendedor (`getCustomerAgenda`) e a base (`exploreCustomerBase`) usam a mesma régua de estágio. O funil não se recalcula na vitrine.

O exemplo é [`examples/funil.ts`](../examples/funil.ts).

local

# Testes locais

Não precisas de chave da Cloudflare, de bearer de produção, nem de acesso ao cluster.

```bash
cp .env.example .env
npm install
npm run test:local
npm run typecheck
npm run build
```

`npm install` usa o teu `GITHUB_TOKEN` com `read:packages` (ver `.npmrc`). `test:local` lê `loja.json` e recusa segredo nesse ficheiro. `typecheck` e `build` não chamam a API.

## Token teu

No `.env`:

- `COMMERCE_TOKEN` — token que criaste em `https://winna.aranduos.com/admin/settings/api-tokens`, com o scope do exemplo (`companies:read`, `reports:read`, `growth:read`, `presales:read`, `customers:read`). Serve para B2B, audiência e funil, no servidor.
- `COMMERCE_STOREFRONT_BEARER` — outro token teu, criado na mesma tela, só para o BFF local. Em produção este nome é uma variável do Pages que tu não lês. Localmente o valor é o teu, e não sai da máquina.

O `member_id` já está em `loja.json`. Não o copies para o git outra vez.

## BFF na tua máquina

```bash
npm run dev:bff
```

Ouve em `http://127.0.0.1:8788`. Só encaminha os paths da allowlist. Sem o teu token no `.env`, sai com erro e não abre a porta.

```bash
curl -sS http://127.0.0.1:8788/api/members/me
```

`GET /api/orders` tem de responder 403. Se responder 200, a allowlist falhou: não publiques.

O checkout local usa o mesmo processo. O `session_token` volta num cookie `HttpOnly`, não no JSON.

loja

# Loja

A identidade pública da Winna está em [`loja.json`](../loja.json), na raiz. É o que precisas para desenvolver. Não é segredo.

| Campo | Uso |
|---|---|
| `member_id` | UUID do tenant. Vai no header `X-Tenant-ID` e no path do MCP. O mesmo valor que o admin mostra. |
| `api` | `https://api.mira-dev.tech` |
| `admin` | Admin da loja, `https://winna.aranduos.com` |
| `preview` | Vitrine sem domínio próprio, `https://winna.preview.aranduos.com` |
| `checkout` | Checkout hospedado, `https://winna-checkout.mira-dev.tech` |
| `custom_hosts` | Domínios próprios desta vitrine. Lista de hostnames, sem `https://` |

O `member_id` vazio significa que o tenant ainda não foi registado. Não inventes um UUID.

Tokens não entram neste ficheiro. O teu token de staff cria-se no admin, em `https://winna.aranduos.com/admin/settings/api-tokens`, e fica só no `.env` da tua máquina. O valor aparece uma vez, na criação. Não o peças à Mirá e não o commits.

sdk

# SDK

Pacote: `@mira-dev-tech/commerce-client-sdk`, tag `next` (o contrato publicado). Cada função é uma operação do OpenAPI. O nome é o `operationId`.

## Instalar

O registo é o GitHub Packages. O `GITHUB_TOKEN` do `.npmrc` é um token **teu**, só com `read:packages`. Não é token da plataforma.

```bash
cp .env.example .env
npm install
npm run typecheck
```

## Cliente

`examples/client.ts` cria o cliente com `COMMERCE_TOKEN`. O `member_id` vem de `loja.json` e sai no header `X-Tenant-ID`. Não o coloques outra vez no `fetch`.

O token fica no processo que corre no servidor. Código que o browser descarrega não importa `@mira-dev-tech/commerce-client-sdk` com esse token. O bearer da vitrine (`COMMERCE_STOREFRONT_BEARER`) também não serve para estes exemplos: ele é da superfície pública (catálogo, checkout). B2B, audiência e funil pedem scope de staff.

| Exemplo | Operações | Scope |
|---|---|---|
| `examples/b2b.ts` | `listCompanies`, `listSalesQuotes` | `companies:read` |
| `examples/audiencia.ts` | `audienceOverview`, `listMediaAudiences`, `exploreCustomerBase` | `reports:read`, `growth:read`, `customers:read` |
| `examples/funil.ts` | `presalesFunnelReport` | `presales:read` |

As funções devolvem `{ data }`. Não há chamada no import: quem quiser os dados chama a função.

Catálogo e checkout, quando fizerem falta, importam-se de `@mira-dev-tech/commerce-client-sdk/storefront-api` (`listStorefrontProducts`, `composeCheckout`). Esse subpath é a superfície da vitrine. B2B, audiência e funil estão no pacote completo e continuam no servidor.