|
@@ -5,12 +5,18 @@
|
|
|
import BoletaVenda from '$lib/components/trading/BoletaVenda.svelte';
|
|
import BoletaVenda from '$lib/components/trading/BoletaVenda.svelte';
|
|
|
import ModalBase from '$lib/components/trading/ModalBase.svelte';
|
|
import ModalBase from '$lib/components/trading/ModalBase.svelte';
|
|
|
import { writable, get } from 'svelte/store';
|
|
import { writable, get } from 'svelte/store';
|
|
|
- import { onMount, onDestroy } from 'svelte';
|
|
|
|
|
|
|
+ import { onMount, onDestroy, tick } from 'svelte';
|
|
|
import { browser } from '$app/environment';
|
|
import { browser } from '$app/environment';
|
|
|
import { authToken } from '$lib/utils/stores';
|
|
import { authToken } from '$lib/utils/stores';
|
|
|
|
|
|
|
|
const breadcrumb = [{ label: 'Início' }, { label: 'Trading', active: true }];
|
|
const breadcrumb = [{ label: 'Início' }, { label: 'Trading', active: true }];
|
|
|
const apiUrl = import.meta.env.VITE_API_URL;
|
|
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([]);
|
|
const orders = writable([]);
|
|
|
|
|
|
|
@@ -22,25 +28,138 @@
|
|
|
let orderbookLoading = false;
|
|
let orderbookLoading = false;
|
|
|
let orderbookError = '';
|
|
let orderbookError = '';
|
|
|
let orderbookEmptyMessage = '';
|
|
let orderbookEmptyMessage = '';
|
|
|
|
|
+ let orderbookSuccessMessage = '';
|
|
|
let initialized = false;
|
|
let initialized = false;
|
|
|
let lastFetchKey = '';
|
|
let lastFetchKey = '';
|
|
|
|
|
|
|
|
|
|
+ let stateOptions = [...DEFAULT_STATE_OPTIONS];
|
|
|
|
|
+ let statesLoading = false;
|
|
|
|
|
+ let statesError = '';
|
|
|
|
|
+
|
|
|
let commodities = [];
|
|
let commodities = [];
|
|
|
let commoditiesLoading = false;
|
|
let commoditiesLoading = false;
|
|
|
let commoditiesError = '';
|
|
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 () => {
|
|
onMount(async () => {
|
|
|
if (browser) {
|
|
if (browser) {
|
|
|
try {
|
|
try {
|
|
|
const saved = localStorage.getItem('tradingOrders');
|
|
const saved = localStorage.getItem('tradingOrders');
|
|
|
if (saved) orders.set(JSON.parse(saved));
|
|
if (saved) orders.set(JSON.parse(saved));
|
|
|
} catch {}
|
|
} catch {}
|
|
|
|
|
+ orderPaymentBeforeUnloadHandler = () => {
|
|
|
|
|
+ clearOrderPaymentState();
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener('beforeunload', orderPaymentBeforeUnloadHandler);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- await fetchCommoditiesOptions();
|
|
|
|
|
|
|
+ await loadStateOptions();
|
|
|
|
|
+ await Promise.all([fetchCommoditiesOptions(), fetchWalletTokens()]);
|
|
|
initialized = true;
|
|
initialized = true;
|
|
|
lastFetchKey = `${selectedState}|${selectedCommodity}`;
|
|
lastFetchKey = `${selectedState}|${selectedCommodity}`;
|
|
|
fetchOrderbook(selectedState, selectedCommodity);
|
|
fetchOrderbook(selectedState, selectedCommodity);
|
|
|
|
|
+ await restoreOrderbookPaymentState();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
async function fetchCommoditiesOptions() {
|
|
async function fetchCommoditiesOptions() {
|
|
@@ -79,6 +198,11 @@
|
|
|
.filter((item) => item.id)
|
|
.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;
|
|
commodities = mapped;
|
|
|
if (!mapped.length) {
|
|
if (!mapped.length) {
|
|
|
selectedCommodity = '';
|
|
selectedCommodity = '';
|
|
@@ -128,59 +252,30 @@
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
const token = get(authToken);
|
|
const token = get(authToken);
|
|
|
- const res = await fetch(`${apiUrl}/token/get`, {
|
|
|
|
|
|
|
+ const res = await fetch(`${apiUrl}/orderbook/filter`, {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: {
|
|
headers: {
|
|
|
'content-type': 'application/json',
|
|
'content-type': 'application/json',
|
|
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
|
},
|
|
},
|
|
|
body: JSON.stringify({
|
|
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 = [];
|
|
ordensCompra = [];
|
|
|
ordensVenda = mapped;
|
|
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) {
|
|
} catch (err) {
|
|
|
console.error('Erro ao buscar ordens:', err);
|
|
console.error('Erro ao buscar ordens:', err);
|
|
|
orderbookError = err?.message ?? 'Falha ao carregar ordens.';
|
|
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) {
|
|
function formatBRL(n) {
|
|
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n || 0));
|
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n || 0));
|
|
|
}
|
|
}
|
|
|
function formatEasyToken(n) {
|
|
function formatEasyToken(n) {
|
|
|
return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 6, maximumFractionDigits: 6 }).format(Number(n || 0));
|
|
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 buyTab = 'market';
|
|
|
let sellTab = 'market';
|
|
let sellTab = 'market';
|
|
@@ -250,6 +842,62 @@
|
|
|
triggerInsufficient('sell');
|
|
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 = [
|
|
const orderColumns = [
|
|
|
{ key: 'side', label: 'Tipo' },
|
|
{ key: 'side', label: 'Tipo' },
|
|
|
{ key: 'price', label: 'EasyCoin' },
|
|
{ key: 'price', label: 'EasyCoin' },
|
|
@@ -277,8 +925,7 @@ $: displayPendingSells = pendingSells.map((o) => ({
|
|
|
status: o.status
|
|
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 selectedCommodity = '';
|
|
|
|
|
|
|
|
let showBuyModal = false;
|
|
let showBuyModal = false;
|
|
@@ -289,6 +936,9 @@ $: displayPendingSells = pendingSells.map((o) => ({
|
|
|
let insufficientType = 'buy';
|
|
let insufficientType = 'buy';
|
|
|
let insufficientTimer = null;
|
|
let insufficientTimer = null;
|
|
|
|
|
|
|
|
|
|
+ let sellSubmitting = false;
|
|
|
|
|
+ let sellSubmitError = '';
|
|
|
|
|
+
|
|
|
function triggerInsufficient(type) {
|
|
function triggerInsufficient(type) {
|
|
|
insufficientType = type;
|
|
insufficientType = type;
|
|
|
insufficientVisible = true;
|
|
insufficientVisible = true;
|
|
@@ -309,6 +959,13 @@ $: displayPendingSells = pendingSells.map((o) => ({
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
onDestroy(() => {
|
|
|
if (insufficientTimer) clearTimeout(insufficientTimer);
|
|
if (insufficientTimer) clearTimeout(insufficientTimer);
|
|
|
|
|
+ stopOrderTransferPolling();
|
|
|
|
|
+ stopOrderPaymentCountdown();
|
|
|
|
|
+ stopOrderPaymentCopyTimeout();
|
|
|
|
|
+ if (browser && orderPaymentBeforeUnloadHandler) {
|
|
|
|
|
+ window.removeEventListener('beforeunload', orderPaymentBeforeUnloadHandler);
|
|
|
|
|
+ orderPaymentBeforeUnloadHandler = null;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
$: selectedCommodityObj = commodities.find((t) => t.id === selectedCommodity);
|
|
$: selectedCommodityObj = commodities.find((t) => t.id === selectedCommodity);
|
|
@@ -321,17 +978,30 @@ $: displayPendingSells = pendingSells.map((o) => ({
|
|
|
<Header title="Trading" subtitle="Trading do sistema" breadcrumb={breadcrumb}>
|
|
<Header title="Trading" subtitle="Trading do sistema" breadcrumb={breadcrumb}>
|
|
|
<svelte:fragment slot="extra">
|
|
<svelte:fragment slot="extra">
|
|
|
<div class="flex w-full items-end justify-between gap-4 md:gap-10">
|
|
<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>
|
|
</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}
|
|
{#if commoditiesError}
|
|
|
<div class="text-xs text-red-500 mb-1">{commoditiesError}</div>
|
|
<div class="text-xs text-red-500 mb-1">{commoditiesError}</div>
|
|
|
{/if}
|
|
{/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>
|
|
<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}
|
|
|
|
|
|
|
|
|
|
+ {#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}
|
|
<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>
|
|
|
<div class="md:col-span-2">
|
|
<div class="md:col-span-2">
|
|
@@ -390,13 +1070,23 @@ $: displayPendingSells = pendingSells.map((o) => ({
|
|
|
confirmBuy();
|
|
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) => {
|
|
on:confirm={(e) => {
|
|
|
const { valorSaca, quantidade } = e.detail || {};
|
|
const { valorSaca, quantidade } = e.detail || {};
|
|
|
sellTab = 'limit';
|
|
sellTab = 'limit';
|
|
|
sellLimitPrice = valorSaca ? String(valorSaca) : '';
|
|
sellLimitPrice = valorSaca ? String(valorSaca) : '';
|
|
|
sellAmountEasyToken = quantidade ? String(quantidade) : '';
|
|
sellAmountEasyToken = quantidade ? String(quantidade) : '';
|
|
|
- confirmSell();
|
|
|
|
|
|
|
+ void handleSellOrderSubmit(e.detail);
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
<ModalBase title="Saldo insuficiente" visible={insufficientVisible} onClose={closeInsufficient}>
|
|
<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'}.
|
|
Saldo insuficiente para {insufficientType === 'buy' ? 'iniciar uma compra' : 'iniciar uma venda'}.
|
|
|
</p>
|
|
</p>
|
|
|
</ModalBase>
|
|
</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>
|
|
</div>
|