|
@@ -1,46 +1,179 @@
|
|
|
<script>
|
|
<script>
|
|
|
import { createEventDispatcher } from 'svelte';
|
|
import { createEventDispatcher } from 'svelte';
|
|
|
import { fly, fade } from 'svelte/transition';
|
|
import { fly, fade } from 'svelte/transition';
|
|
|
- import checkIcon from '$lib/assets/icons/checkIcon.svg?raw';
|
|
|
|
|
|
|
+ import { tick } from 'svelte';
|
|
|
|
|
+ import { get } from 'svelte/store';
|
|
|
|
|
+ import { authToken } from '$lib/utils/stores.js';
|
|
|
|
|
|
|
|
export let isOpen = false;
|
|
export let isOpen = false;
|
|
|
export let onClose = () => {};
|
|
export let onClose = () => {};
|
|
|
- export let onConfirm = (method) => {};
|
|
|
|
|
|
|
+ export let onConfirm = () => {};
|
|
|
|
|
|
|
|
const dispatch = createEventDispatcher();
|
|
const dispatch = createEventDispatcher();
|
|
|
|
|
+ const apiUrl = import.meta.env.VITE_API_URL;
|
|
|
|
|
|
|
|
- let selected = 'Cartão de Crédito';
|
|
|
|
|
- const methods = [
|
|
|
|
|
- {
|
|
|
|
|
- id: 'card',
|
|
|
|
|
- label: 'Cartão de Crédito',
|
|
|
|
|
- icon: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' class='w-5 h-5'><path d='M2.25 7.5A2.25 2.25 0 014.5 5.25h15a2.25 2.25 0 012.25 2.25v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9zM3.75 9h16.5V7.5a.75.75 0 00-.75-.75h-15a.75.75 0 00-.75.75V9z'/></svg>`
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- id: 'pix',
|
|
|
|
|
- label: 'Pix',
|
|
|
|
|
- icon: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' class='w-5 h-5'><path d='M15.75 2.25a3 3 0 012.121.879l3 3a3 3 0 010 4.242l-8.25 8.25a3 3 0 01-4.242 0l-3-3a3 3 0 010-4.242l8.25-8.25A3 3 0 0115.75 2.25z'/></svg>`
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- id: 'crypto',
|
|
|
|
|
- label: 'Criptomoeda',
|
|
|
|
|
- icon: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' class='w-5 h-5'><path d='M12 2.25c5.385 0 9.75 4.365 9.75 9.75S17.385 21.75 12 21.75 2.25 17.385 2.25 12 6.615 2.25 12 2.25zM9 8.25h4.125a2.625 2.625 0 010 5.25H9V8.25zm1.5 3.75h2.625a1.125 1.125 0 100-2.25H10.5v2.25zM9 14.25h4.5a2.25 2.25 0 110 4.5H9v-4.5zm1.5 3h3a.75.75 0 000-1.5h-3v1.5z'/></svg>`
|
|
|
|
|
|
|
+ let amount = '';
|
|
|
|
|
+ let errorMessage = '';
|
|
|
|
|
+ let loading = false;
|
|
|
|
|
+ let pixCode = '';
|
|
|
|
|
+ let copyFeedback = '';
|
|
|
|
|
+ let pixStep = 'form';
|
|
|
|
|
+ let qrContainer;
|
|
|
|
|
+ let qrInstance;
|
|
|
|
|
+ let prevIsOpen = false;
|
|
|
|
|
+
|
|
|
|
|
+ function resetState() {
|
|
|
|
|
+ amount = '';
|
|
|
|
|
+ errorMessage = '';
|
|
|
|
|
+ loading = false;
|
|
|
|
|
+ pixCode = '';
|
|
|
|
|
+ copyFeedback = '';
|
|
|
|
|
+ pixStep = 'form';
|
|
|
|
|
+ if (qrContainer) {
|
|
|
|
|
+ qrContainer.innerHTML = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ qrInstance = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $: {
|
|
|
|
|
+ if (isOpen && !prevIsOpen) {
|
|
|
|
|
+ resetState();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isOpen && prevIsOpen) {
|
|
|
|
|
+ resetState();
|
|
|
}
|
|
}
|
|
|
- ];
|
|
|
|
|
|
|
+ prevIsOpen = isOpen;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
function close() {
|
|
function close() {
|
|
|
|
|
+ resetState();
|
|
|
onClose();
|
|
onClose();
|
|
|
dispatch('close');
|
|
dispatch('close');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function confirm() {
|
|
|
|
|
- onConfirm(selected);
|
|
|
|
|
- dispatch('confirm', { method: selected });
|
|
|
|
|
|
|
+ function formatCurrencyBRL(value) {
|
|
|
|
|
+ return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value || 0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function renderQrCode(code) {
|
|
|
|
|
+ if (!code) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!qrInstance) {
|
|
|
|
|
+ const module = await import('qr-code-styling');
|
|
|
|
|
+ const QRCodeStyles = module.default ?? module;
|
|
|
|
|
+ qrInstance = new QRCodeStyles({
|
|
|
|
|
+ width: 220,
|
|
|
|
|
+ height: 220,
|
|
|
|
|
+ type: 'svg',
|
|
|
|
|
+ data: code,
|
|
|
|
|
+ dotsOptions: { color: '#0f172a', type: 'rounded' },
|
|
|
|
|
+ backgroundOptions: { color: '#ffffff' }
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ qrInstance.update({ data: code });
|
|
|
|
|
+ }
|
|
|
|
|
+ await tick();
|
|
|
|
|
+ if (qrContainer) {
|
|
|
|
|
+ qrContainer.innerHTML = '';
|
|
|
|
|
+ qrInstance.append(qrContainer);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[Wallet] Falha ao renderizar QR Code', err);
|
|
|
|
|
+ errorMessage = 'Não foi possível gerar o QR Code. Utilize o código Pix copia e cola.';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function resolvePixFromResponse(body) {
|
|
|
|
|
+ if (!body) return '';
|
|
|
|
|
+ const data = body?.data ?? body;
|
|
|
|
|
+ return (
|
|
|
|
|
+ data?.pix_code ??
|
|
|
|
|
+ data?.pixCode ??
|
|
|
|
|
+ data?.pix ??
|
|
|
|
|
+ data?.copy_paste ??
|
|
|
|
|
+ data?.copyPaste ??
|
|
|
|
|
+ data?.payload ??
|
|
|
|
|
+ ''
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleConfirm() {
|
|
|
|
|
+ if (loading) return;
|
|
|
|
|
+ const numericAmount = Number(amount);
|
|
|
|
|
+ if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
|
|
|
|
|
+ errorMessage = 'Informe um valor em reais maior que zero.';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const token = get(authToken);
|
|
|
|
|
+ if (!token) {
|
|
|
|
|
+ errorMessage = 'Sessão expirada. Faça login novamente.';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!apiUrl) {
|
|
|
|
|
+ errorMessage = 'Configuração de API inválida.';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ loading = true;
|
|
|
|
|
+ errorMessage = '';
|
|
|
|
|
+ copyFeedback = '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${apiUrl}/wallet/pix/create`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'content-type': 'application/json',
|
|
|
|
|
+ Authorization: `Bearer ${token}`
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({ value: numericAmount })
|
|
|
|
|
+ });
|
|
|
|
|
+ const raw = await res.text();
|
|
|
|
|
+ let body = null;
|
|
|
|
|
+ if (raw) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = JSON.parse(raw);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[Wallet] Resposta inválida ao solicitar PIX', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!res.ok || (body?.status && body.status !== 'ok')) {
|
|
|
|
|
+ throw new Error(body?.msg ?? body?.message ?? `Falha ao gerar pagamento (HTTP ${res.status}).`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const pix = resolvePixFromResponse(body);
|
|
|
|
|
+ if (!pix) {
|
|
|
|
|
+ throw new Error('Resposta do servidor não contém o código Pix.');
|
|
|
|
|
+ }
|
|
|
|
|
+ pixCode = pix;
|
|
|
|
|
+ pixStep = 'result';
|
|
|
|
|
+ await renderQrCode(pixCode);
|
|
|
|
|
+ const detail = { method: 'Pix', amount: numericAmount, pixCode, shouldClose: false };
|
|
|
|
|
+ onConfirm?.(detail);
|
|
|
|
|
+ dispatch('confirm', detail);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[Wallet] Erro ao confirmar compra BRL via Pix:', err);
|
|
|
|
|
+ errorMessage = err?.message ?? 'Não foi possível gerar o pagamento via Pix.';
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleCopyPix() {
|
|
|
|
|
+ if (!pixCode) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await navigator.clipboard.writeText(pixCode);
|
|
|
|
|
+ copyFeedback = 'Código Pix copiado!';
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[Wallet] Falha ao copiar Pix', err);
|
|
|
|
|
+ copyFeedback = 'Não foi possível copiar. Copie manualmente.';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleNewPayment() {
|
|
|
|
|
+ resetState();
|
|
|
}
|
|
}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
{#if isOpen}
|
|
{#if isOpen}
|
|
|
- <div class="fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Selecionar forma de pagamento">
|
|
|
|
|
|
|
+ <div class="fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Pagamento via Pix">
|
|
|
|
|
+
|
|
|
<div
|
|
<div
|
|
|
class="absolute inset-0 bg-black/40"
|
|
class="absolute inset-0 bg-black/40"
|
|
|
role="button"
|
|
role="button"
|
|
@@ -61,7 +194,7 @@
|
|
|
transition:fly={{ x: 24, duration: 200 }}
|
|
transition:fly={{ x: 24, duration: 200 }}
|
|
|
>
|
|
>
|
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
|
- <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Selecionar Forma de Pagamento</h3>
|
|
|
|
|
|
|
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Pagamento via Pix</h3>
|
|
|
<button
|
|
<button
|
|
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
|
aria-label="Fechar"
|
|
aria-label="Fechar"
|
|
@@ -73,41 +206,99 @@
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</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 class="p-6 space-y-4 overflow-auto">
|
|
|
|
|
+ <div class="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/60 dark:bg-blue-900/20 px-4 py-3 text-sm text-blue-900 dark:text-blue-100">
|
|
|
|
|
+ <p class="font-semibold">Pagamento instantâneo</p>
|
|
|
|
|
+ <p>Informe o valor desejado para gerar um Pix copia e cola. Após a confirmação, apresentaremos o QR Code.</p>
|
|
|
</div>
|
|
</div>
|
|
|
- {#each methods as m}
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- class="w-full flex items-center justify-between px-4 py-3 rounded-lg border text-left transition-colors {selected === m.label ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}"
|
|
|
|
|
- on:click={() => (selected = m.label)}
|
|
|
|
|
- >
|
|
|
|
|
- <span class="flex items-center gap-3 text-gray-800 dark:text-gray-100">
|
|
|
|
|
- <span class="text-gray-600 dark:text-gray-300">{@html m.icon}</span>
|
|
|
|
|
- {m.label}
|
|
|
|
|
- </span>
|
|
|
|
|
- {#if selected === m.label}
|
|
|
|
|
- <span class="text-blue-600 [&>svg]:w-5 [&>svg]:h-5 [&>svg]:fill-current">{@html checkIcon}</span>
|
|
|
|
|
- {/if}
|
|
|
|
|
- </button>
|
|
|
|
|
- {/each}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {#if errorMessage}
|
|
|
|
|
+ <div class="rounded border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30 text-sm text-red-700 dark:text-red-200 px-3 py-2">
|
|
|
|
|
+ {errorMessage}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+
|
|
|
|
|
+ {#if pixStep === 'form'}
|
|
|
|
|
+ <div class="space-y-4">
|
|
|
|
|
+ <div class="space-y-2">
|
|
|
|
|
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="pix-amount">Valor em reais</label>
|
|
|
|
|
+ <div class="relative">
|
|
|
|
|
+ <span class="absolute inset-y-0 left-3 flex items-center text-gray-400">R$</span>
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="pix-amount"
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="0"
|
|
|
|
|
+ step="0.01"
|
|
|
|
|
+ inputmode="decimal"
|
|
|
|
|
+ bind:value={amount}
|
|
|
|
|
+ class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
|
|
|
|
|
+ placeholder="0,00"
|
|
|
|
|
+ disabled={loading}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="rounded-md border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
|
|
|
|
+ <p class="font-semibold text-gray-700 dark:text-gray-100">Forma de pagamento</p>
|
|
|
|
|
+ <p>Pix (instantâneo) — o comprovante será gerado após a confirmação.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <div class="space-y-4">
|
|
|
|
|
+ <div class="rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 px-4 py-3 text-sm">
|
|
|
|
|
+ <p class="text-gray-600 dark:text-gray-300">Valor do pagamento</p>
|
|
|
|
|
+ <p class="text-lg font-semibold text-gray-900 dark:text-gray-100">{formatCurrencyBRL(Number(amount))}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="flex flex-col items-center gap-4">
|
|
|
|
|
+ <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
|
|
|
|
|
+ <div bind:this={qrContainer} class="w-[220px] h-[220px] flex items-center justify-center" aria-label="QR Code do Pix"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="w-full space-y-2">
|
|
|
|
|
+ <label class="block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Código Pix copia e cola</label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ class="w-full h-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 px-3 py-2"
|
|
|
|
|
+ readonly
|
|
|
|
|
+ >{pixCode}</textarea>
|
|
|
|
|
+ <div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ <button type="button" class="inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800" on:click={handleCopyPix}>
|
|
|
|
|
+ Copiar código
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {#if copyFeedback}
|
|
|
|
|
+ <span>{copyFeedback}</span>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
|
|
<button
|
|
<button
|
|
|
class="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"
|
|
class="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"
|
|
|
on:click={close}
|
|
on:click={close}
|
|
|
|
|
+ disabled={loading}
|
|
|
>
|
|
>
|
|
|
- Cancelar
|
|
|
|
|
- </button>
|
|
|
|
|
- <button class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white" on:click={confirm}>
|
|
|
|
|
- Confirmar
|
|
|
|
|
|
|
+ {pixStep === 'form' ? 'Cancelar' : 'Fechar'}
|
|
|
</button>
|
|
</button>
|
|
|
|
|
+
|
|
|
|
|
+ {#if pixStep === 'form'}
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
|
|
|
|
|
+ on:click={handleConfirm}
|
|
|
|
|
+ disabled={loading}
|
|
|
|
|
+ >
|
|
|
|
|
+ {loading ? 'Gerando Pix...' : 'Confirmar Pix'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white"
|
|
|
|
|
+ on:click={handleNewPayment}
|
|
|
|
|
+ >
|
|
|
|
|
+ Gerar novo pagamento
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {/if}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|