Prechádzať zdrojové kódy

add the systems about order

gdias 2 týždňov pred
rodič
commit
d0d010e9d9

+ 405 - 0
README_ORDERBOOK.md

@@ -0,0 +1,405 @@
+# Integração do Orderbook no Frontend (Svelte)
+
+> **Importante:** todo o projeto usa apenas JavaScript nos `<script>` dos componentes Svelte. Os exemplos abaixo seguem esse padrão (nada de TypeScript ou arquivos `.ts`).
+
+Este guia explica como o frontend Svelte do Tooeasy deve se integrar aos novos
+endpoints PHP de orderbook e pagamentos. O objetivo é aproveitar os scripts
+já existentes no projeto (`<script>` dos componentes Svelte) sem criar novos
+bundles JavaScript externos.
+
+## 1. Pré-requisitos
+
+- `VITE_API_URL` configurada apontando para a API PHP (ex.: `http://localhost:8000`).
+- Stores `authToken` e `companyId` já disponíveis em `$lib/utils/stores`.
+- Telas existentes:
+  - `src/routes/trading/+page.svelte`: filtros de estado/commodity e tabela do
+    book (reaproveitaremos a lógica atual).
+  - `src/routes/cpr/+page.svelte`: modal Pix (pode ser reutilizado para o fluxo
+    de compra de ordens).
+
+## 2. Convenções de requisições
+
+### Cabeçalhos padrão
+```js
+const token = get(authToken);
+const headers = {
+  'content-type': 'application/json',
+  Authorization: `Bearer ${token}`
+};
+```
+Todas as chamadas descritas abaixo devem ser feitas dentro do `<script>` dos
+componentes Svelte. Use `fetch` nativo e atualize as `let` reativas existentes.
+
+### Helper genérico
+No topo do script do `+page.svelte` (trading) vale manter um helper para tratar
+as respostas envelopadas:
+```js
+async function parseResponse(res) {
+  const raw = await res.text();
+  return raw ? JSON.parse(raw) : null;
+}
+```
+
+## 3. Endpoint: `POST /orderbook/filter`
+
+**Onde usar:** sempre que `selectedState` ou `selectedCommodity` mudar.
+
+### Payload esperado
+```json
+{
+  "state": "SP",
+  "commodity_type": "SOJA"
+}
+```
+
+### Resposta de sucesso
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_ORDERBOOK_FILTER",
+  "data": {
+    "state": "SP",
+    "commodity_type": "SOJA",
+    "orders": [
+      {
+        "orderbook_id": 321,
+        "orderbook_amount": "1000",
+        "token_external_id": "TOKEN_ABC123",
+        "status_id": 0,
+        "token_commodities_value": 150000,
+        "token_commodities_amount": 1000,
+        "chain_id": 1,
+        "wallet_id": 10
+      }
+    ]
+  }
+}
+```
+
+### Erros comuns
+- `400 – E_VALIDATE`: campos ausentes ou vazios.
+- `500 – E_DATABASE`: falha ao consultar o book.
+
+```js
+async function fetchOrderbook(state, commodityId) {
+  const payload = {
+    state,
+    commodity_type: commodityPayloadValue(commodityId)
+  };
+
+  orderbookLoading = true;
+  orderbookError = '';
+
+  try {
+    const res = await fetch(`${apiUrl}/orderbook/filter`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify(payload)
+    });
+    const body = await parseResponse(res);
+
+    if (!res.ok || body?.status !== 'ok') {
+      throw new Error(body?.msg ?? 'Falha ao carregar ordens.');
+    }
+
+    const orders = body?.data?.orders ?? [];
+    ordensVenda = orders.map(mapOrderResponse);
+    orderbookEmptyMessage = orders.length ? '' : 'Nenhuma ordem encontrada.';
+  } catch (err) {
+    orderbookError = err?.message ?? 'Falha ao carregar ordens.';
+    resetOrderbook();
+  } finally {
+    orderbookLoading = false;
+  }
+}
+```
+- `mapOrderResponse` deve converter `token_commodities_value` para `valor` em R$
+  e `orderbook_amount` para `quantidade`.
+
+## 4. Endpoint: `POST /token/orderbook`
+
+**Onde usar:** após emitir uma CPR e desejar listar o token no book (ex.: botão
+"Publicar no orderbook" dentro da tela de emissão).
+
+### Payload esperado
+```json
+{
+  "cpr_id": 42,
+  "value": 150000.75,
+  "state": "SP",
+  "commodity_type": "SOJA",
+  "token_external_id": "TOKEN_ABC123"
+}
+```
+
+### Resposta de sucesso
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_ORDERBOOK_CREATED",
+  "data": {
+    "message": "Token atualizado e ordem registrada com sucesso",
+    "token_id": 99,
+    "orderbook_id": 321
+  }
+}
+```
+
+### Erros principais
+- `400 – E_VALIDATE`: validação falhou (cpr_id, campos vazios etc.).
+- `404 – E_TOKEN_NOT_FOUND`: token inexistente para a CPR.
+- `409 – E_TOKEN_MISMATCH`: token informado não bate com o cadastro da CPR.
+- `500 – E_ORDERBOOK`: erro interno ao atualizar token/criar ordem.
+
+```js
+async function createOrderbookEntry(formData) {
+  const payload = {
+    cpr_id: formData.cpr_id,
+    value: Number(formData.value),
+    state: formData.state?.toUpperCase(),
+    commodity_type: formData.commodity,
+    token_external_id: formData.token_external_id
+  };
+
+  const res = await fetch(`${apiUrl}/token/orderbook`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify(payload)
+  });
+  const body = await parseResponse(res);
+
+  if (!res.ok || body?.status !== 'ok') {
+    throw new Error(body?.msg ?? 'Falha ao registrar ordem.');
+  }
+
+  return body?.data; // contém token_id e orderbook_id
+}
+```
+
+## 5. Endpoint: `POST /orderbook/payment`
+
+**Onde usar:** quando o usuário clicar em uma ordem para comprar.
+
+### Payload esperado
+```json
+{
+  "orderbook_id": 321
+}
+```
+
+### Resposta de sucesso
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_ORDERBOOK_PAYMENT",
+  "data": {
+    "orderbook_id": 321,
+    "payment_id": 555,
+    "payment_code": "000201...",
+    "payment_external_id": "PAY_a1b2c3d4",
+    "token_external_id": "TOKEN_ABC123"
+  }
+}
+```
+
+### Erros principais
+- `400 – E_VALIDATE`: ID inválido.
+- `404 – E_NOT_FOUND`: orderbook inexistente.
+- `409 – E_ORDERBOOK_STATUS`: ordem não está `STATUS_OPEN`.
+- `422 – E_TOKEN_VALUE`: valor calculado ≤ 0.
+- `500 – E_DATABASE` ou `E_PAYMENT`: falha ao iniciar pagamento.
+
+```js
+async function startOrderPayment(orderbookId) {
+  const res = await fetch(`${apiUrl}/orderbook/payment`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify({ orderbook_id: orderbookId })
+  });
+  const body = await parseResponse(res);
+
+  if (!res.ok || body?.status !== 'ok') {
+    throw new Error(body?.msg ?? 'Falha ao iniciar pagamento.');
+  }
+
+  const data = body?.data;
+  if (browser && data?.payment_external_id && data?.token_external_id) {
+    const storageKey = `tooeasy_orderbook_payment_${orderbookId}`;
+    try {
+      // substitui qualquer estado anterior (não damos refresh na página)
+      localStorage.setItem(
+        storageKey,
+        JSON.stringify({
+          orderbook_id: orderbookId,
+          payment_id: data.payment_id,
+          payment_code: data.payment_code,
+          payment_external_id: data.payment_external_id,
+          token_external_id: data.token_external_id,
+          startedAt: Date.now()
+        })
+      );
+    } catch (err) {
+      console.warn('Não foi possível persistir estado do pagamento do orderbook:', err);
+    }
+  }
+
+  return data; // payment_id, payment_code, payment_external_id...
+}
+```
+
+> **Por quê?** O endpoint `/orderbook/transfer` precisa receber `payment_external_id`
+> e `token_external_id`. Salvar esses campos no `localStorage` garante que o QR code
+> continue disponível sem precisar dar refresh. Ao final do fluxo (pagamento confirmado
+> e token transferido), remova essa entrada do `localStorage` para evitar dados
+> obsoletos.
+
+Ao confirmar o pagamento (resposta de sucesso do `/orderbook/transfer`), limpe a chave
+do `localStorage` correspondente para impedir que o modal reabra com dados antigos:
+
+```js
+function clearOrderbookPaymentState(orderbookId) {
+  if (!browser) return;
+  const storageKey = `tooeasy_orderbook_payment_${orderbookId}`;
+  localStorage.removeItem(storageKey);
+}
+```
+
+Com esses dados reutilize o modal Pix já presente em `src/routes/cpr/+page.svelte`:
+- `payment_code` para gerar o QR via `qr-code-styling`.
+- Persistir estado no `localStorage` (ex.: `tooeasy_orderbook_payment_<orderbookId>`)
+  para restaurar o modal se a página recarregar.
+
+## 6. Endpoint: `POST /orderbook/transfer`
+
+Chamado após o usuário confirmar que o pagamento foi compensado (Woovi envia o
+webhook e o usuário clica em "Transferir"). **Importante:** o webhook apenas
+atualiza o banco de dados — cabe ao frontend continuar consultando esse endpoint
+para descobrir quando o status mudou. Enquanto o backend não retornar
+`success`, mantenha um **polling a cada 10 segundos**, por até 30 minutos,
+mostrando o status atual para o usuário.
+
+### Payload esperado
+```json
+{
+  "external_id": "PAY_a1b2c3d4",
+  "token_external_id": "TOKEN_ABC123"
+}
+```
+
+### Resposta de sucesso
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_TOKEN_TRANSFERRED",
+  "data": {
+    "orderbook_id": 321,
+    "token_external_id": "TOKEN_ABC123",
+    "destination_address": "0x8AC9...",
+    "transfer_output": "Transfer success",
+    "transfer_error": ""
+  }
+}
+```
+
+### Erros principais
+- `400 – E_VALIDATE`: body inválido.
+- `403 – E_FORBIDDEN`: ordem de outra empresa.
+- `404 – E_NOT_FOUND` ou `E_WALLET_NOT_FOUND`.
+- `409 – E_PAYMENT_PENDING` / `E_PAYMENT_STATUS`: pagamento ainda pendente ou em status inválido.
+- `500 – E_DATABASE` ou `E_TRANSFER`: falha interna.
+
+```js
+async function transferToken({ external_id, token_external_id }) {
+  const res = await fetch(`${apiUrl}/orderbook/transfer`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify({ external_id, token_external_id })
+  });
+  const body = await parseResponse(res);
+
+  if (!res.ok || body?.status !== 'ok') {
+    throw new Error(body?.msg ?? 'Falha ao transferir token.');
+  }
+
+  return body?.data; // dados da transferência
+}
+
+const ORDERBOOK_TRANSFER_POLL_INTERVAL_MS = 10_000;
+const ORDERBOOK_TRANSFER_TIMEOUT_MS = 30 * 60 * 1000;
+
+async function pollTransferUntilDone(params) {
+  const startedAt = Date.now();
+  while (Date.now() - startedAt < ORDERBOOK_TRANSFER_TIMEOUT_MS) {
+    try {
+      const data = await transferToken(params);
+      return data; // sucesso
+    } catch (err) {
+      if (err.message?.includes('pendente')) {
+        await new Promise((resolve) => setTimeout(resolve, ORDERBOOK_TRANSFER_POLL_INTERVAL_MS));
+        continue;
+      }
+      throw err; // erro fatal
+    }
+  }
+  throw new Error('Tempo limite para confirmação do pagamento expirou.');
+}
+```
+- Tratar códigos especificados no backend:
+  - `E_PAYMENT_PENDING`: mostrar banner "Pagamento ainda pendente" e permitir
+    tentar novamente.
+  - `E_FORBIDDEN`, `E_NOT_FOUND`, `E_TRANSFER`: mensagens orientando contato
+    com suporte.
+
+## 7. Endpoint: `POST /b3/payment/confirm`
+
+Fluxo já implementado na emissão de CPR. Apenas certifique-se de:
+- Enviar `payment_id` (e opcionalmente `b3_access_token`). Payload esperado:
+  ```json
+  {
+    "payment_id": 555,
+    "b3_access_token": "Bearer ..."
+  }
+  ```
+- Resposta de sucesso:
+  ```json
+  {
+    "status": "ok",
+    "msg": "[100] Request ok.",
+    "code": "S_CPR_SENT",
+    "data": {
+      "message": "CPR enviada e token criado com sucesso",
+      "payment_id": 555,
+      "b3_response": { "status": "OK" },
+      "token_id": 99,
+      "token_external_id": "TOKEN_ABC123",
+      "tx_hash": "0x..."
+    }
+  }
+  ```
+- Enviar `{ payment_id }` e, se existir, `b3_access_token`.
+- Continuar usando o modal Pix + polling existente.
+
+## 8. Persistência local e reatividade
+
+- Use `localStorage` dentro de `if (browser)` para guardar dados de pagamentos
+  pendentes e restaurá-los em `onMount`.
+- `let paymentModalVisible`, `let paymentCode`, `let paymentCountdownMs`, etc.,
+  continuam vivos dentro do `<script>` Svelte.
+- Sempre limpe timers em `onDestroy` (`clearInterval`, `clearTimeout`).
+
+## 9. Fluxo completo
+
+1. **Criar ordem**: `/token/orderbook` ao finalizar uma CPR.
+2. **Listar ordens**: `/orderbook/filter` (já integrado na tela trading).
+3. **Iniciar pagamento**: `/orderbook/payment` → abre modal Pix.
+4. **Concluir transferência**: `/orderbook/transfer` após confirmação do usuário.
+5. **Registrar CPR**: `/b3/payment/confirm` continua igual ao fluxo atual.
+
+Toda a lógica reside nos `<script>` dos componentes Svelte, reutilizando as
+stores e helpers existentes. Não é necessário (nem desejado) criar arquivos JS
+separados: mantenha o padrão do projeto, com as funções acima declaradas no
+script da página e conectadas ao template através de bindings e eventos.

+ 168 - 31
src/lib/components/trading/BoletaVenda.svelte

@@ -4,11 +4,20 @@
   import 'flatpickr/dist/flatpickr.min.css';
   import { Portuguese } from 'flatpickr/dist/l10n/pt.js';
   import { createEventDispatcher } from 'svelte';
+
   export let visible = false;
   export let onClose = () => {};
   export let state = '';
+  export let states = [];
   export let commodity = '';
+  export let tokens = [];
+  export let tokensLoading = false;
+  export let tokensError = '';
+  export let submitting = false;
+  export let submitError = '';
+
   const dispatch = createEventDispatcher();
+
   let valorSaca = '';
   let quantidade = '';
   let referencia = 'BRL : 12';
@@ -17,6 +26,44 @@
   $: calendario = validadeInput;
   $: total = (Number(valorSaca) || 0) * (Number(quantidade) || 0);
 
+  let selectedState = state;
+  let stateManuallyChanged = false;
+  $: if (!stateManuallyChanged) {
+    selectedState = state ?? '';
+  }
+  $: if (!visible) {
+    stateManuallyChanged = false;
+    selectedState = state ?? '';
+  }
+
+  let selectedTokenId = '';
+  let tokenSelectTouched = false;
+  $: availableTokens = Array.isArray(tokens) ? tokens : [];
+  $: defaultTokenId = availableTokens.length
+    ? String(availableTokens[0]?.token_id ?? availableTokens[0]?.token_external_id ?? '')
+    : '';
+  $: if (!tokenSelectTouched) {
+    selectedTokenId = defaultTokenId;
+  }
+  $: if (!visible) {
+    tokenSelectTouched = false;
+    selectedTokenId = defaultTokenId;
+  }
+  $: selectedToken = availableTokens.find((token) => {
+    const tokenKey = String(token?.token_id ?? token?.token_external_id ?? '');
+    return tokenKey === selectedTokenId && tokenKey !== '';
+  }) ?? null;
+
+  $: tokenLockedState = selectedToken?.token_uf ?? '';
+  $: displayCommodityName = selectedToken?.cpr_product_name ?? commodity ?? '';
+  $: displayCommodityValue = selectedToken?.token_commodities_value ?? null;
+  $: displayCommodityAmount = selectedToken?.token_commodities_amount ?? null;
+  $: quantidade = displayCommodityAmount != null ? String(displayCommodityAmount) : '';
+  $: referencia = selectedToken ? formatBRL(displayCommodityValue ?? 0) : 'BRL : 12';
+  $: if (tokenLockedState) {
+    selectedState = tokenLockedState;
+  }
+
   let calendarEl;
   function initCalendar() {
     flatpickr(calendarEl, {
@@ -31,6 +78,7 @@
       }
     });
   }
+
   function handleValidadeInput(event) {
     const str = event.target.value;
     validade = str ? new Date(str) : new Date();
@@ -40,23 +88,123 @@
   }
 
   function confirmar() {
-    const dados = { tipo: 'venda', valorSaca: Number(valorSaca)||0, quantidade: Number(quantidade)||0, referencia, total, validade: validadeInput, calendario, estado: state, commodity };
+    const dados = {
+      tipo: 'venda',
+      valorSaca: Number(valorSaca) || 0,
+      quantidade: Number(quantidade) || 0,
+      referencia,
+      total,
+      validade: validadeInput,
+      calendario,
+      estado: selectedState,
+      commodity: displayCommodityName,
+      tokenId: selectedToken?.token_id ?? null,
+      tokenExternalId: selectedToken?.token_external_id ?? null,
+      tokenValue: displayCommodityValue,
+      tokenAmount: displayCommodityAmount,
+      cprId: selectedToken?.cpr_id ?? null,
+      commodityType: displayCommodityName,
+      estadoToken: tokenLockedState
+    };
     console.log('BoletaVenda confirmada', dados);
     dispatch('confirm', dados);
-    try { onClose?.(); } catch {}
+    try {
+      onClose?.();
+    } catch {}
+  }
+
+  function formatBRL(n) {
+    return new Intl.NumberFormat('pt-BR', {
+      style: 'currency',
+      currency: 'BRL'
+    }).format(Number(n || 0));
   }
-  function formatBRL(n) { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n||0)); }
 </script>
 <ModalBase title="Boleta de Venda" {visible} {onClose}>
   <div class="space-y-4">
-    <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
-      <div>
-        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Valor/saca</label>
-        <input type="number" min="0" step="0.01" bind:value={valorSaca} class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" />
+    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+      <div class="space-y-4">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Valor/saca</label>
+          <input type="number" min="0" step="0.01" bind:value={valorSaca} class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" />
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Quantidade em saca</label>
+          <input
+            type="number"
+            min="0"
+            step="1"
+            bind:value={quantidade}
+            readonly
+            class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-3 py-2"
+          />
+          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Quantidade fixa conforme o token selecionado.</p>
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Estado</label>
+          <select
+            class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-60"
+            bind:value={selectedState}
+            disabled={!!tokenLockedState || !states?.length}
+            on:change={() => {
+              stateManuallyChanged = true;
+            }}
+          >
+            {#if !states?.length}
+              <option value="">Carregando estados...</option>
+            {:else}
+              {#each states as uf}
+                <option value={uf}>{uf}</option>
+              {/each}
+            {/if}
+          </select>
+          {#if tokenLockedState}
+            <p class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">Estado definido pelo token (token_uf).</p>
+          {/if}
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Commodity</label>
+          {#if tokensError}
+            <div class="text-xs text-red-500 mb-1">{tokensError}</div>
+          {/if}
+          <select
+            class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-60"
+            bind:value={selectedTokenId}
+            disabled={tokensLoading || !availableTokens.length}
+            on:change={() => {
+              tokenSelectTouched = true;
+            }}
+          >
+            {#if tokensLoading}
+              <option value="">Carregando tokens...</option>
+            {:else if !availableTokens.length}
+              <option value="">Nenhum token disponível</option>
+            {:else}
+              <option value="">Selecione uma commodity/token</option>
+              {#each availableTokens as token}
+                <option value={String(token?.token_id ?? token?.token_external_id ?? '')}>
+                  {token?.cpr_product_name ?? token?.token_content ?? token?.token_external_id ?? 'Token sem nome'}
+                </option>
+              {/each}
+            {/if}
+          </select>
+          {#if selectedToken}
+            <div class="mt-2 text-xs text-gray-600 dark:text-gray-300 space-y-1">
+              <div>Valor pago: <span class="font-semibold">{formatBRL(displayCommodityValue)}</span></div>
+              <div>Quantidade (sacas): <span class="font-semibold">{displayCommodityAmount ?? '--'}</span></div>
+            </div>
+          {/if}
+        </div>
       </div>
-      <div>
-        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Quantidade em saca</label>
-        <input type="number" min="0" step="1" bind:value={quantidade} class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" />
+      <div class="space-y-4">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Validade</label>
+          <input type="date" bind:value={validadeInput} on:input={handleValidadeInput} class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" />
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Calendário</label>
+          <div bind:this={calendarEl} class="mt-1 w-full lg:w-56 text-xs calendar-override rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 p-1.5" use:initCalendar></div>
+        </div>
       </div>
     </div>
     <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -67,28 +215,17 @@
         <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Valor total: {formatBRL(total)}</label>
       </div>
     </div>
-    <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
-      <div>
-        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Validade</label>
-        <input type="date" bind:value={validadeInput} on:input={handleValidadeInput} class="mt-1 block w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" />
-
-        <!-- ESTADO E COMMODITY -->
-        <div class="mt-4">
-          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Estado</label>
-          <input value={state} readonly class="mt-1 block w-full rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 text-gray-900 dark:text-gray-200 px-3 py-2" />
-        </div>
-        <div class="mt-4">
-          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Commodity</label>
-          <input value={commodity} readonly class="mt-1 block w-full rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 text-gray-900 dark:text-gray-200 px-3 py-2" />
-        </div>
-      </div>
-      <div>
-        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Calendário</label>
-        <div bind:this={calendarEl} class="mt-1 w-56 text-sm calendar-override rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 p-2" use:initCalendar></div>
-      </div>
-    </div>
     <div class="flex justify-end">
-      <button class="px-4 py-2 rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500" on:click={confirmar}>Confirmar Venda</button>
+      {#if submitError}
+        <p class="text-sm text-red-500 text-right mb-2">{submitError}</p>
+      {/if}
+      <button
+        class="px-4 py-2 rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-60"
+        on:click={confirmar}
+        disabled={submitting || !selectedToken}
+      >
+        {submitting ? 'Enviando...' : 'Confirmar Venda'}
+      </button>
     </div>
   </div>
 </ModalBase>

+ 22 - 3
src/lib/components/trading/TabelaOrdens.svelte

@@ -8,9 +8,20 @@
   function formatBRL(n) { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n || 0)); }
   function formatQty(n) { return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Number(n || 0)); }
 
-  $: listaCompra = (ordensCompra || []).map((d) => ({ valor: Number(d?.valor ?? 0), quantidade: Number(d?.quantidade ?? 0), city: d?.city ?? '' }));
+  $: listaCompra = (ordensCompra || []).map((d) => ({
+    ...d,
+    valor: Number(d?.valor ?? 0),
+    quantidade: Number(d?.quantidade ?? 0),
+    city: d?.city ?? ''
+  }));
   $: listaCompraView = (listaCompra || []).map((o, __idx) => ({ ...o, __idx })).slice().reverse();
-  $: listaVenda = (ordensVenda || []).map((d) => ({ valor: Number(d?.valor ?? 0), quantidade: Number(d?.quantidade ?? 0), city: d?.city ?? '' }));
+  $: listaVenda = (ordensVenda || []).map((d) => ({
+    ...d,
+    valor: Number(d?.valor ?? 0),
+    quantidade: Number(d?.quantidade ?? 0),
+    city: d?.city ?? '',
+    raw: d?.raw ?? d
+  }));
   $: listaVendaView = (listaVenda || []).map((o, __idx) => ({ ...o, __idx })).slice().reverse();
   // `ultimaVenda` agora vem por prop do pai
 
@@ -119,7 +130,15 @@
             class={`flex items-center justify-between text-sm rounded px-1 py-0.5 transition-colors duration-700 cursor-pointer hover:bg-red-50 dark:hover:bg-red-900/40 ${flashVenda.has(o.__idx) ? 'bg-red-100 dark:bg-red-900/30' : ''}`}
             on:mouseenter={(event) => scheduleTooltip(event, o)}
             on:mouseleave={hideTooltip}
-            on:click={() => dispatch('selectSellOrder', { valor: o.valor, quantidade: o.quantidade })}
+            on:click={() =>
+              dispatch('selectSellOrder', {
+                valor: o.valor,
+                quantidade: o.quantidade,
+                city: o.city,
+                orderbookId: o.orderbookId ?? o.orderbook_id ?? o.raw?.orderbook_id ?? null,
+                tokenExternalId: o.tokenExternalId ?? o.token_external_id ?? o.raw?.token_external_id ?? '',
+                raw: o.raw ?? o
+              })}
           >
             <span class="text-red-600">{formatBRL(o.valor)}</span>
             <span class="text-gray-700 dark:text-gray-200">{formatQty(o.quantidade)}</span>

+ 1 - 1
src/routes/+page.svelte

@@ -557,7 +557,7 @@
       </div>
 
       <p class="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
-        Esqueceu a senha? <a href="#" class="text-blue-600 hover:text-blue-500">Fale com o suporte</a>
+        Esqueceu a senha? <a href="https://wa.me/+19997048082" class="text-blue-600 hover:text-blue-500">Fale com o suporte</a>
       </p>
       <div class="mt-3">
         <a href="/register" class="block w-full text-center rounded border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 font-medium px-4 py-2">

+ 879 - 56
src/routes/trading/+page.svelte

@@ -5,12 +5,18 @@
   import BoletaVenda from '$lib/components/trading/BoletaVenda.svelte';
   import ModalBase from '$lib/components/trading/ModalBase.svelte';
   import { writable, get } from 'svelte/store';
-  import { onMount, onDestroy } from 'svelte';
+  import { onMount, onDestroy, tick } from 'svelte';
   import { browser } from '$app/environment';
   import { authToken } from '$lib/utils/stores';
 
   const breadcrumb = [{ label: 'Início' }, { label: 'Trading', active: true }];
   const apiUrl = import.meta.env.VITE_API_URL;
+  const ORDERBOOK_PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
+  const ORDERBOOK_TRANSFER_POLL_INTERVAL_MS = 10_000;
+  const ORDERBOOK_TRANSFER_TIMEOUT_MS = 30 * 60 * 1000;
+  const ORDERBOOK_PAYMENT_STORAGE_PREFIX = 'tooeasy_orderbook_payment_';
+  const CIDADES_ESTADOS_SRC = 'https://cdn.jsdelivr.net/npm/cidades-estados@1.4.1/cidades-estados.js';
+  const DEFAULT_STATE_OPTIONS = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'];
 
   const orders = writable([]);
 
@@ -22,25 +28,138 @@
   let orderbookLoading = false;
   let orderbookError = '';
   let orderbookEmptyMessage = '';
+  let orderbookSuccessMessage = '';
   let initialized = false;
   let lastFetchKey = '';
 
+  let stateOptions = [...DEFAULT_STATE_OPTIONS];
+  let statesLoading = false;
+  let statesError = '';
+
   let commodities = [];
   let commoditiesLoading = false;
   let commoditiesError = '';
 
+  let walletTokens = [];
+  let walletTokensLoading = false;
+  let walletTokensError = '';
+
+  let orderPaymentModalVisible = false;
+  let orderDetailModalVisible = false;
+  let orderDetailSelected = null;
+  let orderDetailEntries = [];
+  let orderPaymentId = null;
+  let orderPaymentCode = '';
+  let orderPaymentExternalId = '';
+  let orderPaymentTokenExternalId = '';
+  let orderPaymentOrderbookId = null;
+  let orderPaymentSelectedOrder = null;
+  let orderPaymentStatusMessage = '';
+  let orderPaymentError = '';
+  let orderPaymentCopyFeedback = '';
+  let orderPaymentCountdownMs = ORDERBOOK_PAYMENT_TIMEOUT_MS;
+  let orderPaymentLoadingVisible = false;
+  let orderPaymentStartedAt = null;
+  let orderPaymentQrContainer;
+  let orderPaymentQrInstance = null;
+  let orderPaymentCountdownIntervalId = null;
+  let orderPaymentPollIntervalId = null;
+  let orderPaymentCopyTimeoutId = null;
+  let orderPaymentTransferPollStartedAt = null;
+  let isStartingOrderPayment = false;
+  let isCheckingTransfer = false;
+  let orderPaymentBeforeUnloadHandler = null;
+
+  async function parseResponse(res) {
+    const raw = await res.text();
+    return raw ? JSON.parse(raw) : null;
+  }
+
+  function formatOrderDetailKey(key = '') {
+    return key
+      .replace(/_/g, ' ')
+      .replace(/([a-z])([A-Z])/g, '$1 $2')
+      .replace(/\s+/g, ' ')
+      .trim()
+      .replace(/^./, (c) => c.toUpperCase());
+  }
+
+  function buildOrderDetailEntries(order = {}) {
+    const raw = order?.raw && typeof order.raw === 'object' ? order.raw : order;
+    if (!raw || typeof raw !== 'object') return [];
+    return Object.entries(raw)
+      .filter(([, value]) => value !== null && value !== undefined && value !== '')
+      .map(([key, value]) => ({
+        key,
+        label: formatOrderDetailKey(key),
+        value: typeof value === 'number' ? value : Array.isArray(value) ? value.join(', ') : String(value)
+      }));
+  }
+
+  function openOrderDetailModal(order = {}) {
+    if (!order) return;
+    orderDetailSelected = {
+      valor: order?.valor ?? order?.raw?.token_commodities_value ?? null,
+      quantidade: order?.quantidade ?? order?.raw?.orderbook_amount ?? null,
+      city: order?.city ?? order?.raw?.token_city ?? '',
+      orderbookId: order?.orderbookId ?? order?.orderbook_id ?? order?.raw?.orderbook_id ?? null,
+      tokenExternalId: order?.tokenExternalId ?? order?.token_external_id ?? order?.raw?.token_external_id ?? '',
+      raw: order?.raw ?? order
+    };
+    orderDetailEntries = buildOrderDetailEntries(orderDetailSelected);
+    orderDetailModalVisible = true;
+  }
+
+  function closeOrderDetailModal() {
+    orderDetailModalVisible = false;
+    orderDetailEntries = [];
+    orderDetailSelected = null;
+  }
+
+  function handleOrderDetailPurchase() {
+    if (!orderDetailSelected) return;
+    prefillBuy = {
+      valorSaca: orderDetailSelected.valor ?? orderDetailSelected.raw?.token_commodities_value ?? null,
+      quantidade: orderDetailSelected.quantidade ?? orderDetailSelected.raw?.orderbook_amount ?? null
+    };
+    orderDetailModalVisible = false;
+    void startOrderPaymentFlow(orderDetailSelected);
+  }
+
+  function mapOrderResponse(item = {}) {
+    const totalValue = Number(item?.token_commodities_value ?? item?.valor ?? 0);
+    const quantidade = Number(item?.orderbook_amount ?? item?.token_commodities_amount ?? item?.quantidade ?? 0);
+    const valorPorSaca = quantidade ? totalValue / quantidade : totalValue;
+    return {
+      valor: Number.isFinite(valorPorSaca) ? valorPorSaca : 0,
+      quantidade: Number.isFinite(quantidade) ? quantidade : 0,
+      total: Number.isFinite(totalValue) ? totalValue : 0,
+      city: item?.token_city ?? item?.city ?? '',
+      orderbookId: item?.orderbook_id ?? item?.orderbookId ?? null,
+      tokenExternalId: item?.token_external_id ?? item?.tokenExternalId ?? '',
+      raw: item
+    };
+  }
+
+
   onMount(async () => {
     if (browser) {
       try {
         const saved = localStorage.getItem('tradingOrders');
         if (saved) orders.set(JSON.parse(saved));
       } catch {}
+      orderPaymentBeforeUnloadHandler = () => {
+        clearOrderPaymentState();
+      };
+      window.addEventListener('beforeunload', orderPaymentBeforeUnloadHandler);
     }
 
-    await fetchCommoditiesOptions();
+    await loadStateOptions();
+    await Promise.all([fetchCommoditiesOptions(), fetchWalletTokens()]);
     initialized = true;
     lastFetchKey = `${selectedState}|${selectedCommodity}`;
     fetchOrderbook(selectedState, selectedCommodity);
+    await restoreOrderbookPaymentState();
   });
 
   async function fetchCommoditiesOptions() {
@@ -79,6 +198,11 @@
             .filter((item) => item.id)
         : [];
 
+      const hasMilho = mapped.some((item) => item.label?.toUpperCase?.() === 'MILHO');
+      if (!hasMilho) {
+        mapped.push({ id: 'MILHO', label: 'MILHO', raw: { commodities_name: 'MILHO' } });
+      }
+
       commodities = mapped;
       if (!mapped.length) {
         selectedCommodity = '';
@@ -128,59 +252,30 @@
 
     try {
       const token = get(authToken);
-      const res = await fetch(`${apiUrl}/token/get`, {
+      const res = await fetch(`${apiUrl}/orderbook/filter`, {
         method: 'POST',
         headers: {
           'content-type': 'application/json',
           ...(token ? { Authorization: `Bearer ${token}` } : {})
         },
         body: JSON.stringify({
-          token_uf: state,
-          commodities_name: commodityPayload
+          state,
+          commodity_type: commodityPayload
         })
       });
+      const body = await parseResponse(res);
 
-      let payload = null;
-      try {
-        payload = await res.json();
-      } catch (err) {
-        console.error('Falha ao interpretar resposta de /token/get:', err);
+      if (!res.ok || body?.status !== 'ok') {
+        throw new Error(body?.msg ?? 'Falha ao carregar ordens.');
       }
 
-      if (!res.ok) {
-        throw new Error(payload?.msg ?? 'Falha ao carregar ordens.');
-      }
-
-      if (payload?.status !== 'ok') {
-        orderbookEmptyMessage = payload?.msg ?? 'Nenhuma ordem disponível para os filtros selecionados.';
-        ordensCompra = [];
-        ordensVenda = [];
-        ultimaVenda = { valor: 0, quantidade: 0 };
-        return;
-      }
-
-      const mapped = Array.isArray(payload?.data)
-        ? payload.data
-            .map((item) => ({
-              valor: Number(item?.token_commodities_value ?? 0),
-              quantidade: Number(item?.token_commodities_amount ?? 0),
-              city: item?.token_city ?? ''
-            }))
-            .filter((item) => Number.isFinite(item.valor) && Number.isFinite(item.quantidade))
-        : [];
-
-      mapped.sort((a, b) => a.valor - b.valor);
+      const ordersData = body?.data?.orders ?? [];
+      const mapped = ordersData.map(mapOrderResponse);
 
       ordensCompra = [];
       ordensVenda = mapped;
-      if (!mapped.length) {
-        orderbookEmptyMessage = 'Nenhuma ordem encontrada para os filtros selecionados.';
-      } else {
-        orderbookEmptyMessage = '';
-      }
-      ultimaVenda = mapped.length
-        ? { valor: mapped[mapped.length - 1].valor, quantidade: mapped[mapped.length - 1].quantidade }
-        : { valor: 0, quantidade: 0 };
+      orderbookEmptyMessage = mapped.length ? '' : 'Nenhuma ordem encontrada.';
+      ultimaVenda = mapped.length ? mapped[mapped.length - 1] : { valor: 0, quantidade: 0 };
     } catch (err) {
       console.error('Erro ao buscar ordens:', err);
       orderbookError = err?.message ?? 'Falha ao carregar ordens.';
@@ -204,12 +299,509 @@
     });
   }
 
+  async function fetchWalletTokens() {
+    if (!apiUrl) return;
+    walletTokensLoading = true;
+    walletTokensError = '';
+    try {
+      const token = get(authToken);
+      if (!token) {
+        throw new Error('Sessão expirada. Faça login novamente.');
+      }
+      const res = await fetch(`${apiUrl}/wallet/tokens`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        }
+      });
+      const body = await parseResponse(res);
+      if (!res.ok || body?.status !== 'ok') {
+        throw new Error(body?.msg ?? 'Falha ao carregar tokens da carteira.');
+      }
+      walletTokens = Array.isArray(body?.data?.tokens) ? body.data.tokens : [];
+    } catch (err) {
+      console.error('Erro ao buscar tokens da carteira:', err);
+      walletTokens = [];
+      walletTokensError = err?.message ?? 'Falha ao carregar tokens da carteira.';
+    } finally {
+      walletTokensLoading = false;
+    }
+  }
+
   function formatBRL(n) {
     return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n || 0));
   }
   function formatEasyToken(n) {
     return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 6, maximumFractionDigits: 6 }).format(Number(n || 0));
   }
+  function formatQty(n) {
+    return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Number(n || 0));
+  }
+
+  function loadCidadesEstadosScript() {
+    if (!browser) {
+      return Promise.reject(new Error('Ambiente indisponível para carregar estados.'));
+    }
+
+    if (window.dgCidadesEstados) {
+      return Promise.resolve(window.dgCidadesEstados);
+    }
+
+    if (window.__cidadesEstadosPromise) {
+      return window.__cidadesEstadosPromise;
+    }
+
+    window.__cidadesEstadosPromise = new Promise((resolve, reject) => {
+      const existingScript = document.querySelector(`script[src="${CIDADES_ESTADOS_SRC}"]`);
+      const handleLoad = () => {
+        if (window.dgCidadesEstados) {
+          resolve(window.dgCidadesEstados);
+        } else {
+          reject(new Error('Biblioteca cidades-estados não expôs dgCidadesEstados.'));
+        }
+      };
+      if (existingScript) {
+        existingScript.addEventListener('load', handleLoad, { once: true });
+        existingScript.addEventListener('error', () => reject(new Error('Falha ao carregar biblioteca cidades-estados.')), { once: true });
+        return;
+      }
+
+      const script = document.createElement('script');
+      script.src = CIDADES_ESTADOS_SRC;
+      script.async = true;
+      script.onload = handleLoad;
+      script.onerror = () => reject(new Error('Falha ao carregar biblioteca cidades-estados.'));
+      document.head.appendChild(script);
+    });
+
+    return window.__cidadesEstadosPromise;
+  }
+
+  async function loadStateOptions() {
+    if (!browser) return;
+    statesLoading = true;
+    statesError = '';
+    try {
+      const ctor = await loadCidadesEstadosScript();
+      const proto = ctor?.prototype;
+      const estadosData = proto?.estados;
+      if (!Array.isArray(estadosData) || !estadosData.length) {
+        throw new Error('Lista de estados indisponível.');
+      }
+      const lista = estadosData
+        .map((item) => {
+          if (Array.isArray(item)) return item[0];
+          if (typeof item === 'string') return item;
+          if (item && typeof item === 'object') return item?.sigla ?? item?.abbr ?? item?.code;
+          return null;
+        })
+        .filter((sigla) => typeof sigla === 'string' && sigla.trim());
+      if (lista.length) {
+        stateOptions = lista;
+      } else {
+        throw new Error('Não foi possível derivar siglas dos estados.');
+      }
+    } catch (err) {
+      console.error('[Trading] Falha ao carregar estados da biblioteca cidades-estados:', err);
+      statesError = 'Falha ao carregar estados. Usando lista padrão.';
+      stateOptions = [...DEFAULT_STATE_OPTIONS];
+    } finally {
+      statesLoading = false;
+      if (!stateOptions.includes(selectedState)) {
+        selectedState = stateOptions[0] ?? '';
+      }
+    }
+  }
+
+  function formatCountdown(ms) {
+    if (ms == null || Number.isNaN(ms)) return '--:--';
+    const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+    const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
+    const seconds = String(totalSeconds % 60).padStart(2, '0');
+    return `${minutes}:${seconds}`;
+  }
+
+  function getOrderPaymentStorageKey(orderbookId = orderPaymentOrderbookId) {
+    if (!orderbookId) return ORDERBOOK_PAYMENT_STORAGE_PREFIX;
+    return `${ORDERBOOK_PAYMENT_STORAGE_PREFIX}${orderbookId}`;
+  }
+
+  function persistCurrentOrderPaymentState() {
+    if (!browser || !orderPaymentOrderbookId || !orderPaymentId || !orderPaymentCode) return;
+    const sanitizedOrder = orderPaymentSelectedOrder
+      ? {
+          valor: orderPaymentSelectedOrder.valor ?? null,
+          quantidade: orderPaymentSelectedOrder.quantidade ?? null,
+          city: orderPaymentSelectedOrder.city ?? '',
+          orderbookId: orderPaymentSelectedOrder.orderbookId ?? null,
+          tokenExternalId: orderPaymentSelectedOrder.tokenExternalId ?? ''
+        }
+      : null;
+    const payload = {
+      orderbook_id: orderPaymentOrderbookId,
+      payment_id: orderPaymentId,
+      payment_code: orderPaymentCode,
+      payment_external_id: orderPaymentExternalId,
+      token_external_id: orderPaymentTokenExternalId,
+      startedAt: orderPaymentStartedAt ?? Date.now(),
+      selectedOrder: sanitizedOrder
+    };
+    try {
+      clearOrderPaymentState(orderPaymentOrderbookId);
+      localStorage.setItem(getOrderPaymentStorageKey(orderPaymentOrderbookId), JSON.stringify(payload));
+    } catch (err) {
+      console.warn('[Trading] Não foi possível persistir estado do pagamento do orderbook:', err);
+    }
+  }
+
+  function clearOrderPaymentState(orderbookId = orderPaymentOrderbookId) {
+    if (!browser || !orderbookId) return;
+    try {
+      localStorage.removeItem(getOrderPaymentStorageKey(orderbookId));
+    } catch {}
+  }
+
+  function stopOrderPaymentCountdown() {
+    if (orderPaymentCountdownIntervalId) {
+      clearInterval(orderPaymentCountdownIntervalId);
+      orderPaymentCountdownIntervalId = null;
+    }
+  }
+
+  function stopOrderTransferPolling() {
+    if (orderPaymentPollIntervalId) {
+      clearInterval(orderPaymentPollIntervalId);
+      orderPaymentPollIntervalId = null;
+    }
+  }
+
+  function stopOrderPaymentCopyTimeout() {
+    if (orderPaymentCopyTimeoutId) {
+      clearTimeout(orderPaymentCopyTimeoutId);
+      orderPaymentCopyTimeoutId = null;
+    }
+  }
+
+  function startOrderPaymentCountdown(startTimestamp = Date.now()) {
+    if (!browser) return;
+    orderPaymentStartedAt = startTimestamp;
+    updateOrderPaymentCountdown();
+    stopOrderPaymentCountdown();
+    orderPaymentCountdownIntervalId = window.setInterval(updateOrderPaymentCountdown, 1000);
+  }
+
+  function updateOrderPaymentCountdown() {
+    if (!orderPaymentStartedAt) {
+      orderPaymentCountdownMs = ORDERBOOK_PAYMENT_TIMEOUT_MS;
+      return;
+    }
+    const elapsed = Date.now() - orderPaymentStartedAt;
+    const remaining = ORDERBOOK_PAYMENT_TIMEOUT_MS - elapsed;
+    orderPaymentCountdownMs = remaining > 0 ? remaining : 0;
+    if (remaining <= 0) {
+      handleOrderPaymentExpired();
+    }
+  }
+
+  function handleOrderPaymentExpired() {
+    stopOrderTransferPolling();
+    stopOrderPaymentCountdown();
+    clearOrderPaymentState();
+    orderPaymentStatusMessage = '';
+    orderPaymentError = 'O tempo limite para pagamento expirou. Clique na ordem novamente para gerar um novo QR Code.';
+    orderPaymentId = null;
+    orderPaymentCode = '';
+  }
+
+  function resetOrderPaymentTracking({ hideModal = true } = {}) {
+    stopOrderTransferPolling();
+    stopOrderPaymentCountdown();
+    stopOrderPaymentCopyTimeout();
+    clearOrderPaymentState();
+    orderPaymentId = null;
+    orderPaymentCode = '';
+    orderPaymentExternalId = '';
+    orderPaymentTokenExternalId = '';
+    orderPaymentOrderbookId = null;
+    orderPaymentSelectedOrder = null;
+    orderPaymentCountdownMs = ORDERBOOK_PAYMENT_TIMEOUT_MS;
+    orderPaymentStartedAt = null;
+    orderPaymentTransferPollStartedAt = null;
+    orderPaymentStatusMessage = '';
+    orderPaymentError = '';
+    orderPaymentCopyFeedback = '';
+    if (hideModal) {
+      orderPaymentModalVisible = false;
+    }
+  }
+
+  async function renderOrderPaymentQrCode(link) {
+    if (!browser || !link) return;
+    try {
+      if (!orderPaymentQrInstance) {
+        const module = await import('qr-code-styling');
+        const QRCodeStyles = module.default ?? module;
+        orderPaymentQrInstance = new QRCodeStyles({
+          width: 240,
+          height: 240,
+          type: 'svg',
+          data: link,
+          dotsOptions: {
+            color: '#0f172a',
+            type: 'rounded'
+          },
+          backgroundOptions: {
+            color: '#ffffff'
+          }
+        });
+      } else {
+        orderPaymentQrInstance.update({ data: link });
+      }
+      await tick();
+      if (orderPaymentQrContainer) {
+        orderPaymentQrContainer.innerHTML = '';
+        orderPaymentQrInstance.append(orderPaymentQrContainer);
+      }
+    } catch (error) {
+      console.error('[Trading] Falha ao renderizar QR Code do orderbook', error);
+      orderPaymentError = 'Não foi possível gerar o QR Code. Use o código Pix copiado para pagar.';
+    }
+  }
+
+  async function openOrderPaymentModalWithPayment(data, startedAt = Date.now(), immediateTransferPoll = true) {
+    if (!data?.payment_id || !data?.payment_code) return;
+    orderPaymentModalVisible = true;
+    orderPaymentId = Number(data.payment_id);
+    orderPaymentCode = data.payment_code;
+    orderPaymentError = '';
+    orderPaymentStatusMessage = 'Aguardando pagamento via Pix.';
+    orderPaymentCopyFeedback = '';
+    orderPaymentStartedAt = startedAt;
+    const remaining = ORDERBOOK_PAYMENT_TIMEOUT_MS - (Date.now() - startedAt);
+    orderPaymentCountdownMs = remaining > 0 ? remaining : ORDERBOOK_PAYMENT_TIMEOUT_MS;
+    persistCurrentOrderPaymentState();
+    await tick();
+    await renderOrderPaymentQrCode(orderPaymentCode);
+    startOrderPaymentCountdown(startedAt);
+    if (immediateTransferPoll) {
+      startOrderTransferPolling(true);
+    }
+  }
+
+  async function restoreOrderbookPaymentState() {
+    if (!browser) return;
+    try {
+      const storageKeys = [];
+      for (let i = 0; i < localStorage.length; i += 1) {
+        const key = localStorage.key(i);
+        if (key?.startsWith(ORDERBOOK_PAYMENT_STORAGE_PREFIX)) {
+          storageKeys.push(key);
+        }
+      }
+      const targetKey = storageKeys[0];
+      if (!targetKey) return;
+      const raw = localStorage.getItem(targetKey);
+      if (!raw) {
+        localStorage.removeItem(targetKey);
+        return;
+      }
+      const stored = JSON.parse(raw);
+      if (!stored?.payment_id || !stored?.payment_code || !stored?.orderbook_id) {
+        localStorage.removeItem(targetKey);
+        return;
+      }
+      orderPaymentOrderbookId = stored.orderbook_id;
+      orderPaymentExternalId = stored.payment_external_id ?? '';
+      orderPaymentTokenExternalId = stored.token_external_id ?? '';
+      orderPaymentSelectedOrder = stored.selectedOrder ?? null;
+      await openOrderPaymentModalWithPayment(
+        { payment_id: stored.payment_id, payment_code: stored.payment_code },
+        stored.startedAt ?? Date.now(),
+        false
+      );
+      orderPaymentStatusMessage = 'Retomamos a verificação do pagamento pendente.';
+      startOrderTransferPolling(true);
+    } catch (err) {
+      console.error('[Trading] Não foi possível restaurar o pagamento pendente do orderbook:', err);
+      clearOrderPaymentState();
+    }
+  }
+
+  async function handleCopyOrderPaymentCode() {
+    if (!browser || !orderPaymentCode) return;
+    try {
+      await navigator.clipboard.writeText(orderPaymentCode);
+      orderPaymentCopyFeedback = 'Código Pix copiado!';
+    } catch {
+      orderPaymentCopyFeedback = 'Não foi possível copiar automaticamente. Copie manualmente.';
+    } finally {
+      stopOrderPaymentCopyTimeout();
+      if (browser) {
+        orderPaymentCopyTimeoutId = window.setTimeout(() => {
+          orderPaymentCopyFeedback = '';
+          orderPaymentCopyTimeoutId = null;
+        }, 2000);
+      }
+    }
+  }
+
+  async function startOrderPayment(orderbookId) {
+    if (!orderbookId) {
+      throw new Error('Ordem inválida para iniciar pagamento.');
+    }
+    const token = get(authToken);
+    const headers = {
+      'content-type': 'application/json',
+      ...(token ? { Authorization: `Bearer ${token}` } : {})
+    };
+    const res = await fetch(`${apiUrl}/orderbook/payment`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ orderbook_id: orderbookId })
+    });
+    const body = await parseResponse(res);
+
+    if (!res.ok || body?.status !== 'ok') {
+      throw new Error(body?.msg ?? 'Falha ao iniciar pagamento.');
+    }
+
+    const data = body?.data;
+    if (!data?.payment_external_id || !data?.token_external_id) {
+      throw new Error('Resposta inválida: identificadores externos ausentes.');
+    }
+
+    console.info('[Trading] Resposta /orderbook/payment', { orderbookId, response: data });
+
+    return data;
+  }
+
+  async function startOrderPaymentFlow(order = {}) {
+    if (isStartingOrderPayment) return;
+    const orderbookId = order?.orderbookId ?? order?.orderbook_id ?? order?.raw?.orderbook_id;
+    if (!orderbookId) {
+      orderbookError = 'Ordem inválida. Tente novamente.';
+      return;
+    }
+    isStartingOrderPayment = true;
+    orderPaymentLoadingVisible = true;
+    orderPaymentError = '';
+    orderPaymentStatusMessage = 'Preparando QR Code...';
+    orderPaymentSelectedOrder = {
+      valor: order?.valor ?? order?.raw?.token_commodities_value ?? null,
+      quantidade: order?.quantidade ?? order?.raw?.orderbook_amount ?? null,
+      city: order?.city ?? order?.raw?.token_city ?? '',
+      orderbookId,
+      tokenExternalId: order?.tokenExternalId ?? order?.token_external_id ?? order?.raw?.token_external_id ?? ''
+    };
+    try {
+      const data = await startOrderPayment(orderbookId);
+      orderPaymentOrderbookId = orderbookId;
+      orderPaymentId = Number(data.payment_id);
+      orderPaymentCode = data.payment_code;
+      orderPaymentExternalId = data.payment_external_id;
+      orderPaymentTokenExternalId = data.token_external_id;
+      await openOrderPaymentModalWithPayment({ payment_id: data.payment_id, payment_code: data.payment_code });
+    } catch (err) {
+      console.error('[Trading] Falha ao iniciar pagamento do orderbook:', err);
+      orderPaymentError = err?.message ?? 'Não foi possível iniciar o pagamento desta ordem.';
+    } finally {
+      isStartingOrderPayment = false;
+      orderPaymentLoadingVisible = false;
+    }
+  }
+
+  async function requestOrderTransfer() {
+    if (!orderPaymentExternalId || !orderPaymentTokenExternalId) {
+      throw new Error('Pagamento inválido. Gere um novo QR Code.');
+    }
+    const token = get(authToken);
+    const headers = {
+      'content-type': 'application/json',
+      ...(token ? { Authorization: `Bearer ${token}` } : {})
+    };
+    const res = await fetch(`${apiUrl}/orderbook/transfer`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        external_id: orderPaymentExternalId,
+        token_external_id: orderPaymentTokenExternalId
+      })
+    });
+    const body = await parseResponse(res);
+
+    if (res.ok && body?.status === 'ok') {
+      return { state: 'success', data: body?.data };
+    }
+
+    const code = body?.code ?? body?.error;
+    const message = body?.msg ?? body?.message ?? 'Falha ao transferir token.';
+    const normalizedMessage = message?.toLowerCase?.() ?? '';
+    if (res.status === 409 || code === 'E_PAYMENT_PENDING' || normalizedMessage.includes('pendente')) {
+      return { state: 'pending', message: 'Pagamento ainda pendente. Continuaremos monitorando.' };
+    }
+    if (code === 'E_FORBIDDEN' || code === 'E_NOT_FOUND' || code === 'E_TRANSFER' || code === 'E_PAYMENT_STATUS') {
+      return { state: 'error', message };
+    }
+    return { state: 'error', message };
+  }
+
+  function startOrderTransferPolling(immediate = true) {
+    if (!browser || !orderPaymentExternalId || !orderPaymentTokenExternalId) return;
+    stopOrderTransferPolling();
+    orderPaymentTransferPollStartedAt = Date.now();
+    if (immediate) {
+      void pollOrderTransferStatus();
+    }
+    orderPaymentPollIntervalId = window.setInterval(() => {
+      void pollOrderTransferStatus();
+    }, ORDERBOOK_TRANSFER_POLL_INTERVAL_MS);
+  }
+
+  async function pollOrderTransferStatus() {
+    if (isCheckingTransfer) return;
+    if (orderPaymentTransferPollStartedAt && Date.now() - orderPaymentTransferPollStartedAt > ORDERBOOK_TRANSFER_TIMEOUT_MS) {
+      orderPaymentError = 'Tempo limite para confirmação do pagamento expirou. Clique em outra ordem para gerar um novo QR Code.';
+      stopOrderTransferPolling();
+      return;
+    }
+    isCheckingTransfer = true;
+    orderPaymentError = '';
+    try {
+      const result = await requestOrderTransfer();
+      if (result.state === 'success') {
+        handleOrderTransferSuccess(result.data);
+      } else if (result.state === 'pending') {
+        orderPaymentStatusMessage = result.message ?? 'Pagamento pendente.';
+      } else {
+        orderPaymentStatusMessage = '';
+        orderPaymentError = result.message ?? 'Falha ao transferir token.';
+        stopOrderTransferPolling();
+      }
+    } catch (err) {
+      console.error('[Trading] Erro ao confirmar transferência do orderbook', err);
+      orderPaymentError = err?.message ?? 'Não foi possível verificar o pagamento agora.';
+    } finally {
+      isCheckingTransfer = false;
+    }
+  }
+
+  function handleOrderTransferSuccess(payload) {
+    orderPaymentStatusMessage = 'Pagamento confirmado! Tokens transferidos com sucesso.';
+    orderbookSuccessMessage = payload?.transfer_output ?? payload?.message ?? 'Tokens transferidos para o comprador.';
+    resetOrderPaymentTracking({ hideModal: true });
+    void fetchOrderbook(selectedState, selectedCommodity);
+  }
+
+  function handleCancelOrderPaymentFlow() {
+    resetOrderPaymentTracking({ hideModal: true });
+  }
+
+  function handleRetryOrderTransferCheck() {
+    orderPaymentError = '';
+    orderPaymentStatusMessage = 'Verificando status do pagamento...';
+    startOrderTransferPolling(true);
+  }
 
   let buyTab = 'market';
   let sellTab = 'market';
@@ -250,6 +842,62 @@
     triggerInsufficient('sell');
   }
 
+  async function handleSellOrderSubmit(detail = {}) {
+    if (sellSubmitting) return;
+    sellSubmitError = '';
+
+    const tokenExternalId = detail?.tokenExternalId ?? '';
+    const cprId = detail?.cprId;
+    const unitValue = Number(detail?.valorSaca ?? detail?.tokenValue ?? 0);
+    const tokenAmount = Number(detail?.quantidade ?? detail?.tokenAmount ?? 0) || 0;
+    const value = unitValue && tokenAmount ? unitValue * tokenAmount : unitValue;
+    const state = detail?.estadoToken ?? detail?.estado ?? selectedState;
+    const commodityType = detail?.commodityType ?? selectedCommodityLabel ?? 'COMMODITY';
+
+    if (!tokenExternalId || !cprId || !value || !state || !commodityType) {
+      sellSubmitError = 'Selecione um token válido antes de confirmar.';
+      return;
+    }
+
+    const token = get(authToken);
+    if (!token) {
+      sellSubmitError = 'Sessão expirada. Faça login novamente.';
+      return;
+    }
+
+    sellSubmitting = true;
+    try {
+      const body = {
+        cpr_id: Number(cprId),
+        value,
+        state,
+        commodity_type: commodityType,
+        token_external_id: tokenExternalId
+      };
+
+      const res = await fetch(`${apiUrl}/token/orderbook`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify(body)
+      });
+      const parsed = await parseResponse(res);
+      if (!res.ok || parsed?.status !== 'ok') {
+        throw new Error(parsed?.msg ?? 'Falha ao registrar a ordem.');
+      }
+      orderbookSuccessMessage = parsed?.data?.message ?? 'Token colocado à venda com sucesso.';
+      showSellModal = false;
+      await Promise.all([fetchOrderbook(selectedState, selectedCommodity), fetchWalletTokens()]);
+    } catch (err) {
+      console.error('[Trading] Falha ao colocar token à venda:', err);
+      sellSubmitError = err?.message ?? 'Não foi possível registrar a ordem.';
+    } finally {
+      sellSubmitting = false;
+    }
+  }
+
   const orderColumns = [
     { key: 'side', label: 'Tipo' },
     { key: 'price', label: 'EasyCoin' },
@@ -277,8 +925,7 @@ $: displayPendingSells = pendingSells.map((o) => ({
   status: o.status
 }));
 
-  const stateOptions = ['SP','PR','RS','MG','MT','GO','MS','BA','SC','RO','PA'];
-  let selectedState = 'SP';
+  let selectedState = DEFAULT_STATE_OPTIONS[0];
   let selectedCommodity = '';
 
   let showBuyModal = false;
@@ -289,6 +936,9 @@ $: displayPendingSells = pendingSells.map((o) => ({
   let insufficientType = 'buy';
   let insufficientTimer = null;
 
+  let sellSubmitting = false;
+  let sellSubmitError = '';
+
   function triggerInsufficient(type) {
     insufficientType = type;
     insufficientVisible = true;
@@ -309,6 +959,13 @@ $: displayPendingSells = pendingSells.map((o) => ({
 
   onDestroy(() => {
     if (insufficientTimer) clearTimeout(insufficientTimer);
+    stopOrderTransferPolling();
+    stopOrderPaymentCountdown();
+    stopOrderPaymentCopyTimeout();
+    if (browser && orderPaymentBeforeUnloadHandler) {
+      window.removeEventListener('beforeunload', orderPaymentBeforeUnloadHandler);
+      orderPaymentBeforeUnloadHandler = null;
+    }
   });
 
   $: selectedCommodityObj = commodities.find((t) => t.id === selectedCommodity);
@@ -321,17 +978,30 @@ $: displayPendingSells = pendingSells.map((o) => ({
   <Header title="Trading" subtitle="Trading do sistema" breadcrumb={breadcrumb}>
 <svelte:fragment slot="extra">
   <div class="flex w-full items-end justify-between gap-4 md:gap-10">
-    <div class="flex gap-8">
-      <div class="flex flex-col items-center gap-2">
-        <span class="text-xs text-gray-600 dark:text-gray-300">Estado</span>
-        <select bind:value={selectedState} class="block w-32 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500">
-          {#each stateOptions as uf}
-            <option value={uf}>{uf}</option>
-          {/each}
+      <div class="flex gap-8">
+        <div class="flex flex-col items-center gap-2">
+          <span class="text-xs text-gray-600 dark:text-gray-300">Estado</span>
+        {#if statesError}
+          <div class="text-xs text-red-500 mb-1">{statesError}</div>
+        {/if}
+        <select
+          bind:value={selectedState}
+          class="block w-32 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
+          disabled={statesLoading || !stateOptions.length}
+        >
+          {#if statesLoading}
+            <option>Carregando...</option>
+          {:else if !stateOptions.length}
+            <option value="">Sem opções</option>
+          {:else}
+            {#each stateOptions as uf}
+              <option value={uf}>{uf}</option>
+            {/each}
+          {/if}
         </select>
-      </div>
-      <div class="flex flex-col items-center gap-2">
-        <span class="text-xs text-gray-600 dark:text-gray-300">Commodity</span>
+        </div>
+        <div class="flex flex-col items-center gap-2">
+          <span class="text-xs text-gray-600 dark:text-gray-300">Commodity</span>
         {#if commoditiesError}
           <div class="text-xs text-red-500 mb-1">{commoditiesError}</div>
         {/if}
@@ -366,8 +1036,18 @@ $: displayPendingSells = pendingSells.map((o) => ({
           <div class="rounded border border-gray-200 bg-gray-50 text-sm text-gray-700 px-3 py-2 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-200">Carregando orderbook...</div>
         {/if}
 
+        {#if orderbookSuccessMessage}
+          <div class="rounded border border-green-300 bg-green-50 text-green-800 text-sm px-3 py-2 flex items-start justify-between gap-3 dark:border-green-700 dark:bg-green-900/20 dark:text-green-200">
+            <span>{orderbookSuccessMessage}</span>
+            <button type="button" class="text-xs font-semibold uppercase tracking-wide" on:click={() => (orderbookSuccessMessage = '')}>Fechar</button>
+          </div>
+        {/if}
+
         <TabelaOrdens {ordensCompra} {ordensVenda} {ultimaVenda} emptyMessage={orderbookEmptyMessage}
-          on:selectSellOrder={(e) => { const { valor, quantidade } = e.detail; prefillBuy = { valorSaca: valor, quantidade }; showBuyModal = true; }}
+          on:selectSellOrder={(e) => {
+            const orderData = e.detail || {};
+            openOrderDetailModal(orderData);
+          }}
         />
       </div>
       <div class="md:col-span-2">
@@ -390,13 +1070,23 @@ $: displayPendingSells = pendingSells.map((o) => ({
       confirmBuy();
     }}
   />
-  <BoletaVenda visible={showSellModal} onClose={() => (showSellModal = false)} state={selectedState} commodity={selectedCommodityLabel}
+  <BoletaVenda
+    visible={showSellModal}
+    onClose={() => (showSellModal = false)}
+    state={selectedState}
+    states={stateOptions}
+    commodity={selectedCommodityLabel}
+    tokens={walletTokens}
+    tokensLoading={walletTokensLoading}
+    tokensError={walletTokensError}
+    submitting={sellSubmitting}
+    submitError={sellSubmitError}
     on:confirm={(e) => {
       const { valorSaca, quantidade } = e.detail || {};
       sellTab = 'limit';
       sellLimitPrice = valorSaca ? String(valorSaca) : '';
       sellAmountEasyToken = quantidade ? String(quantidade) : '';
-      confirmSell();
+      void handleSellOrderSubmit(e.detail);
     }}
   />
   <ModalBase title="Saldo insuficiente" visible={insufficientVisible} onClose={closeInsufficient}>
@@ -404,4 +1094,137 @@ $: displayPendingSells = pendingSells.map((o) => ({
       Saldo insuficiente para {insufficientType === 'buy' ? 'iniciar uma compra' : 'iniciar uma venda'}.
     </p>
   </ModalBase>
+  <ModalBase
+    title="Detalhes da ordem"
+    visible={orderDetailModalVisible}
+    onClose={closeOrderDetailModal}
+  >
+    {#if orderDetailSelected}
+      <div class="space-y-4 text-sm text-gray-800 dark:text-gray-100">
+        <div class="rounded border border-gray-200 dark:border-gray-700 p-3 space-y-2 bg-gray-50 dark:bg-gray-800/60">
+          <div class="flex justify-between text-base font-semibold">
+            <span>Valor por saca</span>
+            <span>{formatBRL(orderDetailSelected.valor)}</span>
+          </div>
+          <div class="flex justify-between">
+            <span>Quantidade</span>
+            <span>{formatQty(orderDetailSelected.quantidade)}</span>
+          </div>
+          <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400">
+            <span>Cidade</span>
+            <span>{orderDetailSelected.city || 'Não informado'}</span>
+          </div>
+        </div>
+
+        <div class="max-h-64 overflow-y-auto pr-1 space-y-1">
+          {#if orderDetailEntries.length === 0}
+            <p class="text-xs text-gray-500">Sem dados adicionais para esta ordem.</p>
+          {:else}
+            <dl class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
+              {#each orderDetailEntries as entry}
+                <div class="flex items-start justify-between px-3 py-2">
+                  <dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 w-1/2 pr-2">{entry.label}</dt>
+                  <dd class="text-sm text-gray-900 dark:text-gray-100 w-1/2 text-right break-words">{entry.value}</dd>
+                </div>
+              {/each}
+            </dl>
+          {/if}
+        </div>
+
+        <div class="flex flex-col gap-2">
+          <button
+            class="w-full rounded bg-green-600 hover:bg-green-700 text-white font-semibold py-2 disabled:opacity-60"
+            on:click={handleOrderDetailPurchase}
+            disabled={isStartingOrderPayment}
+          >
+            Comprar esta ordem
+          </button>
+          <button
+            type="button"
+            class="w-full rounded border border-gray-300 dark:border-gray-600 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
+            on:click={closeOrderDetailModal}
+          >
+            Cancelar
+          </button>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-gray-500">Selecione uma ordem para ver detalhes.</p>
+    {/if}
+  </ModalBase>
+  <ModalBase
+    title="Pagamento via Pix"
+    visible={orderPaymentModalVisible}
+    onClose={handleCancelOrderPaymentFlow}
+  >
+    {#if orderPaymentId && orderPaymentCode}
+      <div class="space-y-4 text-sm text-gray-800 dark:text-gray-100">
+        <div class="rounded border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-2">
+          <div class="flex items-center justify-between">
+            <span class="font-semibold text-base">QR Code do Pix</span>
+            <span class="text-xs text-gray-500 dark:text-gray-400">Expira em {formatCountdown(orderPaymentCountdownMs)}</span>
+          </div>
+          <div class="flex flex-col items-center justify-center">
+            <div class="w-60 h-60" bind:this={orderPaymentQrContainer}></div>
+          </div>
+        </div>
+
+        <div class="space-y-2">
+          <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Status</div>
+          {#if orderPaymentStatusMessage}
+            <div class="rounded border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-100 px-3 py-2">
+              {orderPaymentStatusMessage}
+            </div>
+          {/if}
+          {#if orderPaymentError}
+            <div class="rounded border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200 px-3 py-2">
+              {orderPaymentError}
+            </div>
+          {/if}
+        </div>
+
+        <div class="space-y-2">
+          <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Código Pix copia e cola</div>
+          <div class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-3 text-xs break-all">
+            {orderPaymentCode}
+          </div>
+          <button
+            type="button"
+            class="w-full rounded bg-gray-900 dark:bg-white text-white dark:text-gray-900 py-2 font-semibold"
+            on:click={handleCopyOrderPaymentCode}
+          >
+            {orderPaymentCopyFeedback || 'Copiar código Pix'}
+          </button>
+        </div>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
+          <button
+            type="button"
+            class="rounded bg-green-600 hover:bg-green-700 text-white font-semibold py-2"
+            on:click={handleRetryOrderTransferCheck}
+            disabled={isCheckingTransfer}
+          >
+            {isCheckingTransfer ? 'Verificando...' : 'Verificar status'}
+          </button>
+          <button
+            type="button"
+            class="rounded border border-gray-300 dark:border-gray-600 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
+            on:click={handleCancelOrderPaymentFlow}
+          >
+            Cancelar pagamento
+          </button>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-gray-500">Nenhum pagamento em andamento.</p>
+    {/if}
+  </ModalBase>
+  {#if orderPaymentLoadingVisible}
+    <div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/60 backdrop-blur-sm">
+      <div class="flex flex-col items-center gap-4 text-white">
+        <div class="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
+        <p class="text-base font-semibold">Gerando QR Code do Pix...</p>
+      </div>
+    </div>
+  {/if}
 </div>