+page.svelte 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. <script>
  2. import { onMount } from 'svelte';
  3. import { get } from 'svelte/store';
  4. import Header from '$lib/layout/Header.svelte';
  5. import StatsCard from '$lib/components/StatsCard.svelte';
  6. import PaymentMenu from '$lib/components/wallet/PaymentMenu.svelte';
  7. import SellMenu from '$lib/components/wallet/SellMenu.svelte';
  8. import tokensIcon from '$lib/assets/icons/sidebar/tokens.svg?raw';
  9. import walletIcon from '$lib/assets/icons/sidebar/wallet.svg?raw';
  10. import { authToken } from '$lib/utils/stores';
  11. import ModalBase from '$lib/components/trading/ModalBase.svelte';
  12. const breadcrumb = [{ label: 'Início' }, { label: 'wallet', active: true }];
  13. const apiUrl = import.meta.env.VITE_API_URL;
  14. let walletTokens = [];
  15. let easyCoinBalance = 0;
  16. let tokensLoading = false;
  17. let tokensError = '';
  18. let isPaymentOpen = false;
  19. let isSellOpen = false;
  20. let rateBRLPerEasyCoin = 100;
  21. onMount(() => {
  22. void fetchWalletTokens();
  23. });
  24. let tokenDetailModalVisible = false;
  25. let tokenDetailSelected = null;
  26. let tokenDetailEntries = [];
  27. function openPayment() {
  28. isPaymentOpen = true;
  29. }
  30. function closePayment() {
  31. isPaymentOpen = false;
  32. }
  33. function confirmPayment(method) {
  34. isPaymentOpen = false;
  35. }
  36. function openSell() {
  37. isSellOpen = true;
  38. }
  39. function closeSell() {
  40. isSellOpen = false;
  41. }
  42. function confirmSell(e) {
  43. isSellOpen = false;
  44. }
  45. async function parseResponse(res) {
  46. const raw = await res.text();
  47. return raw ? JSON.parse(raw) : null;
  48. }
  49. async function fetchWalletTokens() {
  50. if (!apiUrl) return;
  51. tokensLoading = true;
  52. tokensError = '';
  53. try {
  54. const token = get(authToken);
  55. if (!token) {
  56. throw new Error('Sessão expirada. Faça login novamente.');
  57. }
  58. const res = await fetch(`${apiUrl}/wallet/tokens`, {
  59. method: 'POST',
  60. headers: {
  61. 'content-type': 'application/json',
  62. Authorization: `Bearer ${token}`
  63. }
  64. });
  65. const body = await parseResponse(res);
  66. if (!res.ok || body?.status !== 'ok') {
  67. throw new Error(body?.msg ?? 'Falha ao carregar tokens da wallet.');
  68. }
  69. const payload = body?.data ?? {};
  70. walletTokens = Array.isArray(payload?.tokens) ? payload.tokens : [];
  71. easyCoinBalance = resolveEasyCoinBalance(payload);
  72. } catch (err) {
  73. console.error('[Wallet] Erro ao buscar tokens:', err);
  74. walletTokens = [];
  75. easyCoinBalance = 0;
  76. tokensError = err?.message ?? 'Não foi possível carregar os tokens.';
  77. } finally {
  78. tokensLoading = false;
  79. }
  80. }
  81. function resolveEasyCoinBalance(payload) {
  82. const candidates = [
  83. payload?.wallet?.easycoin_balance,
  84. payload?.wallet?.easyCoinBalance,
  85. payload?.wallet?.balance,
  86. payload?.easycoin_balance,
  87. payload?.easyCoinBalance,
  88. payload?.easycoin,
  89. payload?.balance
  90. ];
  91. const firstValue = candidates.find((value) => value !== undefined && value !== null && value !== '');
  92. return Number(firstValue ?? 0);
  93. }
  94. function formatToken(n) {
  95. return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 6 }).format(Number(n || 0));
  96. }
  97. function formatCoin(n) {
  98. return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(n || 0));
  99. }
  100. function formatCurrencyBRL(n) {
  101. return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n || 0));
  102. }
  103. function tokenCardLabel(token) {
  104. return token?.cpr_product_name ?? token?.token_content ?? token?.token_external_id ?? token?.token_id ?? 'EasyToken';
  105. }
  106. function tokenCardSubtitle(token) {
  107. if (token?.token_city && token?.token_uf) {
  108. return `${token.token_city}/${token.token_uf}`;
  109. }
  110. if (token?.token_external_id) {
  111. return `ID ${token.token_external_id}`;
  112. }
  113. return 'Saldo atual';
  114. }
  115. function tokenAmount(token) {
  116. const candidates = [
  117. token?.token_commodities_amount,
  118. token?.token_amount,
  119. token?.token_balance,
  120. token?.balance,
  121. token?.amount
  122. ];
  123. const value = candidates.find((item) => item !== undefined && item !== null && item !== '');
  124. return Number(value ?? 0);
  125. }
  126. function formatTokenDetailKey(key = '') {
  127. return key
  128. .replace(/_/g, ' ')
  129. .replace(/([a-z])([A-Z])/g, '$1 $2')
  130. .replace(/\s+/g, ' ')
  131. .trim()
  132. .replace(/^./, (c) => c.toUpperCase());
  133. }
  134. const hiddenTokenDetailKeys = new Set([
  135. 'token_id',
  136. 'token_external_id',
  137. 'token_commodities_amount',
  138. 'token_commodities_value',
  139. 'token_uf',
  140. 'token_city',
  141. 'token_content',
  142. 'wallet_id',
  143. 'chain_id',
  144. 'commodities_id'
  145. ]);
  146. const tokenDetailFieldConfig = [
  147. { key: 'cpr_product_name', label: 'Produto' },
  148. {
  149. key: 'cpr_product_quantity',
  150. label: 'Quantidade do Produto',
  151. formatter: (value, token) => {
  152. const formattedQuantity = formatToken(value);
  153. const unit = token?.cpr_measure_unit_name ?? token?.cpr_packaging_way_name;
  154. return unit ? `${formattedQuantity} ${unit}` : formattedQuantity;
  155. }
  156. },
  157. { key: 'cpr_packaging_way_name', label: 'Forma de Embalagem' },
  158. { key: 'cpr_measure_unit_name', label: 'Unidade de Medida' },
  159. {
  160. key: 'cpr_issue_value',
  161. label: 'Valor da Emissão',
  162. formatter: (value) => formatCurrencyBRL(value)
  163. },
  164. { key: 'cpr_delivery_place_city_name', label: 'Cidade de Entrega' },
  165. { key: 'cpr_delivery_place_state_acronym', label: 'Estado de Entrega' },
  166. { key: 'cpr_production_place_name', label: 'Local de Produção' },
  167. { key: 'cpr_id', label: 'Número da CPR' },
  168. { key: 'user_id', label: 'Código do Usuário' }
  169. ];
  170. const tokenDetailKeyTranslations = tokenDetailFieldConfig.reduce((acc, field) => {
  171. if (field.key) {
  172. acc[field.key] = field.label;
  173. }
  174. return acc;
  175. }, {});
  176. function buildTokenDetailEntries(token = {}) {
  177. if (!token || typeof token !== 'object') return [];
  178. const entries = [];
  179. const usedKeys = new Set();
  180. for (const field of tokenDetailFieldConfig) {
  181. const rawValue = token?.[field.key];
  182. if (rawValue === null || rawValue === undefined || rawValue === '') continue;
  183. usedKeys.add(field.key);
  184. const formattedValue = field.formatter
  185. ? field.formatter(rawValue, token)
  186. : typeof rawValue === 'number'
  187. ? formatToken(rawValue)
  188. : Array.isArray(rawValue)
  189. ? rawValue.join(', ')
  190. : String(rawValue);
  191. entries.push({ key: field.key, label: field.label, value: formattedValue });
  192. }
  193. Object.entries(token)
  194. .filter(([key, value]) => !hiddenTokenDetailKeys.has(key) && !usedKeys.has(key) && value !== null && value !== undefined && value !== '')
  195. .forEach(([key, value]) => {
  196. const formattedValue = typeof value === 'number'
  197. ? formatToken(value)
  198. : Array.isArray(value)
  199. ? value.join(', ')
  200. : String(value);
  201. const label = tokenDetailKeyTranslations[key] ?? formatTokenDetailKey(key);
  202. entries.push({ key, label, value: formattedValue });
  203. });
  204. return entries;
  205. }
  206. function openTokenDetailModal(token) {
  207. if (!token) return;
  208. tokenDetailSelected = token;
  209. tokenDetailEntries = buildTokenDetailEntries(token);
  210. tokenDetailModalVisible = true;
  211. }
  212. function closeTokenDetailModal() {
  213. tokenDetailModalVisible = false;
  214. tokenDetailSelected = null;
  215. tokenDetailEntries = [];
  216. }
  217. </script>
  218. <div>
  219. <Header title="Wallet" subtitle="Saldos e operações" breadcrumb={breadcrumb} />
  220. <div class="p-4 space-y-6">
  221. {#if tokensError}
  222. <div class="rounded border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{tokensError}</div>
  223. {/if}
  224. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  225. {#if tokensLoading && !walletTokens.length}
  226. <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">
  227. Carregando tokens...
  228. </div>
  229. {:else if walletTokens.length}
  230. {#each walletTokens as token (token?.token_external_id ?? token?.token_id ?? token?.cpr_id ?? token)}
  231. <div class="rounded-lg overflow-hidden">
  232. <button
  233. type="button"
  234. class="block w-full text-left focus:outline-none"
  235. on:click={() => openTokenDetailModal(token)}
  236. >
  237. <StatsCard
  238. title={tokenCardLabel(token)}
  239. value={formatToken(tokenAmount(token))}
  240. change={tokenCardSubtitle(token)}
  241. iconSvg={tokensIcon}
  242. />
  243. </button>
  244. </div>
  245. {/each}
  246. {:else}
  247. <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">
  248. Nenhum token encontrado para sua conta.
  249. </div>
  250. {/if}
  251. <div class="rounded-lg overflow-hidden">
  252. <StatsCard
  253. title="EasyCoin"
  254. value={formatCoin(easyCoinBalance)}
  255. change={tokensLoading ? 'Atualizando...' : 'Saldo atual'}
  256. iconSvg={walletIcon}
  257. />
  258. </div>
  259. </div>
  260. <div class="flex gap-3">
  261. <button
  262. class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white shadow"
  263. on:click={openPayment}
  264. >
  265. Comprar BRLA
  266. </button>
  267. <button
  268. class="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100"
  269. on:click={openSell}
  270. >
  271. Vender BRLA
  272. </button>
  273. </div>
  274. </div>
  275. <PaymentMenu
  276. isOpen={isPaymentOpen}
  277. onClose={closePayment}
  278. onConfirm={confirmPayment}
  279. />
  280. <SellMenu
  281. isOpen={isSellOpen}
  282. onClose={closeSell}
  283. onConfirm={(e) => confirmSell(e.detail || e)}
  284. rateBRLPerEasyCoin={rateBRLPerEasyCoin}
  285. />
  286. <ModalBase
  287. title="Detalhes do token"
  288. visible={tokenDetailModalVisible}
  289. onClose={closeTokenDetailModal}
  290. >
  291. {#if tokenDetailSelected}
  292. <div class="space-y-4 text-sm text-gray-800 dark:text-gray-100">
  293. <div class="rounded border border-gray-200 dark:border-gray-700 p-3 space-y-2 bg-gray-50 dark:bg-gray-800/60">
  294. <div class="text-base font-semibold">{tokenCardLabel(tokenDetailSelected)}</div>
  295. <div class="flex justify-between">
  296. <span>Quantidade</span>
  297. <span>{formatToken(tokenAmount(tokenDetailSelected))}</span>
  298. </div>
  299. <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400">
  300. <span>Local</span>
  301. <span>
  302. {#if tokenDetailSelected?.token_city || tokenDetailSelected?.token_uf}
  303. {tokenDetailSelected?.token_city ?? '—'}/{tokenDetailSelected?.token_uf ?? '—'}
  304. {:else}
  305. Não informado
  306. {/if}
  307. </span>
  308. </div>
  309. </div>
  310. <div class="max-h-64 overflow-y-auto pr-1 space-y-1">
  311. {#if tokenDetailEntries.length === 0}
  312. <p class="text-xs text-gray-500">Sem dados adicionais para este token.</p>
  313. {:else}
  314. <dl class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
  315. {#each tokenDetailEntries as entry}
  316. <div class="flex items-start justify-between px-3 py-2">
  317. <dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 w-1/2 pr-2">{entry.label}</dt>
  318. <dd class="text-sm text-gray-900 dark:text-gray-100 w-1/2 text-right break-words">{entry.value}</dd>
  319. </div>
  320. {/each}
  321. </dl>
  322. {/if}
  323. </div>
  324. </div>
  325. {:else}
  326. <p class="text-sm text-gray-500">Selecione um token para ver detalhes.</p>
  327. {/if}
  328. </ModalBase>
  329. </div>