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

+ 69 - 1
src/lib/components/commodities/cpr/EmissionCpr.svelte

@@ -18,6 +18,8 @@
 
   const STATE_FIELD = 'cpr_deliveryPlace_state_acronym';
   const CITY_FIELD = 'cpr_deliveryPlace_city_name';
+  const ISSUER_STATE_FIELD = 'cpr_issuers_state_acronym';
+  const ISSUER_CITY_FIELD = 'cpr_issuers_city_name';
   const CIDADES_ESTADOS_SRC = 'https://cdn.jsdelivr.net/npm/cidades-estados@1.4.1/cidades-estados.js';
   const isBrowser = typeof window !== 'undefined';
 
@@ -184,6 +186,30 @@
   function handleDeliveryCityChange(value) {
     onFieldChange(CITY_FIELD, value);
   }
+
+  function isIssuerStateField(key) {
+    return key === ISSUER_STATE_FIELD;
+  }
+
+  function isIssuerCityField(key) {
+    return key === ISSUER_CITY_FIELD;
+  }
+
+  function getCitiesForState(value) {
+    if (!value) return [];
+    return cidadesPorEstado[value] ?? [];
+  }
+
+  function handleRepeatingStateChange(groupKey, index, stateField, cityField, value) {
+    onRepeatingFieldChange(groupKey, index, stateField, value);
+    if (cityField) {
+      onRepeatingFieldChange(groupKey, index, cityField, '');
+    }
+  }
+
+  function handleRepeatingCityChange(groupKey, index, cityField, value) {
+    onRepeatingFieldChange(groupKey, index, cityField, value);
+  }
 </script>
 
 <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
@@ -333,7 +359,49 @@
                             <span class="text-red-500">*</span>
                           {/if}
                         </label>
-                        {#if field.type === 'select'}
+                        {#if isIssuerStateField(field.key)}
+                          <select
+                            class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
+                            value={entry[field.key] ?? ''}
+                            on:change={(event) => handleRepeatingStateChange(config.key, index, field.key, ISSUER_CITY_FIELD, event.currentTarget.value)}
+                            required={requiredFields?.has(field.key)}
+                            disabled={estadosCarregando || !!estadosErro}
+                          >
+                            <option value="">{estadosCarregando ? 'Carregando estados...' : 'Selecione um estado'}</option>
+                            {#each estadosOptions as estado}
+                              <option value={estado.value}>{estado.label}</option>
+                            {/each}
+                          </select>
+                          {#if estadosErro}
+                            <p class="text-xs text-red-500 mt-1">{estadosErro}</p>
+                          {/if}
+                        {:else if isIssuerCityField(field.key)}
+                          {#if estadosErro}
+                            <p class="text-xs text-red-500 mt-1">{estadosErro}</p>
+                          {/if}
+                          <select
+                            class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
+                            value={entry[field.key] ?? ''}
+                            on:change={(event) => handleRepeatingCityChange(config.key, index, field.key, event.currentTarget.value)}
+                            required={requiredFields?.has(field.key)}
+                            disabled={!entry[ISSUER_STATE_FIELD] || !getCitiesForState(entry[ISSUER_STATE_FIELD]).length || !!estadosErro}
+                          >
+                            <option value="">
+                              {#if !entry[ISSUER_STATE_FIELD]}
+                                Selecione um estado primeiro
+                              {:else if estadosCarregando}
+                                Carregando cidades...
+                              {:else if !getCitiesForState(entry[ISSUER_STATE_FIELD]).length}
+                                Nenhuma cidade disponível
+                              {:else}
+                                Selecione uma cidade
+                              {/if}
+                            </option>
+                            {#each getCitiesForState(entry[ISSUER_STATE_FIELD]) as cidade}
+                              <option value={cidade}>{cidade}</option>
+                            {/each}
+                          </select>
+                        {:else if field.type === 'select'}
                           <select
                             class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
                             value={entry[field.key] ?? ''}

+ 7 - 0
src/lib/components/wallet/PaymentMenu.svelte

@@ -61,6 +61,13 @@
       </div>
 
       <div class="p-6 space-y-3 overflow-auto">
+        <div class="rounded-lg border border-amber-200 bg-amber-50 text-amber-900 px-4 py-3 text-sm">
+          <p class="font-semibold">Função não implementada ainda</p>
+          <p>
+            Esta gaveta serve apenas como preview: é possível escolher um método e fechar o fluxo,
+            porém o pagamento real ainda não foi conectado.
+          </p>
+        </div>
         {#each methods as m}
           <button
             type="button"

+ 7 - 0
src/lib/components/wallet/SellMenu.svelte

@@ -55,6 +55,13 @@
       </div>
 
       <div class="p-6 space-y-4 overflow-auto">
+        <div class="rounded-lg border border-amber-200 bg-amber-50 text-amber-900 px-4 py-3 text-sm">
+          <p class="font-semibold">Função não implementada ainda</p>
+          <p>
+            Este formulário permite simular a venda e preencher os campos (PIX e quantidade),
+            mas o envio ainda não está conectado ao backend/pagamentos reais.
+          </p>
+        </div>
         <div class="space-y-1">
           <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Chave Pix</label>
           <input

+ 12 - 0
src/routes/cpr/+page.svelte

@@ -283,6 +283,7 @@
   let paymentCopyFeedback = '';
   let paymentCountdownMs = PAYMENT_TIMEOUT_MS;
   let paymentStartedAt = null;
+  let paymentLoadingVisible = false;
   let paymentQrContainer;
   let paymentQrInstance = null;
   let paymentPollIntervalId = null;
@@ -903,6 +904,7 @@
 
     const payload = buildPayload();
     isSubmitting = true;
+    paymentLoadingVisible = true;
 
     try {
       const res = await fetch(`${apiUrl}/b3/cpr/register`, {
@@ -948,6 +950,7 @@
       submitError = err?.message ?? 'Falha ao registrar CPR.';
     } finally {
       isSubmitting = false;
+      paymentLoadingVisible = false;
     }
   }
 </script>
@@ -1240,4 +1243,13 @@
       </div>
     </div>
   {/if}
+
+  {#if paymentLoadingVisible}
+    <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>

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

@@ -1020,7 +1020,7 @@ $: displayPendingSells = pendingSells.map((o) => ({
     </div>
 
     <div class="flex items-center gap-2 md:gap-3  ">
-      <button class="px-3 py-1.5 rounded bg-green-600 hover:bg-green-700 text-white focus:outline-none focus:ring-2 focus:ring-green-500" on:click={() => (showBuyModal = true)}>COMPRAR</button>
+      <!--<button class="px-3 py-1.5 rounded bg-green-600 hover:bg-green-700 text-white focus:outline-none focus:ring-2 focus:ring-green-500" on:click={() => (showBuyModal = true)}>COMPRAR</button>-->
       <button class="px-3 py-1.5 rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500" on:click={() => (showSellModal = true)}>VENDER</button>
     </div>
   </div>

+ 128 - 20
src/routes/wallet/+page.svelte

@@ -1,63 +1,171 @@
 <script>
+  import { onMount } from 'svelte';
+  import { get } from 'svelte/store';
   import Header from '$lib/layout/Header.svelte';
   import StatsCard from '$lib/components/StatsCard.svelte';
   import PaymentMenu from '$lib/components/wallet/PaymentMenu.svelte';
   import SellMenu from '$lib/components/wallet/SellMenu.svelte';
   import tokensIcon from '$lib/assets/icons/sidebar/tokens.svg?raw';
   import walletIcon from '$lib/assets/icons/sidebar/wallet.svg?raw';
+  import { authToken } from '$lib/utils/stores';
 
   const breadcrumb = [{ label: 'Início' }, { label: 'wallet', active: true }];
+  const apiUrl = import.meta.env.VITE_API_URL;
 
-  let easyTokens = [
-    { id: 1, label: 'EasyToken Soja', amount: 1 },
-    { id: 2, label: 'EasyToken Milho', amount: 1 },
-    { id: 3, name: 'EasyToken Café', balance: 1 }
-  ];
+  let walletTokens = [];
   let easyCoinBalance = 0;
+  let tokensLoading = false;
+  let tokensError = '';
 
   let isPaymentOpen = false;
   let isSellOpen = false;
 
   let rateBRLPerEasyCoin = 100;
 
-  function openPayment() { isPaymentOpen = true; }
-  function closePayment() { isPaymentOpen = false; }
-  function confirmPayment(method) { isPaymentOpen = false; }
+  onMount(() => {
+    void fetchWalletTokens();
+  });
 
-  function openSell() { isSellOpen = true; }
-  function closeSell() { isSellOpen = false; }
+  function openPayment() {
+    isPaymentOpen = true;
+  }
+  function closePayment() {
+    isPaymentOpen = false;
+  }
+  function confirmPayment(method) {
+    isPaymentOpen = false;
+  }
+
+  function openSell() {
+    isSellOpen = true;
+  }
+  function closeSell() {
+    isSellOpen = false;
+  }
   function confirmSell(e) {
     isSellOpen = false;
   }
 
+  async function parseResponse(res) {
+    const raw = await res.text();
+    return raw ? JSON.parse(raw) : null;
+  }
+
+  async function fetchWalletTokens() {
+    if (!apiUrl) return;
+    tokensLoading = true;
+    tokensError = '';
+    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 wallet.');
+      }
+      const payload = body?.data ?? {};
+      walletTokens = Array.isArray(payload?.tokens) ? payload.tokens : [];
+      easyCoinBalance = resolveEasyCoinBalance(payload);
+    } catch (err) {
+      console.error('[Wallet] Erro ao buscar tokens:', err);
+      walletTokens = [];
+      easyCoinBalance = 0;
+      tokensError = err?.message ?? 'Não foi possível carregar os tokens.';
+    } finally {
+      tokensLoading = false;
+    }
+  }
+
+  function resolveEasyCoinBalance(payload) {
+    const candidates = [
+      payload?.wallet?.easycoin_balance,
+      payload?.wallet?.easyCoinBalance,
+      payload?.wallet?.balance,
+      payload?.easycoin_balance,
+      payload?.easyCoinBalance,
+      payload?.easycoin,
+      payload?.balance
+    ];
+    const firstValue = candidates.find((value) => value !== undefined && value !== null && value !== '');
+    return Number(firstValue ?? 0);
+  }
+
   function formatToken(n) {
     return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 6 }).format(Number(n || 0));
   }
   function formatCoin(n) {
     return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(n || 0));
   }
+
+  function tokenCardLabel(token) {
+    return token?.cpr_product_name ?? token?.token_content ?? token?.token_external_id ?? token?.token_id ?? 'EasyToken';
+  }
+
+  function tokenCardSubtitle(token) {
+    if (token?.token_city && token?.token_uf) {
+      return `${token.token_city}/${token.token_uf}`;
+    }
+    if (token?.token_external_id) {
+      return `ID ${token.token_external_id}`;
+    }
+    return 'Saldo atual';
+  }
+
+  function tokenAmount(token) {
+    const candidates = [
+      token?.token_commodities_amount,
+      token?.token_amount,
+      token?.token_balance,
+      token?.balance,
+      token?.amount
+    ];
+    const value = candidates.find((item) => item !== undefined && item !== null && item !== '');
+    return Number(value ?? 0);
+  }
 </script>
 
 <div>
   <Header title="Wallet" subtitle="Saldos e operações" breadcrumb={breadcrumb} />
 
   <div class="p-4 space-y-6">
+    {#if tokensError}
+      <div class="rounded border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{tokensError}</div>
+    {/if}
+
     <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
-      {#each easyTokens as t}
-        <div class="rounded-lg overflow-hidden">
-          <StatsCard
-            title={t.label || t.name || 'EasyToken'}
-            value={formatToken(t.amount || t.balance || 0)}
-            change="Saldo atual"
-            iconSvg={tokensIcon}
-          />
+      {#if tokensLoading && !walletTokens.length}
+        <div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-gray-800/60 p-4 text-center text-sm text-gray-500">
+          Carregando tokens...
+        </div>
+      {:else if walletTokens.length}
+        {#each walletTokens as token (token?.token_external_id ?? token?.token_id ?? token?.cpr_id ?? token)}
+          <div class="rounded-lg overflow-hidden">
+            <StatsCard
+              title={tokenCardLabel(token)}
+              value={formatToken(tokenAmount(token))}
+              change={tokenCardSubtitle(token)}
+              iconSvg={tokensIcon}
+            />
+          </div>
+        {/each}
+      {:else}
+        <div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-gray-800/60 p-4 text-center text-sm text-gray-500">
+          Nenhum token encontrado para sua conta.
         </div>
-      {/each}
+      {/if}
       <div class="rounded-lg overflow-hidden">
         <StatsCard
           title="EasyCoin"
           value={formatCoin(easyCoinBalance)}
-          change="Saldo atual"
+          change={tokensLoading ? 'Atualizando...' : 'Saldo atual'}
           iconSvg={walletIcon}
         />
       </div>