2 Commits

Author SHA1 Message Date
hueso
325778da4d more type fixes 2025-06-26 12:11:13 -03:00
hueso
9d9ed7a3dd fix type errors 2025-06-22 01:17:10 -03:00
113 changed files with 11186 additions and 5246 deletions

18
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
env: {
node: true,
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"@vue/eslint-config-prettier",
],
parserOptions: {
ecmaVersion: "latest",
},
};

60
.github/workflows/cd.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Deploy FrontEnd
on:
push:
branches: [ main, develop ]
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID_STAGING }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_STAGING }}
ENV_VARIABLE: ${{ secrets.ENV_STAGING }}
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v3
- name: 🏗 Config .env
run: echo "$ENV_VARIABLE" > .env
- name: 🏗 Install Vercel CLI
run: npm install --global vercel@latest
- name: 🏗 Pull staging app from vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_AUTH_TOKEN }}
- name: 📦 Build staging app artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_AUTH_TOKEN }}
- name: 📦 Deploy staging app artifacts to vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_AUTH_TOKEN }}
deploy-production:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
ENV_VARIABLE: ${{ secrets.ENV_PROD }}
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v3
- name: 🏗 Config .env
run: echo "$ENV_VARIABLE" > .env
- name: 🏗 Install Vercel CLI
run: npm install --global vercel@latest
- name: 🏗 Pull production vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_AUTH_TOKEN }}
- name: 📦 Build app artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_AUTH_TOKEN }}
- name: 📦 Deploy app artifacts to vercel in production
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_AUTH_TOKEN }}

64
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: CI script
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v3
- name: 🏗 Setup node
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'yarn'
- name: 🏗 Install dependencies
run: yarn
- name: 📦 Lint with eslint
run: yarn lint
build:
runs-on: ubuntu-latest
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID_STAGING }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_STAGING }}
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v3
- name: 🏗 Install Vercel CLI
run: npm install --global vercel@latest
- name: 🏗 Pull staging app from vercel environment
run: vercel pull --yes --token=${{ secrets.VERCEL_AUTH_TOKEN }}
- name: 📦 Build staging app artifacts
run: vercel build --token=${{ secrets.VERCEL_AUTH_TOKEN }}
test-coverage:
name: SonarCloud
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: 🏗 Install dependencies
run: yarn
- name: 📦 Test and coverage
run: yarn coverage
- name: 📦 SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,139 +0,0 @@
name: Deploy to IPFS
on:
push:
branches: [main, develop]
tags: ['*']
pull_request:
branches: [main, develop]
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
container: node:lts
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Install bun
run: npm install -g bun
- name: Install submodule deps (yarn 4 via corepack)
run: corepack enable && yarn --cwd p2pix-smart-contracts install --immutable
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate contract ABIs
env:
ALCHEMY_API_KEY: dummy
run: bun run wagmi:gen
- name: Build SPA
run: bun run build-only
- name: Upload dist artifact
# v3 pinned: Gitea Actions does not implement the v4 cache API
# (the runner returns ECONNREFUSED on getCacheEntry).
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 1
deploy-rsync:
needs: build
runs-on: ubuntu-latest
container: node:lts
steps:
- uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Deploy via RSYNC
uses: up9cloud/action-rsync@v1
env:
USER: ${{ secrets.SSH_USER }}
HOST: ${{ secrets.SSH_HOST }}
KEY: ${{ secrets.SSH_KEY }}
SOURCE: dist/
TARGET: ${{ github.ref_name }}
deploy-ipfs:
needs: build
runs-on: ubuntu-latest
container: node:lts
outputs:
cid: ${{ steps.ipfs.outputs.cid }}
steps:
- name: Install jq
run: apt-get update && apt-get install -y --no-install-recommends jq
- uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Deploy to IPFS
uses: ipshipyard/ipfs-deploy-action@v1
id: ipfs
with:
path-to-deploy: dist
set-github-status: false
set-pr-comment: false
kubo-api-url: ${{ secrets.KUBO_API_URL }}
kubo-api-auth: ${{ secrets.KUBO_API_AUTH }}
upload-car-artifact: false
deploy-pinata:
needs: [deploy-ipfs, deploy-rsync]
runs-on: ubuntu-latest
container: node:lts
steps:
- name: Install jq
run: apt-get update && apt-get install -y --no-install-recommends jq
- uses: actions/checkout@v4
with:
sparse-checkout: package.json
- uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Install Pinata CLI
run: curl -fsSL https://cli.pinata.cloud/install | bash
- name: Authenticate Pinata CLI
env:
PINATA_JWT: ${{ secrets.PINATA_JWT }}
run: echo -n "$PINATA_JWT" > ~/.pinata-files-cli
- name: Upload to Pinata
env:
PINATA_GROUP: ${{ secrets.PINATA_GROUP }}
IPFS_CID: ${{ needs.deploy-ipfs.outputs.cid }}
run: |
export PATH="$HOME/.local/share/pinata/:$PATH"
VERSION=$(jq -r .version package.json)
UPLOAD_JSON=$(pinata upload --group "$PINATA_GROUP" --name "p2pix_${VERSION}" ./dist)
CID=$(echo "$UPLOAD_JSON" | jq -r .cid)
if [ -z "$CID" ] || [ "$CID" = "null" ]; then
echo "Error: Could not parse CID. Output: $UPLOAD_JSON"
exit 1
fi
{
echo "## Deploy Summary"
echo "- IPFS CID (kubo): \`$IPFS_CID\`"
echo "- Pinata CID: \`$CID\`"
echo "- Version: \`$VERSION\`"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,30 +0,0 @@
name: Lint & Format
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
container: oven/bun:1-alpine
steps:
- name: Install required tools
run: apk add --no-cache git
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: ESLint
run: bun run lint
- name: Prettier check
run: bun run format:check

View File

@@ -1,28 +0,0 @@
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
unit:
runs-on: ubuntu-latest
container: oven/bun:1-alpine
steps:
- name: Install required tools
run: apk add --no-cache git
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Unit tests (vitest)
run: bunx vitest run --passWithNoTests

6
.gitignore vendored
View File

@@ -13,9 +13,6 @@ dist
dist-ssr
coverage
*.local
vendor/
.dagrobin
.claude
/cypress/videos/
/cypress/screenshots/
@@ -32,6 +29,3 @@ vendor/
.vercel
.env
# Codegen output (regenerated by `bun run wagmi:gen`, runs on prestart)
src/blockchain/abi.ts

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "p2pix-smart-contracts"]
path = p2pix-smart-contracts
url = https://git.p2pix.co/doiim/p2pix-smart-contracts

View File

@@ -1,3 +1 @@
{
"singleQuote": true
}
{}

View File

@@ -1,10 +1,10 @@
FROM oven/bun:latest
FROM node:lts-alpine
WORKDIR /app
COPY package.json bun.lock ./
COPY vendor ./vendor
RUN bun install --frozen-lockfile
COPY . .
COPY package.json yarn.lock ./
RUN yarn
COPY ./ ./
EXPOSE 3000
CMD ["bun", "run", "start"]
CMD ["yarn", "start"]

View File

@@ -3,6 +3,13 @@
</p>
<br />
<center>
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=liftlearning_P2Pix-Front-End&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=liftlearning_P2Pix-Front-End)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=liftlearning_P2Pix-Front-End&metric=coverage)](https://sonarcloud.io/summary/new_code?id=liftlearning_P2Pix-Front-End)
</center>
This application aims to create a democratic and secure solution for the purchase and sale of ERC20 tokens, through the PIX, integrating the functionalities of smart contracts (smart contracts) of the blockchain with a receipt by digital signature. Allowing the integration of national financial system transactions to public blockchains, dispensing with custody through intermediaries.
# Table of Contents
@@ -52,54 +59,47 @@ See [Vite Configuration Reference](https://vitejs.dev/config/).
## Dependencies
### API + RPC
### API
For full operation of the application, it is necessary to correctly configure the variable that points to the api in the .env file, in the repository there is an .env.example file, just rename it to just .env and modify the variable `VITE_API_URL`. The api can be run locally see [https://github.com/liftlearning/Pix-Explorer-Back-End](https://github.com/liftlearning/Pix-Explorer-Back-End), or it can be pointed to just her staging address: [https://p2pix-block-explorer-api-staging.vercel.app/](https://p2pix-block-explorer-api-staging.vercel.app/)
Copy `.env.example` to `.env` and set the per-network variables:
### Alchemy Keys
In the .env file, set `VITE_GOERLI_API_URL=https://eth-goerli.g.alchemy.com/v2/Zu9m4b2U_EzVU_zd-vgZDOleY8OF1DNP` and `VITE_MUMBAI_API_URL=https://polygon-mumbai.g.alchemy.com/v2/ZANeCqfj6VsXGpOH6gWAP6SIVIgD9Pwv`
| Var | Purpose |
|---|---|
| `VITE_APP_API_URL` | zkPix middleware base URL (default `http://localhost:3001`) |
| `VITE_SEPOLIA_API_URL`, `VITE_MAINNET_API_URL`, `VITE_RSK_API_URL` | RPC endpoints per network (Alchemy, Infura, public RPC) |
| `VITE_SEPOLIA_TOKEN_ADDRESS`, `VITE_MAINNET_TOKEN_ADDRESS`, `VITE_RSK_TOKEN_ADDRESS` | BRZ token address per network |
| `VITE_SEPOLIA_SUBGRAPH_URL`, `VITE_MAINNET_SUBGRAPH_URL`, `VITE_RSK_SUBGRAPH_URL` | The Graph subgraph endpoints |
You can also replace it with your own Alchemy Keys if you have one.
## Build Setup
The application can be tested by its trial version [https://p2pix-staging.vercel.app/](https://p2pix-staging.vercel.app/), the only requirement is to be running the smart contract of local way. To run the application locally, there are two different ways:
### Run with bun
### Run with yarn
```sh
# Pull the smart-contracts submodule (skip if you cloned with --recurse-submodules)
git submodule update --init
# Clone the repo
git clone https://github.com/liftlearning/P2Pix-Front-End
cd P2Pix-Front-End
# Install front-end dependencies
bun install
# One-time bootstrap of the smart-contracts submodule (needed before wagmi:gen)
cd p2pix-smart-contracts && bun install && cd ..
# Generate ABI bindings from the submodule (run again whenever contracts change)
bun run wagmi:gen
# Install dependencies with yarn
yarn install
# Type-Check, Compile and Minify for Production
bun run build
yarn build
# Compile and Hot-Reload for Development (port 3000)
bun start
yarn start
# Lint with [ESLint](https://eslint.org/)
bun run lint
yarn lint
```
### Run with docker-compose
```sh
# Pull the smart-contracts submodule (skip if you cloned with --recurse-submodules)
git submodule update --init
# Clone the repo
git clone https://github.com/liftlearning/P2Pix-Front-End
cd P2Pix-Front-End
#1. Install [Docker](https://docs.docker.com/install/linux/docker-ce/ubuntu/);
#2. Install [Docker Compose](https://docs.docker.com/compose/install/).
# Run docker-compose up command
docker-compose up
```
@@ -116,5 +116,3 @@ curl -X POST \
https://api.studio.thegraph.com/query/113713/p-2-pix/sepolia
https://api.studio.thegraph.com/query/113713/p-2-pix/1
curl --request POST --url 'https://api.hm.bb.com.br/testes-portal-desenvolvedor/v1/boletos-pix/pagar?gw-app-key=95cad3f03fd9013a9d15005056825665' --header 'content-type: application/json' --data '{"pix":"00020101021226070503***63041654" }'

View File

@@ -1,7 +1,7 @@
module.exports = {
presets: [
[
'@babel/preset-env',
"@babel/preset-env",
{
modules: false,
},
@@ -10,8 +10,8 @@ module.exports = {
env: {
test: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
},
},

1204
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -5,5 +5,7 @@ services:
container_name: p2pix_frontend
build:
context: .
volumes:
- '.:/app'
ports:
- '3000:3000'

2
env.d.ts vendored
View File

@@ -1,3 +1 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -1,31 +0,0 @@
import pluginVue from 'eslint-plugin-vue';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import vuePrettierConfig from '@vue/eslint-config-prettier';
const sources = ['src/**/*.{ts,tsx,vue,js,mjs,cjs}', 'tests/**/*.{ts,tsx,vue,js,mjs,cjs}'];
export default defineConfigWithVueTs(
{ ignores: ['src/generated.ts', 'dist/**', 'node_modules/**'] },
{
files: sources,
extends: [pluginVue.configs['flat/essential'], vueTsConfigs.recommended],
},
{
files: sources,
rules: {
quotes: ['error', 'single', { avoidEscape: true }],
'prettier/prettier': ['error', { singleQuote: true }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{ ...vuePrettierConfig, files: sources },
);

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [
"src/router/index.ts",
"index.html",
"wagmi.config.ts"
],
"project": [
"src/**/*.{ts,vue}",
"*.config.{ts,js}"
],
"ignore": [
"p2pix-smart-contracts/**",
"babel.config.js"
],
"ignoreUnresolved": [
"./abi",
"@/blockchain/abi"
],
"tags": ["public"]
}

View File

@@ -1,60 +1,65 @@
{
"name": "p2pix-front-end",
"version": "1.2.0",
"type": "module",
"version": "0.1.0",
"scripts": {
"start": "vite --host=0.0.0.0 --port 3000",
"build": "bun run type-check && bun run build-only",
"build-only": "vite build",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test": "vitest",
"serve": "vue-cli-service serve",
"coverage": "vitest run --coverage",
"build-only": "vite build",
"type-check": "vue-tsc --skipLibCheck --noEmit",
"lint": "eslint",
"lint:fix": "eslint --fix",
"format": "prettier --write \"src/**/*.{ts,vue,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,vue,json}\"",
"wagmi:gen": "wagmi generate",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore --fix",
"lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@floating-ui/vue": "^1.1.11",
"@vueuse/core": "^14.3.0",
"@web3-onboard/injected-wallets": "^2.11.3",
"@web3-onboard/vue": "^2.10.0",
"marked": "^18.0.3",
"qrcode": "^1.5.4",
"viem": "^2.48.8",
"vite-svg-loader": "^5.1.1",
"vue": "^3.5.33",
"vue-router": "^5.0.6"
"@floating-ui/vue": "^0.2.1",
"@headlessui/vue": "^1.7.3",
"@heroicons/vue": "^2.0.12",
"@vueuse/core": "^9.12.0",
"@web3-onboard/injected-wallets": "^2.11.2",
"@web3-onboard/vue": "^2.9.0",
"alchemy-sdk": "^2.3.0",
"axios": "^1.2.1",
"crc": "^3.8.0",
"ethers": "^6.13.4",
"marked": "^4.2.12",
"qrcode": "^1.5.1",
"viem": "^2.31.3",
"vite-svg-loader": "^5.1.0",
"vue": "^3.2.41",
"vue-markdown": "^2.2.4",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/coverage-v8": "^4.1.5",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.1",
"@wagmi/cli": "^2.10.0",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.0",
"happy-dom": "^20.9.0",
"postcss": "^8.5.8",
"prettier": "^3.5.3",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"vue-tsc": "^3.2.7"
},
"trustedDependencies": [
"esbuild",
"vue-demi"
]
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.18.6",
"@rushstack/eslint-patch": "^1.1.4",
"@types/crc": "^3.8.0",
"@types/jest": "^27.0.0",
"@types/marked": "^4.0.8",
"@types/node": "^16.11.68",
"@types/qrcode": "^1.5.0",
"@types/vue-markdown": "^2.2.1",
"@vitejs/plugin-vue": "^3.1.2",
"@vitejs/plugin-vue-jsx": "^2.0.1",
"@vitest/coverage-c8": "^0.28.2",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/test-utils": "^2.2.7",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.12",
"eslint": "^8.22.0",
"eslint-plugin-vue": "^9.3.0",
"jsdom": "^21.1.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.18",
"prettier": "^2.7.1",
"tailwindcss": "^3.2.1",
"typescript": "~5.8.2",
"vite": "^3.1.8",
"vitest": "^0.28.1",
"vue-tsc": "^2.2.8"
}
}

View File

@@ -1,5 +1,6 @@
export default {
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,26 +1,34 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import TopBar from '@/components/TopBar/TopBar.vue';
import SpinnerComponent from '@/components/ui/SpinnerComponent.vue';
import ToasterComponent from '@/components/ui/ToasterComponent.vue';
import VersionFooter from '@/components/ui/VersionFooter.vue';
import { init, useOnboard } from '@web3-onboard/vue';
import injectedModule from '@web3-onboard/injected-wallets';
import { Networks, DEFAULT_NETWORK } from '@/config/networks';
import { ref } from 'vue';
import { useRoute } from "vue-router";
import TopBar from "@/components/TopBar/TopBar.vue";
import SpinnerComponent from "@/components/SpinnerComponent.vue";
import ToasterComponent from "@/components/ToasterComponent.vue";
import { init, useOnboard } from "@web3-onboard/vue";
import injectedModule from "@web3-onboard/injected-wallets";
import { Networks } from "./model/Networks";
import { NetworkEnum } from "./model/NetworkEnum";
import { ref } from "vue";
const route = useRoute();
const injected = injectedModule();
const targetNetwork = ref(DEFAULT_NETWORK);
const targetNetwork = ref(NetworkEnum.sepolia);
const web3Onboard = init({
wallets: [injected],
chains: Object.values(Networks).map((network) => ({
id: `0x${network.id.toString(16)}`,
token: network.nativeCurrency.symbol,
label: network.name,
rpcUrl: network.rpcUrls.default.http[0],
})),
chains: [
{
id: Networks[NetworkEnum.sepolia].chainId,
token: "ETH",
label: "Sepolia",
rpcUrl: import.meta.env.VITE_SEPOLIA_API_URL,
},
{
id: Networks[NetworkEnum.rootstock].chainId,
token: "tRBTC",
label: "Rootstock Testnet",
rpcUrl: import.meta.env.VITE_ROOTSTOCK_API_URL,
},
],
connect: {
autoConnectLastWallet: true,
},
@@ -33,7 +41,7 @@ if (!connectedWallet) {
</script>
<template>
<main class="p-3 sm:p-4 md:p-8">
<div class="p-3 sm:p-4 md:p-8">
<TopBar />
<RouterView v-slot="{ Component }">
<template v-if="Component">
@@ -54,6 +62,5 @@ if (!connectedWallet) {
</template>
</RouterView>
<ToasterComponent :targetNetwork="targetNetwork" />
<VersionFooter />
</main>
</div>
</template>

View File

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 644 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,5 +1,7 @@
@import "./base.css" layer(base);
@import "tailwindcss";
@import './base.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
#app {
width: 100%;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 79 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,98 @@
import { expectTypeOf, it, expect } from "vitest";
import {
getTokenAddress,
getP2PixAddress,
getProviderUrl,
isPossibleNetwork,
} from "../addresses";
import { setActivePinia, createPinia } from "pinia";
import { NetworkEnum, TokenEnum } from "@/model/NetworkEnum";
import { useUser } from "@/composables/useUser";
describe("addresses.ts types", () => {
it("My addresses.ts types work properly", () => {
expectTypeOf(getTokenAddress).toBeFunction();
expectTypeOf(getP2PixAddress).toBeFunction();
expectTypeOf(getProviderUrl).toBeFunction();
expectTypeOf(isPossibleNetwork).toBeFunction();
});
});
describe("addresses.ts functions", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it("getTokenAddress Ethereum", () => {
const user = useUser();
user.setNetworkId(11155111);
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0x4A2886EAEc931e04297ed336Cc55c4eb7C75BA00"
);
});
it("getTokenAddress Rootstock", () => {
const user = useUser();
user.setNetworkId(30);
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0xfE841c74250e57640390f46d914C88d22C51e82e"
);
});
it("getTokenAddress Default", () => {
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0x4A2886EAEc931e04297ed336Cc55c4eb7C75BA00"
);
});
it("getP2PixAddress Ethereum", () => {
const user = useUser();
user.setNetworkId(11155111);
expect(getP2PixAddress()).toBe(
"0x2414817FF64A114d91eCFA16a834d3fCf69103d4"
);
});
it("getP2PixAddress Rootstock", () => {
const user = useUser();
user.setNetworkId(30);
expect(getP2PixAddress()).toBe(
"0x98ba35eb14b38D6Aa709338283af3e922476dE34"
);
});
it("getP2PixAddress Default", () => {
expect(getP2PixAddress()).toBe(
"0x2414817FF64A114d91eCFA16a834d3fCf69103d4"
);
});
it("getProviderUrl Ethereum", () => {
const user = useUser();
user.setNetworkId(11155111);
expect(getProviderUrl()).toBe(import.meta.env.VITE_GOERLI_API_URL);
});
it("getProviderUrl Rootstock", () => {
const user = useUser();
user.setNetworkId(30);
expect(getProviderUrl()).toBe(import.meta.env.VITE_ROOTSTOCK_API_URL);
});
it("getProviderUrl Default", () => {
expect(getProviderUrl()).toBe(import.meta.env.VITE_GOERLI_API_URL);
});
it("isPossibleNetwork Returns", () => {
const user = useUser();
user.setNetworkId(11155111);
expect(isPossibleNetwork(0x5 as NetworkEnum)).toBe(true);
expect(isPossibleNetwork(5 as NetworkEnum)).toBe(true);
expect(isPossibleNetwork(0x13881 as NetworkEnum)).toBe(true);
expect(isPossibleNetwork(80001 as NetworkEnum)).toBe(true);
expect(isPossibleNetwork(NaN as NetworkEnum)).toBe(false);
expect(isPossibleNetwork(0x55 as NetworkEnum)).toBe(false);
});
});

View File

@@ -0,0 +1,67 @@
import { useUser } from "@/composables/useUser";
import { NetworkEnum, TokenEnum } from "@/model/NetworkEnum";
import { createPublicClient, http } from "viem";
import { sepolia, rootstock } from "viem/chains";
const Tokens: { [key in NetworkEnum]: { [key in TokenEnum]: `0x${string}` } } = {
[NetworkEnum.sepolia]: {
BRZ: "0x3eBE67A2C7bdB2081CBd34ba3281E90377462289",
// BRX: "0x3eBE67A2C7bdB2081CBd34ba3281E90377462289",
},
[NetworkEnum.rootstock]: {
BRZ: "0xfE841c74250e57640390f46d914C88d22C51e82e",
// BRX: "0xfE841c74250e57640390f46d914C88d22C51e82e",
},
};
export const getTokenByAddress = (address: `0x${string}`) => {
const user = useUser();
const networksTokens = Tokens[user.networkName.value];
for (const [token, tokenAddress] of Object.entries(networksTokens)) {
if (tokenAddress.toLowerCase() === address.toLowerCase()) {
return token;
}
}
return null;
};
export const getTokenAddress = (
token: TokenEnum,
network?: NetworkEnum
): `0x${string}` => {
const user = useUser();
return Tokens[network ? network : user.networkName.value][token];
};
export const getP2PixAddress = (network?: NetworkEnum): `0x${string}` => {
const user = useUser();
const possibleP2PixAddresses: { [key in NetworkEnum]: `0x${string}` } = {
[NetworkEnum.sepolia]: "0xb7cD135F5eFD9760981e02E2a898790b688939fe",
[NetworkEnum.rootstock]: "0x98ba35eb14b38D6Aa709338283af3e922476dE34",
};
return possibleP2PixAddresses[network ? network : user.networkName.value];
};
export const getProviderUrl = (network?: NetworkEnum): string => {
const user = useUser();
const possibleProvidersUrls: { [key in NetworkEnum]: string } = {
[NetworkEnum.sepolia]: import.meta.env.VITE_SEPOLIA_API_URL,
[NetworkEnum.rootstock]: import.meta.env.VITE_RSK_API_URL,
};
return possibleProvidersUrls[network || user.networkName.value];
};
export const getProviderByNetwork = (network: NetworkEnum) => {
console.log("network", network);
const chain = network === NetworkEnum.sepolia ? sepolia : rootstock;
return createPublicClient({
chain,
transport: http(getProviderUrl(network)),
});
};
export const isPossibleNetwork = (networkChain: NetworkEnum): boolean => {
return Number(networkChain) in NetworkEnum;
};

View File

@@ -1,78 +1,86 @@
import { getContract } from './provider';
import { ChainContract } from 'viem';
import { parseEther, type Address, type TransactionReceipt } from 'viem';
import { getContract } from "./provider";
import { getTokenAddress } from "./addresses";
import { parseEther } from "viem";
import type { TokenEnum } from "@/model/NetworkEnum";
export const addLock = async (
sellerAddress: Address,
tokenAddress: Address,
amount: number,
): Promise<bigint> => {
sellerAddress: string,
tokenAddress: string,
amount: number
): Promise<string> => {
const { address, abi, wallet, client, account } = await getContract();
const parsedAmount = parseEther(amount.toString());
if (!wallet) {
throw new Error('Wallet not connected');
}
const { result, request } = await client.simulateContract({
const { request } = await client.simulateContract({
address,
abi,
functionName: 'lock',
functionName: "lock",
args: [sellerAddress, tokenAddress, parsedAmount, [], []],
account,
});
console.log(wallet);
if (!wallet) {
throw new Error("Wallet is undefined");
}
const hash = await wallet.writeContract(request);
const receipt = await client.waitForTransactionReceipt({ hash });
if (!receipt.status)
throw new Error('Transaction failed: ' + receipt.transactionHash);
throw new Error("Transaction failed: " + receipt.transactionHash);
return result;
const topic = receipt.logs[0]?.topics[2];
if (!topic) {
throw new Error("Topic is undefined");
}
return topic;
};
export const withdrawDeposit = async (
amount: string,
token: Address,
token: TokenEnum
): Promise<boolean> => {
const { address, abi, wallet, client, account } = await getContract();
if (!wallet) {
throw new Error('Wallet not connected');
}
const tokenAddress = getTokenAddress(token);
const { request } = await client.simulateContract({
address,
abi,
functionName: 'withdraw',
args: [token, parseEther(amount), []],
account,
functionName: "withdraw",
args: [tokenAddress, parseEther(amount), []],
account
});
if (!wallet) {
throw new Error("Wallet is undefined");
}
const hash = await wallet.writeContract(request);
const receipt = await client.waitForTransactionReceipt({ hash });
return receipt.status === 'success';
if ( receipt.status == "success"){
return true;
} else {
return false;
}
};
export const releaseLock = async (
lockID: bigint,
pixTimestamp: `0x${string}` & { lenght: 34 },
signature: `0x${string}`,
): Promise<TransactionReceipt> => {
export const releaseLock = async (solicitation: any): Promise<any> => {
const { address, abi, wallet, client, account } = await getContract();
if (!wallet) {
throw new Error('Wallet not connected');
}
const { request } = await client.simulateContract({
address,
abi,
functionName: 'release',
args: [BigInt(lockID), pixTimestamp, signature],
account,
functionName: "release",
args: [solicitation.lockId, solicitation.e2eId],
account
});
if (!wallet) {
throw new Error("Wallet is undefined");
}
const hash = await wallet.writeContract(request);
return client.waitForTransactionReceipt({ hash });
};

View File

@@ -1,13 +1,35 @@
import { useUser } from '@/composables/useUser';
import { formatEther, toHex, stringToHex } from 'viem';
import type { PublicClient, Address } from 'viem';
import { Networks } from '@/config/networks';
import { getContract } from './provider';
import { p2PixAbi } from './abi';
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { NetworkConfig } from '@/model/NetworkEnum';
import type { UnreleasedLock } from '@/model/UnreleasedLock';
import { ChainContract } from 'viem';
import { useUser } from "@/composables/useUser";
import { formatEther, toHex, type PublicClient, type ByteArray } from "viem";
import p2pix from "@/utils/smart_contract_files/P2PIX.json";
import { getContract } from "./provider";
import type { ValidDeposit } from "@/model/ValidDeposit";
import { getP2PixAddress, getTokenAddress } from "./addresses";
import { getNetworkSubgraphURL, NetworkEnum } from "@/model/NetworkEnum";
import type { UnreleasedLock } from "@/model/UnreleasedLock";
import type { Pix } from "@/model/Pix";
export interface Lock { // from DataTypes.sol
counter: bigint;
expirationBlock: bigint;
pixTarget: string;
amount: bigint;
token: `0x${string}`;
buyerAddress: `0x${string}`;
seller: `0x${string}`;
}
export enum LockStatus {
Inexistent = 0, // Uninitialized Lock
Active = 1, // Valid Lock
Expired = 2, // Expired Lock
Released = 3 // Already released Lock
}
export interface GetLocksStatusResponse {
sortedIDs: number[];
status: LockStatus[];
}
const getNetworksLiquidity = async (): Promise<void> => {
const user = useUser();
@@ -15,10 +37,12 @@ const getNetworksLiquidity = async (): Promise<void> => {
const depositLists: ValidDeposit[][] = [];
for (const network of Object.values(Networks)) {
for (const network of Object.values(NetworkEnum).filter(
(v) => !isNaN(Number(v))
)) {
const deposits = await getValidDeposits(
user.network.value.tokens[user.selectedToken.value].address,
network,
getTokenAddress(user.selectedToken.value),
Number(network)
);
if (deposits) depositLists.push(deposits);
}
@@ -28,49 +52,48 @@ const getNetworksLiquidity = async (): Promise<void> => {
user.setLoadingNetworkLiquidity(false);
};
const getParticipantID = async (
seller: Address,
token: Address,
): Promise<string> => {
const getPixKey = async (seller: string, token: string): Promise<string> => {
const { address, abi, client } = await getContract();
const participantIDHex = await client.readContract({
const pixKeyHex = await client.readContract({
address,
abi,
functionName: 'getPixTarget',
functionName: "getPixTarget",
args: [seller, token],
});
}) as ByteArray;
// Remove '0x' prefix and convert hex to UTF-8 string
const hexString =
typeof participantIDHex === 'string'
? participantIDHex
: toHex(participantIDHex as bigint);
if (!hexString) throw new Error('Participant ID not found');
typeof pixKeyHex === "string" ? pixKeyHex : toHex(pixKeyHex);
if (!hexString) throw new Error("PixKey not found");
const bytes = new Uint8Array(
// @ts-ignore
hexString
.slice(2)
.match(/.{1,2}/g)!
.map((byte: string) => parseInt(byte, 16)),
.match(/.{1,2}/g)
.map((byte: string) => parseInt(byte, 16))
);
// Remove null bytes from the end of the string
return new TextDecoder().decode(bytes).replace(/\0/g, '');
return new TextDecoder().decode(bytes).replace(/\0/g, "");
};
const getValidDeposits = async (
token: Address,
network: NetworkConfig,
contractInfo?: { client: PublicClient; address: Address },
token: string,
network: NetworkEnum,
contractInfo?: { client: any; address: string }
): Promise<ValidDeposit[]> => {
let client: PublicClient, abi;
let client: PublicClient, address, abi;
if (contractInfo) {
({ client } = contractInfo);
abi = p2PixAbi;
({ client, address } = contractInfo);
abi = p2pix.abi;
} else {
({ abi, client } = await getContract(true));
({ address, abi, client } = await getContract(true));
}
// TODO: Remove this once we have a subgraph for rootstock
if (network === NetworkEnum.rootstock) return [];
const body = {
query: `
{
@@ -84,27 +107,23 @@ const getValidDeposits = async (
`,
};
const depositLogs = await fetch(network.subgraphUrls[0], {
method: 'POST',
const depositLogs = await fetch(getNetworkSubgraphURL(network), {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// remove doubles from sellers list
const depositData = await depositLogs.json();
if (!depositData.data) {
console.error('Error fetching deposit logs');
return [];
}
const depositAddeds = depositData.data.depositAddeds;
const uniqueSellers = depositAddeds.reduce(
(acc: Record<Address, boolean>, deposit: any) => {
(acc: Record<string, boolean>, deposit: any) => {
acc[deposit.seller] = true;
return acc;
},
{} as Record<Address, boolean>,
{} as Record<string, boolean>
);
if (!contractInfo) {
@@ -114,12 +133,12 @@ const getValidDeposits = async (
const depositList: { [key: string]: ValidDeposit } = {};
const sellersList = Object.keys(uniqueSellers) as Address[];
const sellersList = Object.keys(uniqueSellers);
// Use multicall to batch all getBalance requests
const balanceCalls = sellersList.map((seller) => ({
address: (network.contracts?.p2pix as ChainContract).address,
address: getP2PixAddress(network),
abi,
functionName: 'getBalance',
functionName: "getBalance",
args: [seller, token],
}));
@@ -133,12 +152,12 @@ const getValidDeposits = async (
if (!mappedBalance.error && mappedBalance.result) {
const validDeposit: ValidDeposit = {
token,
token: token,
blockNumber: 0,
remaining: Number(formatEther(mappedBalance.result as bigint)),
seller,
seller: seller,
network,
participantID: '',
pixKey: "",
};
depositList[seller + token] = validDeposit;
}
@@ -147,22 +166,28 @@ const getValidDeposits = async (
};
const getUnreleasedLockById = async (
lockID: bigint,
lockID: string
): Promise<UnreleasedLock> => {
const { address, abi, client } = await getContract();
const pixData: Pix = {
pixKey: "",
};
const [, , , amount, token, seller] = await client.readContract({
const lock = await client.readContract({
address,
abi,
functionName: 'mapLocks',
args: [lockID],
});
functionName: "mapLocks",
args: [BigInt(lockID)],
}) as Lock;
const pixTarget = lock.pixTarget;
const amount = formatEther(lock.amount);
pixData.pixKey = pixTarget;
pixData.value = Number(amount);
return {
lockID,
amount: Number(formatEther(amount)),
tokenAddress: token,
sellerAddress: seller,
lockID: lockID,
pix: pixData,
};
};
@@ -170,5 +195,5 @@ export {
getValidDeposits,
getNetworksLiquidity,
getUnreleasedLockById,
getParticipantID,
getPixKey,
};

View File

@@ -1,5 +1,6 @@
import { p2PixAbi } from './abi';
import { updateWalletStatus } from './wallet';
import p2pix from "@/utils/smart_contract_files/P2PIX.json";
import { updateWalletStatus } from "./wallet";
import { getProviderUrl, getP2PixAddress } from "./addresses";
import {
createPublicClient,
createWalletClient,
@@ -7,20 +8,18 @@ import {
http,
PublicClient,
WalletClient,
} from 'viem';
import { useUser } from '@/composables/useUser';
import type { NetworkConfig } from '@/model/NetworkEnum';
import type { ChainContract } from 'viem';
} from "viem";
import { sepolia, rootstock } from "viem/chains";
import { useUser } from "@/composables/useUser";
let publicClient: PublicClient | null = null;
let walletClient: WalletClient | null = null;
const getPublicClient = (): PublicClient => {
const user = useUser();
const rpcUrl = (user.network.value as NetworkConfig).rpcUrls.default.http[0];
const chain = user.network.value;
const rpcUrl = getProviderUrl();
return createPublicClient({
chain,
chain:
Number(user.networkName.value) === sepolia.id ? sepolia : rootstock,
transport: http(rpcUrl),
});
};
@@ -31,15 +30,12 @@ const getWalletClient = (): WalletClient | null => {
const getContract = async (onlyRpcProvider = false) => {
const client = getPublicClient();
const user = useUser();
const address = (user.network.value.contracts?.p2pix as ChainContract)
.address;
const abi = p2PixAbi;
const wallet = onlyRpcProvider ? null : getWalletClient();
if (!client) {
throw new Error('Public client not initialized');
throw new Error("Public client is not initialized");
}
const address = getP2PixAddress();
const abi = p2pix.abi;
const wallet = getWalletClient();
const [account] = wallet ? await wallet.getAddresses() : [null];
@@ -47,10 +43,17 @@ const getContract = async (onlyRpcProvider = false) => {
};
const connectProvider = async (p: any): Promise<void> => {
console.log("Connecting to wallet provider...");
const user = useUser();
const chain = user.network.value;
const chain =
Number(user.networkName.value) === sepolia.id ? sepolia : rootstock;
const [account] = await p!.request({ method: 'eth_requestAccounts' });
publicClient = createPublicClient({
chain,
transport: custom(p),
});
const [account] = await p!.request({ method: "eth_requestAccounts" });
walletClient = createWalletClient({
account,

View File

@@ -1,15 +1,11 @@
import { getContract, getPublicClient, getWalletClient } from './provider';
import { parseEther, toHex, ChainContract } from 'viem';
import { mockTokenAbi } from './abi';
import { useUser } from '@/composables/useUser';
import { createParticipant } from '@/utils/bbPay';
import type { Participant } from '@/utils/bbPay';
import type { Address } from 'viem';
import { getContract, getPublicClient, getWalletClient } from "./provider";
import { getTokenAddress, getP2PixAddress } from "./addresses";
import { parseEther, toHex } from "viem";
const getP2PixAddress = (): Address => {
const user = useUser();
return (user.network.value.contracts?.p2pix as ChainContract).address;
};
import mockToken from "../utils/smart_contract_files/MockToken.json";
import { useUser } from "@/composables/useUser";
import { createParticipant } from "@/utils/bbPay";
import type { Participant } from "@/utils/bbPay";
const approveTokens = async (participant: Participant): Promise<any> => {
const user = useUser();
@@ -17,36 +13,34 @@ const approveTokens = async (participant: Participant): Promise<any> => {
const walletClient = getWalletClient();
if (!publicClient || !walletClient) {
throw new Error('Clients not initialized');
throw new Error("Clients not initialized");
}
user.setSeller(participant);
const [account] = await walletClient.getAddresses();
// Get token address
const tokenAddress =
user.network.value.tokens[user.selectedToken.value].address;
const tokenAddress = getTokenAddress(user.selectedToken.value);
// Check if the token is already approved
const allowance = await publicClient.readContract({
address: tokenAddress,
abi: mockTokenAbi,
functionName: 'allowance',
abi: mockToken.abi,
functionName: "allowance",
args: [account, getP2PixAddress()],
});
}) as bigint;
if (allowance < parseEther(participant.offer.toString())) {
// Approve tokens
const chain = user.network.value;
const hash = await walletClient.writeContract({
const { result } = await publicClient.simulateContract({
address: tokenAddress,
abi: mockTokenAbi,
functionName: 'approve',
abi: mockToken.abi,
functionName: "approve",
args: [getP2PixAddress(), parseEther(participant.offer.toString())],
account,
chain,
account
});
const hash = await walletClient.writeContract(result);
await publicClient.waitForTransactionReceipt({ hash });
return true;
}
@@ -55,36 +49,34 @@ const approveTokens = async (participant: Participant): Promise<any> => {
const addDeposit = async (): Promise<any> => {
const { address, abi, client } = await getContract();
const publicClient = getPublicClient();
const walletClient = getWalletClient();
const user = useUser();
if (!walletClient) {
throw new Error('Wallet client not initialized');
throw new Error("Wallet client not initialized");
}
const [account] = await walletClient.getAddresses();
const sellerId = await createParticipant(user.seller.value);
user.setSellerId(sellerId.id);
if (!sellerId.id) {
throw new Error('Failed to create participant');
}
const chain = user.network.value;
const hash = await walletClient.writeContract({
const { result } = await publicClient.simulateContract({
address,
abi,
functionName: 'deposit',
functionName: "deposit",
args: [
user.network.value.id + '-' + sellerId.id,
toHex('', { size: 32 }),
user.network.value.tokens[user.selectedToken.value].address,
user.networkId + "-" + sellerId.id,
toHex("", { size: 32 }),
getTokenAddress(user.selectedToken.value),
parseEther(user.seller.value.offer.toString()),
true,
],
account,
chain,
});
const hash = await walletClient.writeContract(result);
const receipt = await client.waitForTransactionReceipt({ hash });
return receipt;
};

View File

@@ -1,14 +1,18 @@
import { formatEther, type Address } from 'viem';
import { useUser } from '@/composables/useUser';
import { decodeEventLog, formatEther, type Log } from "viem";
import { useUser } from "@/composables/useUser";
import { getPublicClient, getWalletClient, getContract } from './provider';
import { getPublicClient, getWalletClient, getContract } from "./provider";
import { getTokenAddress } from "./addresses";
import { getValidDeposits, getUnreleasedLockById } from './events';
import p2pix from "@/utils/smart_contract_files/P2PIX.json";
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { WalletTransaction } from '@/model/WalletTransaction';
import type { UnreleasedLock } from '@/model/UnreleasedLock';
import { LockStatus } from '@/model/LockStatus';
import { getValidDeposits, Lock, LockStatus, GetLocksStatusResponse } from "./events";
import type { ValidDeposit } from "@/model/ValidDeposit";
import type { WalletTransaction } from "@/model/WalletTransaction";
import type { UnreleasedLock } from "@/model/UnreleasedLock";
import type { Pix } from "@/model/Pix";
import { getNetworkSubgraphURL } from "@/model/NetworkEnum";
export const updateWalletStatus = async (): Promise<void> => {
const user = useUser();
@@ -17,7 +21,7 @@ export const updateWalletStatus = async (): Promise<void> => {
const walletClient = getWalletClient();
if (!publicClient || !walletClient) {
console.error('Client not initialized');
console.error("Client not initialized");
return;
}
@@ -30,12 +34,12 @@ export const updateWalletStatus = async (): Promise<void> => {
};
export const listValidDepositTransactionsByWalletAddress = async (
walletAddress: Address,
walletAddress: string
): Promise<ValidDeposit[]> => {
const user = useUser();
const walletDeposits = await getValidDeposits(
user.network.value.tokens[user.selectedToken.value].address,
user.network.value,
getTokenAddress(user.selectedToken.value),
user.networkName.value
);
if (walletDeposits) {
return walletDeposits
@@ -50,29 +54,70 @@ export const listValidDepositTransactionsByWalletAddress = async (
const getLockStatus = async (id: bigint): Promise<LockStatus> => {
const { address, abi, client } = await getContract();
const [sortedIDs, status] = await client.readContract({
const { sortedIDs , status } = await client.readContract({
address,
abi,
functionName: 'getLocksStatus',
functionName: "getLocksStatus",
args: [[id]],
});
}) as GetLocksStatusResponse;
return status[0];
};
const filterLockStatus = async (
transactions: Log[]
): Promise<WalletTransaction[]> => {
const txs: WalletTransaction[] = [];
for (const transaction of transactions) {
try {
const decoded = decodeEventLog({
abi: p2pix.abi,
data: transaction.data,
topics: transaction.topics,
});
if (!decoded || !decoded.args) continue;
// Type assertion to handle the args safely
const args = decoded.args as Record<string, any>;
const tx: WalletTransaction = {
token: args.token ? String(args.token) : "",
blockNumber: Number(transaction.blockNumber),
amount: args.amount ? Number(formatEther(args.amount)) : -1,
seller: args.seller ? String(args.seller) : "",
buyer: args.buyer ? String(args.buyer) : "",
event: decoded.eventName || "",
lockStatus:
decoded.eventName == "LockAdded" && args.lockID
? await getLockStatus(args.lockID)
: -1,
transactionHash: transaction.transactionHash
? transaction.transactionHash
: "",
transactionID: args.lockID ? args.lockID.toString() : "",
};
txs.push(tx);
} catch (error) {
console.error("Error decoding log", error);
}
}
return txs;
};
export const listAllTransactionByWalletAddress = async (
walletAddress: Address,
walletAddress: string
): Promise<WalletTransaction[]> => {
const user = useUser();
// Get the current network for the subgraph URL
const network = user.network.value;
const network = user.networkName.value;
// Query subgraph for all relevant transactions
const subgraphQuery = {
query: `
{
depositAddeds(where: {seller: "${walletAddress.toLowerCase()}"}) {
id
seller
token
amount
@@ -84,6 +129,7 @@ export const listAllTransactionByWalletAddress = async (
buyer
lockID
seller
token
amount
blockTimestamp
blockNumber
@@ -92,6 +138,7 @@ export const listAllTransactionByWalletAddress = async (
lockReleaseds(where: {buyer: "${walletAddress.toLowerCase()}"}) {
buyer
lockId
e2eId
blockTimestamp
blockNumber
transactionHash
@@ -108,30 +155,33 @@ export const listAllTransactionByWalletAddress = async (
`,
};
const response = await fetch(network.subgraphUrls[0], {
method: 'POST',
console.log("Fetching transactions from subgraph");
const response = await fetch(getNetworkSubgraphURL(network), {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(subgraphQuery),
});
const data = await response.json();
console.log("Subgraph data fetched:", data);
// Convert all transactions to common WalletTransaction format
const transactions: WalletTransaction[] = [];
// Process deposit added events
if (data.data?.depositAddeds) {
console.log("Processing deposit events");
for (const deposit of data.data.depositAddeds) {
transactions.push({
token: deposit.token,
blockNumber: parseInt(deposit.blockNumber),
blockTimestamp: parseInt(deposit.blockTimestamp),
amount: parseFloat(formatEther(BigInt(deposit.amount))),
seller: deposit.seller,
buyer: '',
event: 'DepositAdded',
lockStatus: undefined,
buyer: "",
event: "DepositAdded",
lockStatus: -1,
transactionHash: deposit.transactionHash,
});
}
@@ -139,6 +189,7 @@ export const listAllTransactionByWalletAddress = async (
// Process lock added events
if (data.data?.lockAddeds) {
console.log("Processing lock events");
for (const lock of data.data.lockAddeds) {
// Get lock status from the contract
const lockStatus = await getLockStatus(BigInt(lock.lockID));
@@ -146,11 +197,10 @@ export const listAllTransactionByWalletAddress = async (
transactions.push({
token: lock.token,
blockNumber: parseInt(lock.blockNumber),
blockTimestamp: parseInt(lock.blockTimestamp),
amount: parseFloat(formatEther(BigInt(lock.amount))),
seller: lock.seller,
buyer: lock.buyer,
event: 'LockAdded',
event: "LockAdded",
lockStatus: lockStatus,
transactionHash: lock.transactionHash,
transactionID: lock.lockID.toString(),
@@ -160,16 +210,16 @@ export const listAllTransactionByWalletAddress = async (
// Process lock released events
if (data.data?.lockReleaseds) {
console.log("Processing release events");
for (const release of data.data.lockReleaseds) {
transactions.push({
token: undefined, // Subgraph doesn't provide token in this event, we could enhance this later
token: "", // Subgraph doesn't provide token in this event, we could enhance this later
blockNumber: parseInt(release.blockNumber),
blockTimestamp: parseInt(release.blockTimestamp),
amount: -1, // Amount not available in this event
seller: '',
seller: "",
buyer: release.buyer,
event: 'LockReleased',
lockStatus: undefined,
event: "LockReleased",
lockStatus: -1,
transactionHash: release.transactionHash,
transactionID: release.lockId.toString(),
});
@@ -178,16 +228,16 @@ export const listAllTransactionByWalletAddress = async (
// Process deposit withdrawn events
if (data.data?.depositWithdrawns) {
console.log("Processing withdrawal events");
for (const withdrawal of data.data.depositWithdrawns) {
transactions.push({
token: withdrawal.token,
blockNumber: parseInt(withdrawal.blockNumber),
blockTimestamp: parseInt(withdrawal.blockTimestamp),
amount: parseFloat(formatEther(BigInt(withdrawal.amount))),
seller: withdrawal.seller,
buyer: '',
event: 'DepositWithdrawn',
lockStatus: undefined,
buyer: "",
event: "DepositWithdrawn",
lockStatus: -1,
transactionHash: withdrawal.transactionHash,
});
}
@@ -197,9 +247,75 @@ export const listAllTransactionByWalletAddress = async (
return transactions.sort((a, b) => b.blockNumber - a.blockNumber);
};
const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
// get wallet's release transactions
export const listReleaseTransactionByWalletAddress = async (
walletAddress: string
) => {
const user = useUser();
const network = user.network.value;
const network = user.networkName.value;
// Query subgraph for release transactions
const subgraphQuery = {
query: `
{
lockReleaseds(where: {buyer: "${walletAddress.toLowerCase()}"}) {
buyer
lockId
e2eId
blockTimestamp
blockNumber
transactionHash
}
}
`,
};
// Fetch data from subgraph
const response = await fetch(getNetworkSubgraphURL(network), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subgraphQuery),
});
const data = await response.json();
// Process the subgraph response into the same format as the previous implementation
if (!data.data?.lockReleaseds) {
return [];
}
// Transform the subgraph data to match the event log decode format
return data.data.lockReleaseds
.sort((a: any, b: any) => {
return parseInt(b.blockNumber) - parseInt(a.blockNumber);
})
.map((release: any) => {
try {
// Create a structure similar to the decoded event log
return {
eventName: "LockReleased",
args: {
buyer: release.buyer,
lockID: BigInt(release.lockId),
e2eId: release.e2eId,
},
// Add any other necessary fields to match the original return format
blockNumber: BigInt(release.blockNumber),
transactionHash: release.transactionHash,
};
} catch (error) {
console.error("Error processing subgraph data", error);
return null;
}
})
.filter((decoded: any) => decoded !== null);
};
const listLockTransactionByWalletAddress = async (walletAddress: string) => {
const user = useUser();
const network = user.networkName.value;
// Query subgraph for lock added transactions
const subgraphQuery = {
@@ -209,6 +325,7 @@ const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
buyer
lockID
seller
token
amount
blockTimestamp
blockNumber
@@ -220,10 +337,10 @@ const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
try {
// Fetch data from subgraph
const response = await fetch(network.subgraphUrls[0], {
method: 'POST',
const response = await fetch(getNetworkSubgraphURL(network), {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(subgraphQuery),
});
@@ -243,12 +360,12 @@ const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
try {
// Create a structure similar to the decoded event log
return {
eventName: 'LockAdded',
eventName: "LockAdded",
args: {
buyer: lock.buyer,
lockID: BigInt(lock.lockID),
seller: lock.seller,
token: undefined, // Token not available in LockAdded subgraph event
token: lock.token,
amount: BigInt(lock.amount),
},
// Add other necessary fields to match the original format
@@ -256,19 +373,20 @@ const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
transactionHash: lock.transactionHash,
};
} catch (error) {
console.error('Error processing subgraph data', error);
console.error("Error processing subgraph data", error);
return null;
}
})
.filter((decoded: any) => decoded !== null);
} catch (error) {
console.error('Error fetching from subgraph:', error);
console.error("Error fetching from subgraph:", error);
}
};
const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
const listLockTransactionBySellerAddress = async (sellerAddress: string) => {
const user = useUser();
const network = user.network.value;
const network = user.networkName.value;
console.log("Will get locks as seller", sellerAddress);
// Query subgraph for lock added transactions where seller matches
const subgraphQuery = {
@@ -278,6 +396,7 @@ const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
buyer
lockID
seller
token
amount
blockTimestamp
blockNumber
@@ -289,10 +408,10 @@ const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
try {
// Fetch data from subgraph
const response = await fetch(network.subgraphUrls[0], {
method: 'POST',
const response = await fetch(getNetworkSubgraphURL(network), {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(subgraphQuery),
});
@@ -312,12 +431,12 @@ const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
try {
// Create a structure similar to the decoded event log
return {
eventName: 'LockAdded',
eventName: "LockAdded",
args: {
buyer: lock.buyer,
lockID: BigInt(lock.lockID),
seller: lock.seller,
token: undefined, // Token not available in LockAdded subgraph event
token: lock.token,
amount: BigInt(lock.amount),
},
// Add other necessary fields to match the original format
@@ -325,44 +444,66 @@ const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
transactionHash: lock.transactionHash,
};
} catch (error) {
console.error('Error processing subgraph data', error);
console.error("Error processing subgraph data", error);
return null;
}
})
.filter((decoded: any) => decoded !== null);
} catch (error) {
console.error('Error fetching from subgraph:', error);
console.error("Error fetching from subgraph:", error);
return [];
}
};
export const checkUnreleasedLock = async (
walletAddress: Address,
walletAddress: string
): Promise<UnreleasedLock | undefined> => {
const { address, abi, client } = await getContract();
const pixData: Pix = {
pixKey: "",
};
const addedLocks = await listLockTransactionByWalletAddress(walletAddress);
if (!addedLocks.length) return undefined;
const lockIds = addedLocks.map((lock: any) => lock.args.lockID);
const [sortedIDs, status] = await client.readContract({
const { sortedIDs, status: lockStatus } = await client.readContract({
address,
abi,
functionName: 'getLocksStatus',
functionName: "getLocksStatus",
args: [lockIds],
});
}) as GetLocksStatusResponse;
const unreleasedLockId = status.findIndex(
(status: LockStatus) => status == LockStatus.Active,
const unreleasedLockId = lockStatus[1].findIndex(
(status: number) => status == 1
);
if (unreleasedLockId !== -1)
return getUnreleasedLockById(sortedIDs[unreleasedLockId]);
if (unreleasedLockId !== -1) {
const lockID = lockStatus[0][unreleasedLockId];
const lock = await client.readContract({
address,
abi,
functionName: "mapLocks",
args: [lockID],
}) as Lock;
const pixTarget = lock.pixTarget;
const amount = formatEther(lock.amount);
pixData.pixKey = pixTarget;
pixData.value = Number(amount);
return {
lockID,
pix: pixData,
};
}
};
export const getActiveLockAmount = async (
walletAddress: Address,
walletAddress: string
): Promise<number> => {
const { address, abi, client } = await getContract(true);
const lockSeller = await listLockTransactionBySellerAddress(walletAddress);
@@ -371,20 +512,20 @@ export const getActiveLockAmount = async (
const lockIds = lockSeller.map((lock: any) => lock.args.lockID);
const [sortedIDs, status] = await client.readContract({
const { sortedIDs, status: lockStatus } = await client.readContract({
address,
abi,
functionName: 'getLocksStatus',
functionName: "getLocksStatus",
args: [lockIds],
});
}) as GetLocksStatusResponse;
const mapLocksRequests = status.map((id: LockStatus) =>
const mapLocksRequests = lockStatus[0].map((id: bigint) =>
client.readContract({
address: address,
address,
abi,
functionName: 'mapLocks',
args: [BigInt(id)],
}),
functionName: "mapLocks",
args: [id],
})
);
const mapLocksResults = await client.multicall({
@@ -392,7 +533,7 @@ export const getActiveLockAmount = async (
});
return mapLocksResults.reduce((total: number, lock: any, index: number) => {
if (status[index] === 1) {
if (lockStatus[1][index] === 1) {
return total + Number(formatEther(lock.amount));
}
return total;

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { withdrawDeposit } from '@/blockchain/buyerMethods';
import { withdrawDeposit } from "@/blockchain/buyerMethods";
import {
getActiveLockAmount,
listAllTransactionByWalletAddress,
listValidDepositTransactionsByWalletAddress,
} from '@/blockchain/wallet';
import CustomButton from '@/components/ui/CustomButton.vue';
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { WalletTransaction } from '@/model/WalletTransaction';
import { useUser } from '@/composables/useUser';
import { onMounted, ref, watch } from 'vue';
import ListingComponent from '@/components/ListingComponent/ListingComponent.vue';
} from "@/blockchain/wallet";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import type { ValidDeposit } from "@/model/ValidDeposit";
import type { WalletTransaction } from "@/model/WalletTransaction";
import { useUser } from "@/composables/useUser";
import { onMounted, ref, watch } from "vue";
import ListingComponent from "../ListingComponent/ListingComponent.vue";
// props
const props = defineProps<{
@@ -31,11 +31,11 @@ const getWalletTransactions = async () => {
user.setLoadingWalletTransactions(true);
if (walletAddress.value) {
const walletDeposits = await listValidDepositTransactionsByWalletAddress(
walletAddress.value,
walletAddress.value
);
const allUserTransactions = await listAllTransactionByWalletAddress(
walletAddress.value,
walletAddress.value
);
activeLockAmount.value = await getActiveLockAmount(walletAddress.value);
@@ -53,22 +53,19 @@ const getWalletTransactions = async () => {
const callWithdraw = async (amount: string) => {
if (amount) {
user.setLoadingWalletTransactions(true);
const withdraw = await withdrawDeposit(
amount,
user.network.value.tokens[user.selectedToken.value].address,
);
const withdraw = await withdrawDeposit(amount, user.selectedToken.value);
if (withdraw) {
console.log('Saque realizado!');
console.log("Saque realizado!");
await getWalletTransactions();
} else {
console.log('Não foi possível realizar o saque!');
console.log("Não foi possível realizar o saque!");
}
user.setLoadingWalletTransactions(false);
}
};
// Emits
const emit = defineEmits(['makeAnotherTransaction']);
const emit = defineEmits(["makeAnotherTransaction"]);
// observer
watch(props, async (): Promise<void> => {
@@ -89,7 +86,9 @@ onMounted(async () => {
</span>
</div>
<div class="main-container">
<div class="flex flex-col w-full bg-white px-10 py-5 rounded-lg">
<div
class="flex flex-col w-full bg-white px-10 py-5 rounded-lg border-y-10"
>
<div>
<p>Tokens recebidos</p>
<p class="text-2xl text-gray-900">
@@ -130,7 +129,6 @@ onMounted(async () => {
</template>
<style scoped>
@reference "tailwindcss";
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
@@ -151,8 +149,8 @@ p {
@apply font-medium text-base text-gray-900;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -0,0 +1,37 @@
import { mount } from "@vue/test-utils";
import BuyConfirmedComponent from "../BuyConfirmedComponent.vue";
import { createPinia, setActivePinia } from "pinia";
describe("BuyConfirmedComponent.vue", async () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const wrapper = mount(BuyConfirmedComponent, {
props: {
tokenAmount: 1,
isCurrentStep: false,
},
});
// test("Test component Header Text", () => {
// expect(wrapper.html()).toContain("Os tokens já foram transferidos");
// expect(wrapper.html()).toContain("para a sua carteira!");
// });
// test("Test component Container Text", () => {
// expect(wrapper.html()).toContain("Tokens recebidos");
// expect(wrapper.html()).toContain("BRZ");
// expect(wrapper.html()).toContain("Não encontrou os tokens?");
// expect(wrapper.html()).toContain("Clique no botão abaixo para");
// expect(wrapper.html()).toContain("cadastrar o BRZ em sua carteira.");
// });
test("Test makeAnotherTransactionEmit", async () => {
wrapper.vm.$emit("makeAnotherTransaction");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("makeAnotherTransaction")).toBeTruthy();
});
});

View File

@@ -1,291 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import CustomButton from '@/components/ui/CustomButton.vue';
import CustomModal from '@/components/ui/CustomModal.vue';
import SpinnerComponent from '@/components/ui/SpinnerComponent.vue';
import { createSolicitation, getSolicitation, type Offer } from '@/utils/bbPay';
import { getParticipantID } from '@/blockchain/events';
import { getUnreleasedLockById } from '@/blockchain/events';
import QRCode from 'qrcode';
// Props
interface Props {
lockID: string;
}
const props = defineProps<Props>();
const qrCode = ref<string>('');
const qrCodeSvg = ref<string>('');
const showWarnModal = ref<boolean>(true);
const pixTimestamp = ref<string>('');
const releaseSignature = ref<string>('');
const solicitationData = ref<any>(null);
const pollingInterval = ref<NodeJS.Timeout | null>(null);
const copyFeedback = ref<boolean>(false);
const copyFeedbackTimeout = ref<NodeJS.Timeout | null>(null);
// Function to generate QR code SVG
const generateQrCodeSvg = async (text: string) => {
try {
const svgString = await QRCode.toString(text, {
type: 'svg',
width: 192, // 48 * 4 for better quality
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
qrCodeSvg.value = svgString;
} catch (error) {
console.error('Error generating QR code SVG:', error);
}
};
// Emits
const emit = defineEmits(['pixValidated']);
// Function to check solicitation status
const checkSolicitationStatus = async () => {
if (!solicitationData.value?.numeroSolicitacao) {
return;
}
try {
const response = await getSolicitation(
solicitationData.value.numeroSolicitacao,
);
if (response.signature) {
pixTimestamp.value = response.pixTimestamp;
releaseSignature.value = response.signature;
// Stop polling when payment is confirmed
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
}
} catch (error) {
console.error('Error checking solicitation status:', error);
}
};
// Function to start polling
const startPolling = () => {
// Clear any existing interval
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
}
// Start new polling interval (10 seconds)
pollingInterval.value = setInterval(checkSolicitationStatus, 10000);
};
const copyToClipboard = async () => {
if (!qrCode.value) {
return;
}
try {
await navigator.clipboard.writeText(qrCode.value);
if (copyFeedbackTimeout.value) {
clearTimeout(copyFeedbackTimeout.value);
}
copyFeedback.value = true;
copyFeedbackTimeout.value = setTimeout(() => {
copyFeedback.value = false;
}, 2000);
} catch (error) {
console.error('Error copying to clipboard:', error);
}
};
onMounted(async () => {
try {
const { tokenAddress, sellerAddress, amount } = await getUnreleasedLockById(
BigInt(props.lockID),
);
const participantId = await getParticipantID(sellerAddress, tokenAddress);
const offer: Offer = {
amount,
sellerId: participantId,
};
const response = await createSolicitation(offer);
solicitationData.value = response;
// Update qrCode if the response contains QR code data
if (response?.informacoesPIX?.textoQrCode) {
qrCode.value = response.informacoesPIX?.textoQrCode;
// Generate SVG QR code
await generateQrCodeSvg(qrCode.value);
}
// Start polling for solicitation status
startPolling();
} catch (error) {
console.error('Error creating solicitation:', error);
}
});
// Clean up interval on component unmount
onUnmounted(() => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
if (copyFeedbackTimeout.value) {
clearTimeout(copyFeedbackTimeout.value);
copyFeedbackTimeout.value = null;
}
});
</script>
<template>
<div class="page">
<div class="text-container">
<span
class="text font-extrabold lg:text-2xl text-xl sm:max-w-[30rem] max-w-[24rem]"
>
Utilize o QR Code ou copie o código para realizar o Pix
</span>
<span class="text font-medium lg:text-md text-sm max-w-[28rem]">
Após realizar o Pix no banco de sua preferência, clique no botão abaixo
para liberação dos tokens.
</span>
</div>
<div class="main-container max-w-md text-black">
<div
class="flex-col items-center justify-center flex w-full bg-white sm:p-8 p-4 rounded-lg break-normal"
>
<div
v-if="qrCodeSvg"
v-html="qrCodeSvg"
class="w-48 h-48 flex items-center justify-center"
></div>
<div
v-else
class="w-48 h-48 flex items-center justify-center rounded-lg"
>
<SpinnerComponent width="8" height="8"></SpinnerComponent>
</div>
<span class="text-center font-bold">Código pix</span>
<div class="break-words w-4/5">
<span class="text-center text-xs">
{{ qrCode }}
</span>
</div>
<div class="flex flex-col items-center gap-1">
<img
alt="Copy PIX code"
src="@/assets/copyPix.svg?url"
width="16"
height="16"
class="pt-2 cursor-pointer hover:opacity-70 transition-opacity"
@click="copyToClipboard"
/>
<transition name="fade">
<span
v-if="copyFeedback"
class="text-xs text-emerald-500 font-semibold"
>
Código copiado!
</span>
</transition>
</div>
</div>
<CustomButton
:is-disabled="releaseSignature === ''"
:text="
releaseSignature ? 'Enviar para a rede' : 'Validando pagamento...'
"
@button-clicked="
emit('pixValidated', { pixTimestamp, signature: releaseSignature })
"
/>
</div>
<CustomModal
v-if="showWarnModal"
@close-modal="showWarnModal = false"
:isRedirectModal="false"
/>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
::placeholder {
/* Most modern browsers support this now. */
color: #9ca3af;
}
h4 {
color: #080808;
font-size: 14px;
}
h2 {
color: #080808;
}
.form-input {
@apply rounded-lg border border-gray-200 p-2 text-black;
}
.form-label {
@apply font-semibold tracking-wide text-emerald-50;
}
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
}
.bottom-position {
top: -20px;
right: 50%;
transform: translateX(50%);
}
.text-container {
@apply flex flex-col items-center justify-center gap-4;
}
.text {
@apply text-white text-center;
}
.blur-container {
@apply flex flex-col justify-center items-center px-8 py-6 gap-2 rounded-lg shadow-md shadow-gray-600 backdrop-blur-md mt-6 max-w-screen-sm;
}
input[type='number'] {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
}
/* Fade transition for copy feedback */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,36 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref } from "vue";
const props = defineProps<{
type: string;
}>();
const alertText = ref<string>('');
const alertPaddingLeft = ref<string>('18rem');
const alertText = ref<string>("");
const alertPaddingLeft = ref<string>("18rem");
if (props.type === 'sell') {
alertPaddingLeft.value = '30%';
} else if (props.type === 'buy') {
alertPaddingLeft.value = '30%';
} else if (props.type === 'withdraw') {
alertPaddingLeft.value = '40%';
} else if (props.type === 'redirect') {
alertPaddingLeft.value = '35%';
if (props.type === "sell") {
alertPaddingLeft.value = "30%";
} else if (props.type === "buy") {
alertPaddingLeft.value = "30%";
} else if (props.type === "withdraw") {
alertPaddingLeft.value = "40%";
} else if (props.type === "redirect") {
alertPaddingLeft.value = "35%";
}
switch (props.type) {
case 'buy':
case "buy":
alertText.value =
'Tudo certo! Os tokens já foram retirados da oferta e estão disponíveis na sua carteira.';
"Tudo certo! Os tokens já foram retirados da oferta e estão disponíveis na sua carteira.";
break;
case 'sell':
case "sell":
alertText.value =
'Tudo certo! Os tokens já foram reservados e sua oferta está disponível.';
"Tudo certo! Os tokens já foram reservados e sua oferta está disponível.";
break;
case 'redirect':
alertText.value = 'Existe uma compra em aberto. Continuar?';
case "redirect":
alertText.value = "Existe uma compra em aberto. Continuar?";
break;
case 'withdraw':
alertText.value = 'Tudo certo! Saque realizado com sucesso!';
case "withdraw":
alertText.value = "Tudo certo! Saque realizado com sucesso!";
break;
}
</script>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
const props = defineProps({
text: String,
isDisabled: Boolean,
});
const emit = defineEmits(["buttonClicked"]);
</script>
<template>
<button
type="button"
class="button"
@click="emit('buttonClicked')"
v-bind:class="{ 'opacity-70': props.isDisabled }"
:disabled="props.isDisabled ? props.isDisabled : false"
>
{{ props.text }}
</button>
</template>
<style scoped>
.button {
@apply rounded-lg w-full text-base font-semibold text-gray-900 p-4 bg-amber-400;
}
</style>

View File

@@ -0,0 +1,27 @@
import { mount } from "@vue/test-utils";
import CustomButton from "../CustomButton.vue";
describe("CustomButton.vue", () => {
test("Test button content", () => {
const wrapper = mount(CustomButton, {
props: {
text: "Testing",
},
});
expect(wrapper.html()).toContain("Testing");
});
test("Test if disabled props works", () => {
const wrapper = mount(CustomButton, {
props: {
isDisabled: true,
},
});
//@ts-ignore
const button = wrapper.find(".button") as HTMLButtonElement;
//@ts-ignore
expect(button.element.disabled).toBe(true);
});
});

View File

@@ -1,25 +1,25 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref } from "vue";
const props = defineProps({
isRedirectModal: Boolean,
});
const modalColor = ref<string>('white');
const modalHeight = ref<string>('250px');
const pFontSize = ref<string>('16px');
const modalColor = ref<string>("white");
const modalHeight = ref<string>("250px");
const pFontSize = ref<string>("16px");
if (props.isRedirectModal) {
modalColor.value = 'rgba(251, 191, 36, 1)';
modalHeight.value = '150px';
pFontSize.value = '20px';
modalColor.value = "rgba(251, 191, 36, 1)";
modalHeight.value = "150px";
pFontSize.value = "20px";
}
</script>
<template>
<div>
<div
class="modal-overlay inset-0 fixed hidden md:block justify-center backdrop-blur-sm sm:backdrop-blur-none"
class="modal-overlay inset-0 fixed justify-center backdrop-blur-sm sm:backdrop-blur-none"
v-if="!isRedirectModal"
>
<div class="modal px-5 text-center">

View File

@@ -0,0 +1,27 @@
import { mount } from "@vue/test-utils";
import CustomModal from "../CustomModal.vue";
describe("CustomModal test", () => {
test("Test custom modal when receive is redirect modal props as false", () => {
const wrapper = mount(CustomModal, {
props: {
isRedirectModal: false,
},
});
expect(wrapper.html()).toContain("ATENÇÃO!");
expect(wrapper.html()).toContain("Entendi");
});
test("Test custom modal when receive is redirect modal props as true", () => {
const wrapper = mount(CustomModal, {
props: {
isRedirectModal: true,
},
});
expect(wrapper.html()).toContain("Retomar a última compra?");
expect(wrapper.html()).toContain("Não");
expect(wrapper.html()).toContain("Sim");
});
});

View File

@@ -1,78 +0,0 @@
<script setup lang="ts">
interface Props {
title: string;
value: string;
change?: string;
changeType?: 'positive' | 'negative' | 'neutral';
icon?: string;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
changeType: 'neutral',
loading: false,
});
</script>
<template>
<div class="analytics-card">
<div class="analytics-content">
<div v-if="loading" class="analytics-value">
<div class="animate-pulse bg-gray-300 h-8 w-16 rounded"></div>
</div>
<div v-else class="analytics-value">{{ value }}</div>
<div class="analytics-title">{{ title }}</div>
<div
v-if="change && !loading"
class="analytics-change"
:class="`change-${changeType}`"
>
{{ change }}
</div>
</div>
<div v-if="icon && !loading" class="analytics-icon">
<img :src="icon" :alt="`${title} icon`" class="w-8 h-8" />
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.analytics-card {
@apply bg-white rounded-lg border border-gray-200 p-6 flex items-center justify-between shadow-lg;
}
.analytics-content {
@apply flex flex-col;
}
.analytics-value {
@apply text-2xl font-bold text-amber-400 mb-1 break-words overflow-hidden;
word-break: break-all;
max-width: 100%;
}
.analytics-title {
@apply text-sm text-gray-900 mb-1;
}
.analytics-change {
@apply text-xs font-medium;
}
.change-positive {
@apply text-green-600;
}
.change-negative {
@apply text-red-600;
}
.change-neutral {
@apply text-gray-600;
}
.analytics-icon {
@apply flex-shrink-0;
}
</style>

View File

@@ -1,311 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
interface Transaction {
id: string;
type: 'deposit' | 'lock' | 'release' | 'return';
timestamp: string;
seller?: string;
buyer?: string | null;
amount: string;
token: string;
blockNumber: string;
transactionHash: string;
}
interface Props {
transactions: Transaction[];
networkExplorerUrl: string;
}
const props = defineProps<Props>();
const copyFeedback = ref<{ [key: string]: boolean }>({});
const copyFeedbackTimeout = ref<{ [key: string]: NodeJS.Timeout | null }>({});
const getTransactionTypeInfo = (type: string) => {
const typeMap = {
deposit: { label: 'Depósito', status: 'completed' as const },
lock: { label: 'Bloqueio', status: 'open' as const },
release: { label: 'Liberação', status: 'completed' as const },
return: { label: 'Retorno', status: 'expired' as const },
};
return (
typeMap[type as keyof typeof typeMap] || {
label: type,
status: 'pending' as const,
}
);
};
const getTransactionTypeColor = (type: string) => {
const colorMap = {
deposit: 'text-emerald-600',
lock: 'text-amber-600',
release: 'text-emerald-600',
return: 'text-gray-600',
};
return colorMap[type as keyof typeof colorMap] || 'text-gray-600';
};
const formatAddress = (address: string) => {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
const formatAmount = (amount: string, decimals: number = 18): string => {
const num = parseFloat(amount) / Math.pow(10, decimals);
return num.toString();
};
const getExplorerUrl = (txHash: string) => {
return `${props.networkExplorerUrl}/tx/${txHash}`;
};
const copyToClipboard = async (address: string, key: string) => {
if (!address) {
return;
}
try {
await navigator.clipboard.writeText(address);
if (copyFeedbackTimeout.value[key]) {
clearTimeout(copyFeedbackTimeout.value[key]!);
}
copyFeedback.value[key] = true;
copyFeedbackTimeout.value[key] = setTimeout(() => {
copyFeedback.value[key] = false;
}, 2000);
} catch (error) {
console.error('Error copying to clipboard:', error);
}
};
</script>
<template>
<div>
<div class="hidden lg:block overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4 text-gray-700 font-medium">
Horário
</th>
<th class="text-left py-3 px-4 text-gray-700 font-medium">Tipo</th>
<th class="text-left py-3 px-4 text-gray-700 font-medium">
Participantes
</th>
<th class="text-left py-3 px-4 text-gray-700 font-medium">Valor</th>
<th class="text-left py-3 px-4 text-gray-700 font-medium">Bloco</th>
<th class="text-left py-3 px-4 text-gray-700 font-medium">Ações</th>
</tr>
</thead>
<tbody>
<tr
v-for="transaction in transactions"
:key="transaction.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<td class="py-4 px-4">
<div class="text-sm text-gray-600">
{{ transaction.timestamp }}
</div>
</td>
<td class="py-4 px-4">
<span
:class="getTransactionTypeColor(transaction.type)"
class="text-sm font-medium"
>
{{ getTransactionTypeInfo(transaction.type).label }}
</span>
</td>
<td class="py-4 px-4">
<div class="space-y-1">
<div v-if="transaction.seller" class="text-sm">
<span class="text-gray-600">Vendedor: </span>
<div class="relative inline-block">
<span
class="text-gray-900 font-mono cursor-pointer hover:text-amber-500 transition-colors"
@click="
copyToClipboard(
transaction.seller,
`seller-${transaction.id}`,
)
"
title="Copiar"
>
{{ formatAddress(transaction.seller) }}
</span>
<transition name="fade">
<span
v-if="copyFeedback[`seller-${transaction.id}`]"
class="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs text-emerald-500 font-semibold bg-white px-2 py-1 rounded shadow-sm whitespace-nowrap z-10"
>
Copiado!
</span>
</transition>
</div>
</div>
<div v-if="transaction.buyer" class="text-sm">
<span class="text-gray-600">Comprador: </span>
<div class="relative inline-block">
<span
class="text-gray-900 font-mono cursor-pointer hover:text-amber-500 transition-colors"
@click="
copyToClipboard(
transaction.buyer,
`buyer-${transaction.id}`,
)
"
title="Copiar"
>
{{ formatAddress(transaction.buyer) }}
</span>
<transition name="fade">
<span
v-if="copyFeedback[`buyer-${transaction.id}`]"
class="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs text-emerald-500 font-semibold bg-white px-2 py-1 rounded shadow-sm whitespace-nowrap z-10"
>
Copiado!
</span>
</transition>
</div>
</div>
</div>
</td>
<td class="py-4 px-4">
<div class="text-sm font-semibold text-emerald-600">
{{ formatAmount(transaction.amount, 18) }} BRZ
</div>
</td>
<td class="py-4 px-4">
<div class="text-sm text-gray-600 font-mono">
#{{ transaction.blockNumber }}
</div>
</td>
<td class="py-4 px-4">
<a
:href="getExplorerUrl(transaction.transactionHash)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-3 py-1 bg-amber-400 text-gray-900 rounded-lg text-sm font-medium hover:bg-amber-500 transition-colors"
>
Explorador
</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="lg:hidden space-y-4">
<div
v-for="transaction in transactions"
:key="transaction.id"
class="bg-gray-50 rounded-lg p-4 border border-gray-200"
>
<div class="flex items-center justify-between mb-3">
<span
:class="getTransactionTypeColor(transaction.type)"
class="text-sm font-medium"
>
{{ getTransactionTypeInfo(transaction.type).label }}
</span>
<div class="text-sm text-gray-600">{{ transaction.timestamp }}</div>
</div>
<div class="space-y-2 mb-4">
<div v-if="transaction.seller" class="text-sm">
<span class="text-gray-600">Vendedor: </span>
<div class="relative inline-block">
<span
class="text-gray-900 font-mono cursor-pointer hover:text-amber-500 transition-colors"
@click="
copyToClipboard(
transaction.seller,
`seller-${transaction.id}`,
)
"
title="Copiar"
>
{{ formatAddress(transaction.seller) }}
</span>
<transition name="fade">
<span
v-if="copyFeedback[`seller-${transaction.id}`]"
class="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs text-emerald-500 font-semibold bg-white px-2 py-1 rounded shadow-sm whitespace-nowrap z-10"
>
Copiado!
</span>
</transition>
</div>
</div>
<div v-if="transaction.buyer" class="text-sm">
<span class="text-gray-600">Comprador: </span>
<div class="relative inline-block">
<span
class="text-gray-900 font-mono cursor-pointer hover:text-amber-500 transition-colors"
@click="
copyToClipboard(transaction.buyer, `buyer-${transaction.id}`)
"
title="Copiar"
>
{{ formatAddress(transaction.buyer) }}
</span>
<transition name="fade">
<span
v-if="copyFeedback[`buyer-${transaction.id}`]"
class="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs text-emerald-500 font-semibold bg-white px-2 py-1 rounded shadow-sm whitespace-nowrap z-10"
>
Copiado!
</span>
</transition>
</div>
</div>
<div class="text-sm">
<span class="text-gray-600">Valor: </span>
<span class="font-semibold text-emerald-600"
>{{ formatAmount(transaction.amount, 18) }} BRZ</span
>
</div>
<div class="text-sm">
<span class="text-gray-600">Bloco: </span>
<span class="text-gray-900 font-mono"
>#{{ transaction.blockNumber }}</span
>
</div>
</div>
<a
:href="getExplorerUrl(transaction.transactionHash)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-3 py-1 bg-amber-400 text-gray-900 rounded-lg text-sm font-medium hover:bg-amber-500 transition-colors"
>
Ver no Explorador
</a>
</div>
</div>
<!-- Empty State -->
<div v-if="transactions.length === 0" class="text-center py-12">
<div class="text-gray-500 text-lg mb-2">📭</div>
<p class="text-gray-600">Nenhuma transação encontrada</p>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,212 +0,0 @@
<script setup lang="ts">
import type { ValidDeposit } from '@/model/ValidDeposit';
import { ref, watch, onMounted, computed } from 'vue';
import { debounce } from '@/utils/debounce';
import { decimalCount } from '@/utils/decimalCount';
import { useFloating, arrow, offset, flip, shift } from '@floating-ui/vue';
import IconButton from '../ui/IconButton.vue';
import withdrawIcon from '@/assets/withdraw.svg?url';
const props = defineProps<{
validDeposits: ValidDeposit[];
activeLockAmount: number;
selectedToken: string;
}>();
const emit = defineEmits<{
withdraw: [amount: string];
}>();
const withdrawAmount = ref<string>('');
const isCollapsibleOpen = ref<boolean>(false);
const validDecimals = ref<boolean>(true);
const validWithdrawAmount = ref<boolean>(true);
const enableConfirmButton = ref<boolean>(false);
const showInfoTooltip = ref<boolean>(false);
const floatingArrow = ref(null);
const reference = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const infoText = ref<HTMLElement | null>(null);
const remaining = computed(() => {
if (props.validDeposits.length > 0) {
const deposit = props.validDeposits[0];
return deposit ? deposit.remaining : 0;
}
return 0;
});
const handleInputEvent = (event: any): void => {
const { value } = event.target;
if (decimalCount(String(value)) > 2) {
validDecimals.value = false;
enableConfirmButton.value = false;
return;
}
validDecimals.value = true;
if (value > remaining.value) {
validWithdrawAmount.value = false;
enableConfirmButton.value = false;
return;
}
validWithdrawAmount.value = true;
enableConfirmButton.value = true;
};
const callWithdraw = () => {
if (enableConfirmButton.value && withdrawAmount.value) {
emit('withdraw', withdrawAmount.value);
// Reset form after withdraw
withdrawAmount.value = '';
isCollapsibleOpen.value = false;
}
};
const openWithdrawForm = () => {
isCollapsibleOpen.value = true;
};
const cancelWithdraw = () => {
isCollapsibleOpen.value = false;
withdrawAmount.value = '';
validDecimals.value = true;
validWithdrawAmount.value = true;
enableConfirmButton.value = false;
};
onMounted(() => {
useFloating(reference, floating, {
placement: 'right',
middleware: [
offset(10),
flip(),
shift(),
arrow({ element: floatingArrow }),
],
});
});
</script>
<template>
<div class="w-full bg-white p-4 sm:p-6 rounded-lg">
<div class="flex justify-between items-center">
<div>
<p class="text-sm leading-5 font-medium text-gray-600">
Saldo disponível
</p>
<p class="text-xl leading-7 font-semibold text-gray-900">
{{ remaining }} {{ selectedToken }}
</p>
<div class="flex gap-2 w-32 sm:w-56" v-if="activeLockAmount != 0">
<span class="text-xs font-normal text-gray-400" ref="infoText">
{{ `com ${activeLockAmount.toFixed(2)} ${selectedToken} em lock` }}
</span>
<div
class="absolute mt-[2px] md-view"
:style="{ left: `${(infoText?.clientWidth ?? 108) + 4}px` }"
>
<img
alt="info image"
src="@/assets/info.svg?url"
aria-describedby="tooltip"
ref="reference"
@mouseover="showInfoTooltip = true"
@mouseout="showInfoTooltip = false"
/>
<div
role="tooltip"
ref="floating"
class="w-56 z-50 tooltip md-view"
v-if="showInfoTooltip"
>
Valor "em lock" significa que a quantia está aguardando
confirmação de compra e estará disponível para saque caso a
transação expire.
</div>
</div>
</div>
</div>
<div v-show="!isCollapsibleOpen" class="flex justify-end items-center">
<IconButton
text="Sacar"
:icon="withdrawIcon"
variant="outline"
size="md"
:full-width="false"
@click="openWithdrawForm"
/>
</div>
</div>
<div class="pt-5">
<div v-show="isCollapsibleOpen" class="py-2 w-100">
<p class="text-sm leading-5 font-medium">Valor do saque</p>
<input
type="number"
@input="debounce(handleInputEvent, 500)($event)"
placeholder="0"
class="text-2xl text-gray-900 w-full outline-none"
v-model="withdrawAmount"
/>
</div>
<div class="flex justify-center" v-if="!validDecimals">
<span class="text-red-500 font-normal text-sm">
Por favor utilize no máximo 2 casas decimais
</span>
</div>
<div class="flex justify-center" v-else-if="!validWithdrawAmount">
<span class="text-red-500 font-normal text-sm">
Saldo insuficiente
</span>
</div>
<hr v-show="isCollapsibleOpen" class="pb-3" />
<div v-show="isCollapsibleOpen" class="flex justify-between items-center">
<h1
@click="cancelWithdraw"
class="text-black font-medium cursor-pointer hover:text-gray-600 transition-colors"
>
Cancelar
</h1>
<IconButton
text="Sacar"
:icon="withdrawIcon"
variant="outline"
size="md"
:full-width="false"
:disabled="!enableConfirmButton"
@click="callWithdraw"
/>
</div>
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
p {
@apply text-gray-900;
}
.tooltip {
@apply bg-white text-gray-900 font-medium text-xs md:text-base px-3 py-2 rounded border-2 border-emerald-500 left-5 top-[-3rem];
}
input[type='number'] {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
}
@media screen and (max-width: 640px) {
.md-view {
display: none;
}
}
</style>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { WalletTransaction } from '@/model/WalletTransaction';
import { useUser } from '@/composables/useUser';
import { ref, watch } from 'vue';
import SpinnerComponent from '../ui/SpinnerComponent.vue';
import BalanceCard from './BalanceCard.vue';
import TransactionCard from './TransactionCard.vue';
import { NetworkEnum } from "@/model/NetworkEnum";
import type { ValidDeposit } from "@/model/ValidDeposit";
import type { WalletTransaction } from "@/model/WalletTransaction";
import { useUser } from "@/composables/useUser";
import { ref, watch, onMounted } from "vue";
import SpinnerComponent from "../SpinnerComponent.vue";
import { decimalCount } from "@/utils/decimalCount";
import { debounce } from "@/utils/debounce";
import { getTokenByAddress } from "@/blockchain/addresses";
import { useFloating, arrow, offset, flip, shift } from "@floating-ui/vue";
const user = useUser();
@@ -16,14 +19,84 @@ const props = defineProps<{
activeLockAmount: number;
}>();
const emit = defineEmits(['depositWithdrawn']);
const emit = defineEmits(["depositWithdrawn"]);
const { loadingWalletTransactions } = user;
const remaining = ref<number>(0);
const itemsToShow = ref<WalletTransaction[]>([]);
const withdrawAmount = ref<string>("");
const withdrawButtonOpacity = ref<number>(0.6);
const withdrawButtonCursor = ref<string>("not-allowed");
const isCollapsibleOpen = ref<boolean>(false);
const validDecimals = ref<boolean>(true);
const validWithdrawAmount = ref<boolean>(true);
const enableConfirmButton = ref<boolean>(false);
const showInfoTooltip = ref<boolean>(false);
const floatingArrow = ref(null);
const callWithdraw = (amount: string) => {
emit('depositWithdrawn', amount);
const reference = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const infoText = ref<HTMLElement | null>(null);
// Debounce methods
const handleInputEvent = (event: any): void => {
const { value } = event.target;
if (decimalCount(String(value)) > 2) {
validDecimals.value = false;
enableConfirmButton.value = false;
return;
}
validDecimals.value = true;
if (value > remaining.value) {
validWithdrawAmount.value = false;
enableConfirmButton.value = false;
return;
}
validWithdrawAmount.value = true;
enableConfirmButton.value = true;
};
const callWithdraw = () => {
emit("depositWithdrawn", withdrawAmount.value);
};
watch(enableConfirmButton, (): void => {
if (!enableConfirmButton.value) {
withdrawButtonOpacity.value = 0.7;
withdrawButtonCursor.value = "not-allowed";
} else {
withdrawButtonOpacity.value = 1;
withdrawButtonCursor.value = "pointer";
}
});
watch(withdrawAmount, (): void => {
if (!withdrawAmount.value || !enableConfirmButton.value) {
withdrawButtonOpacity.value = 0.7;
withdrawButtonCursor.value = "not-allowed";
} else {
withdrawButtonOpacity.value = 1;
withdrawButtonCursor.value = "pointer";
}
});
const getRemaining = (): number => {
if (props.validDeposits.length > 0) {
// in this release only the BRL token is being used.
const deposit = props.validDeposits[0];
remaining.value = deposit ? deposit.remaining : 0;
return deposit ? deposit.remaining : 0;
}
return 0;
};
const getExplorer = (): string => {
return user.networkName.value == NetworkEnum.sepolia
? "Etherscan"
: "Polygonscan";
};
const showInitialItems = (): void => {
@@ -31,18 +104,46 @@ const showInitialItems = (): void => {
};
const openEtherscanUrl = (transactionHash: string): void => {
const networkUrl = user.network.value.blockExplorers?.default.url;
const networkUrl =
user.networkName.value == NetworkEnum.sepolia
? "sepolia.etherscan.io"
: "mumbai.polygonscan.com";
const url = `https://${networkUrl}/tx/${transactionHash}`;
window.open(url, '_blank');
window.open(url, "_blank");
};
const loadMore = (): void => {
const itemsShowing = itemsToShow.value.length;
itemsToShow.value?.push(
...props.walletTransactions.slice(itemsShowing, itemsShowing + 3),
...props.walletTransactions.slice(itemsShowing, itemsShowing + 3)
);
};
const getEventName = (event: string | undefined): string => {
if (!event) return "Desconhecido";
const possibleEventName: { [key: string]: string } = {
DepositAdded: "Oferta",
LockAdded: "Reserva",
LockReleased: "Compra",
DepositWithdrawn: "Retirada",
};
return possibleEventName[event];
};
onMounted(() => {
useFloating(reference, floating, {
placement: "right",
middleware: [
offset(10),
flip(),
shift(),
arrow({ element: floatingArrow }),
],
});
});
// watch props changes
watch(props, async (): Promise<void> => {
const itemsToShowQty = itemsToShow.value.length;
@@ -59,31 +160,183 @@ showInitialItems();
</script>
<template>
<div
class="main-container max-w-md flex justify-center items-center min-h-[200px] w-16 h-16"
v-if="loadingWalletTransactions"
>
Carregando ofertas...
<div class="main-container max-w-md" v-if="loadingWalletTransactions">
<SpinnerComponent width="8" height="8"></SpinnerComponent>
</div>
<div class="main-container max-w-md" v-else>
<BalanceCard
<div class="main-container max-w-md" v-if="!loadingWalletTransactions">
<div
class="w-full bg-white p-4 sm:p-6 rounded-lg"
v-if="props.validDeposits.length > 0"
:valid-deposits="props.validDeposits"
:active-lock-amount="activeLockAmount"
:selected-token="user.selectedToken.value"
@withdraw="callWithdraw"
>
<div class="flex justify-between items-center">
<div>
<p class="text-sm leading-5 font-medium text-gray-600">
Saldo disponível
</p>
<p class="text-xl leading-7 font-semibold text-gray-900">
{{ getRemaining() }} {{ user.networkName.value }}
</p>
<div class="flex gap-2 w-32 sm:w-56" v-if="activeLockAmount != 0">
<span class="text-xs font-normal text-gray-400" ref="infoText">{{
`com ${activeLockAmount.toFixed(2)} ${
user.networkName.value
} em lock`
}}</span>
<div
class="absolute mt-[2px] md-view"
:style="{ left: `${(infoText?.clientWidth ?? 108) + 4}px` }"
>
<img
alt="info image"
src="@/assets/info.svg?url"
aria-describedby="tooltip"
ref="reference"
@mouseover="showInfoTooltip = true"
@mouseout="showInfoTooltip = false"
/>
<div
role="tooltip"
ref="floating"
class="w-56 z-50 tooltip md-view"
v-if="showInfoTooltip"
>
Valor em lock significa que a quantia está aguardando
confirmação de compra e estará disponível para saque caso a
transação expire.
</div>
</div>
</div>
</div>
<div v-show="!isCollapsibleOpen" class="flex justify-end items-center">
<div
class="flex gap-2 cursor-pointer items-center justify-self-center border-2 p-2 border-amber-300 rounded-md"
@click="[(isCollapsibleOpen = true)]"
>
<img
alt="Withdraw image"
src="@/assets/withdraw.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
<span class="last-release-info">Sacar</span>
</div>
</div>
</div>
<div class="pt-5">
<div v-show="isCollapsibleOpen" class="py-2 w-100">
<p class="text-sm leading-5 font-medium">Valor do saque</p>
<input
type="number"
name=""
@input="debounce(handleInputEvent, 500)($event)"
placeholder="0"
class="text-2xl text-gray-900 w-full outline-none"
v-model="withdrawAmount"
/>
</div>
<div class="flex justify-center" v-if="!validDecimals">
<span class="text-red-500 font-normal text-sm"
>Por favor utilize no máximo 2 casas decimais</span
>
</div>
<div class="flex justify-center" v-else-if="!validWithdrawAmount">
<span class="text-red-500 font-normal text-sm"
>Saldo insuficiente</span
>
</div>
<hr v-show="isCollapsibleOpen" class="pb-3" />
<div
v-show="isCollapsibleOpen"
class="flex justify-between items-center"
>
<h1
@click="[(isCollapsibleOpen = false)]"
class="text-black font-medium cursor-pointer"
>
Cancelar
</h1>
<TransactionCard
<div
class="withdraw-button flex gap-2 items-center justify-self-center border-2 p-2 border-amber-300 rounded-md"
@click="callWithdraw"
>
<img
alt="Withdraw image"
src="@/assets/withdraw.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
<span class="last-release-info">Sacar</span>
</div>
</div>
</div>
</div>
<div
class="w-full bg-white p-4 sm:p-6 rounded-lg"
v-for="item in itemsToShow"
:key="item.blockNumber"
:selected-token="user.selectedToken.value"
:transaction="item"
:network-name="user.network.value.name"
@open-explorer="openEtherscanUrl"
>
<div class="item-container">
<div class="flex flex-col self-start">
<span class="text-xs sm:text-sm leading-5 font-medium text-gray-600">
{{ getEventName(item.event) }}
</span>
<span
class="text-xl sm:text-xl leading-7 font-semibold text-gray-900"
>
{{ item.amount }}
{{ getTokenByAddress(item.token) }}
</span>
</div>
<div>
<div
class="bg-amber-300 status-text"
v-if="getEventName(item.event) == 'Reserva' && item.lockStatus == 1"
>
Em Aberto
</div>
<div
class="bg-[#94A3B8] status-text"
v-if="getEventName(item.event) == 'Reserva' && item.lockStatus == 2"
>
Expirado
</div>
<div
class="bg-emerald-300 status-text"
v-if="
(getEventName(item.event) == 'Reserva' && item.lockStatus == 3) ||
getEventName(item.event) != 'Reserva'
"
>
Finalizado
</div>
<div
class="flex gap-2 cursor-pointer items-center justify-self-center w-full"
@click="openEtherscanUrl(item.transactionHash)"
v-if="getEventName(item.event) != 'Reserva' || item.lockStatus != 1"
>
<span class="last-release-info">{{ getExplorer() }}</span>
<img
alt="Redirect image"
src="@/assets/redirect.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
</div>
<div
class="flex gap-2 justify-self-center w-full"
v-if="getEventName(item.event) == 'Reserva' && item.lockStatus == 1"
>
<RouterLink
:to="{
name: 'home',
force: true,
state: { lockID: item.transactionID },
}"
class="router-button"
>Continuar</RouterLink
>
</div>
</div>
</div>
</div>
<div
class="flex flex-col justify-center items-center w-full mt-2 gap-2"
v-if="
@@ -93,14 +346,14 @@ showInitialItems();
>
<button
type="button"
class="text-white font-semibold border-2 border-amber-300 rounded-lg px-4 py-2 hover:bg-amber-300/10 transition-colors cursor-pointer"
class="text-white font-semibold"
@click="loadMore()"
>
Carregar mais
</button>
<span class="text-gray-300 text-sm">
{{ itemsToShow.length }} de {{ props.walletTransactions.length }}
transações
<span class="text-gray-300">
({{ itemsToShow.length }} de {{ props.walletTransactions.length }}
transações )
</span>
</div>
@@ -111,5 +364,62 @@ showInitialItems();
</template>
<style scoped>
/* Minimal styles - most styles moved to child components */
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
p {
@apply text-gray-900;
}
.text-container {
@apply flex flex-col items-center justify-center gap-4;
}
.item-container {
@apply flex justify-between items-center;
}
.status-text {
@apply text-xs sm:text-base font-medium text-gray-900 rounded-lg text-center mb-2 px-2 py-1 mt-4;
}
.text {
@apply text-white text-center;
}
.grid-container {
@apply grid grid-cols-4 grid-flow-row items-center px-8 py-6 gap-4 rounded-lg shadow-md shadow-gray-600 backdrop-blur-md mt-10 w-auto;
}
.last-release-info {
@apply font-medium text-xs sm:text-sm text-gray-900 justify-self-center;
}
.tooltip {
@apply bg-white text-gray-900 font-medium text-xs md:text-base px-3 py-2 rounded border-2 border-emerald-500 left-5 top-[-3rem];
}
.router-button {
@apply rounded-lg border-amber-300 border-2 px-3 py-2 text-gray-900 font-semibold sm:text-base text-xs hover:bg-transparent w-full text-center;
}
.withdraw-button {
opacity: v-bind(withdrawButtonOpacity);
cursor: v-bind(withdrawButtonCursor);
}
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
@media screen and (max-width: 640px) {
.md-view {
display: none;
}
}
</style>

View File

@@ -1,146 +0,0 @@
<script setup lang="ts">
import type { WalletTransaction } from '@/model/WalletTransaction';
import { TokenEnum } from '@/model/NetworkEnum';
import { computed } from 'vue';
import StatusBadge, { type StatusType } from '../ui/StatusBadge.vue';
import { Networks } from '@/config/networks';
const props = defineProps<{
transaction: WalletTransaction;
networkName: keyof typeof Networks;
selectedToken: TokenEnum;
}>();
const emit = defineEmits<{
openExplorer: [transactionHash: string];
}>();
const eventName = computed(() => {
if (!props.transaction.event) return 'Desconhecido';
const possibleEventName: { [key: string]: string } = {
DepositAdded: 'Oferta',
LockAdded: 'Reserva',
LockReleased: 'Compra',
DepositWithdrawn: 'Retirada',
};
return possibleEventName[props.transaction.event] || 'Desconhecido';
});
const explorerName = computed(() => {
return Networks[(props.networkName as string).toLowerCase()].blockExplorers
?.default.name;
});
const statusType = computed((): StatusType => {
if (eventName.value === 'Reserva') {
switch (props.transaction.lockStatus) {
case 1:
return 'open';
case 2:
return 'expired';
case 3:
return 'completed';
default:
return 'completed';
}
}
return 'completed';
});
const showExplorerLink = computed(() => {
return eventName.value !== 'Reserva' || props.transaction.lockStatus !== 1;
});
const showContinueButton = computed(() => {
return eventName.value === 'Reserva' && props.transaction.lockStatus === 1;
});
const formattedDate = computed(() => {
if (!props.transaction.blockTimestamp) return '';
const timestamp = props.transaction.blockTimestamp;
const date = new Date(timestamp * 1000);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
});
const handleExplorerClick = () => {
emit('openExplorer', props.transaction.transactionHash);
};
</script>
<template>
<div class="w-full bg-white p-4 sm:p-6 rounded-lg">
<div class="item-container">
<div class="flex flex-col self-start">
<span class="text-xs sm:text-sm leading-5 font-medium text-gray-600">
{{ eventName }}
</span>
<span class="text-xl sm:text-xl leading-7 font-semibold text-gray-900">
{{ transaction.amount }} {{ selectedToken }}
</span>
<span
v-if="formattedDate"
class="text-xs sm:text-sm leading-5 font-normal text-gray-500 mt-1"
>
{{ formattedDate }}
</span>
</div>
<div class="flex flex-col items-center justify-center">
<div class="mb-2 mt-4">
<StatusBadge :status="statusType" />
</div>
<div
v-if="showExplorerLink"
class="flex gap-2 cursor-pointer items-center justify-self-center w-full"
@click="handleExplorerClick"
>
<span class="last-release-info">{{ explorerName }}</span>
<img
alt="Redirect image"
src="@/assets/redirect.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
</div>
<div
v-if="showContinueButton"
class="flex gap-2 justify-self-center w-full"
>
<RouterLink
:to="{
name: 'home',
force: true,
state: { lockID: transaction.transactionID },
}"
class="router-button"
>
Continuar
</RouterLink>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.item-container {
@apply flex justify-between items-center;
}
.last-release-info {
@apply font-medium text-xs sm:text-sm text-gray-900 justify-self-center;
}
.router-button {
@apply rounded-lg border-amber-300 border-2 px-3 py-2 text-gray-900 font-semibold sm:text-base text-xs hover:bg-transparent w-full text-center;
}
</style>

View File

@@ -0,0 +1,85 @@
import { describe, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import ListingComponent from "../ListingComponent.vue";
import { useUser } from "@/composables/useUser";
import { MockValidDeposits } from "@/model/mock/ValidDepositMock";
import { MockWalletTransactions } from "@/model/mock/WalletTransactionMock";
describe("ListingComponent.vue", () => {
beforeEach(() => {
useUser().setLoadingWalletTransactions(false);
});
test("Test Message when an empty array is received", () => {
const wrapper = mount(ListingComponent, {
props: {
validDeposits: [],
walletTransactions: [],
activeLockAmount: 0,
},
});
expect(wrapper.html()).toContain("Não há nenhuma transação anterior");
});
test("Test number of elements in the list first render", () => {
const wrapper = mount(ListingComponent, {
props: {
validDeposits: [],
walletTransactions: MockWalletTransactions,
activeLockAmount: 0,
},
});
const elements = wrapper.findAll(".item-container");
expect(elements).toHaveLength(3);
});
test("Test load more button behavior", async () => {
const wrapper = mount(ListingComponent, {
props: {
validDeposits: MockValidDeposits,
walletTransactions: MockWalletTransactions,
activeLockAmount: 0,
},
});
const btn = wrapper.find("button");
let elements = wrapper.findAll(".item-container");
expect(elements).toHaveLength(3);
await btn.trigger("click");
elements = wrapper.findAll(".item-container");
expect(elements).toHaveLength(5);
});
test("Test withdraw offer button emit", async () => {
const wrapper = mount(ListingComponent, {
props: {
validDeposits: MockValidDeposits,
walletTransactions: MockWalletTransactions,
activeLockAmount: 0,
},
});
wrapper.vm.$emit("depositWithdrawn");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("depositWithdrawn")).toBeTruthy();
});
test("Test should render lock info when active lock amount is greater than 0", () => {
const wrapper = mount(ListingComponent, {
props: {
validDeposits: MockValidDeposits,
walletTransactions: [],
activeLockAmount: 50,
},
});
expect(wrapper.html()).toContain("com 50.00 BRZ em lock");
});
});

View File

@@ -12,11 +12,13 @@ const props = defineProps({
<span
class="text font-bold sm:text-3xl text-2xl sm:max-w-[29rem] max-w-[20rem]"
>
{{ props.title ? props.title : 'Confirme em sua carteira' }}
{{ props.title ? props.title : "Confirme em sua carteira" }}
</span>
</div>
<div class="main-container max-w-md">
<div class="flex flex-col w-full bg-white sm:px-10 px-4 py-5 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-4 py-5 rounded-lg border-y-10"
>
<div
class="flex flex-col text-center justify-center w-full items-center p-2 px-3 rounded-3xl lg:min-w-fit gap-1"
>
@@ -36,7 +38,6 @@ const props = defineProps({
</template>
<style scoped>
@reference "tailwindcss";
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
@@ -59,12 +60,12 @@ const props = defineProps({
@apply text-white text-center;
}
input[type='number'] {
input[type="number"] {
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -0,0 +1,27 @@
import { mount } from "@vue/test-utils";
import LoadingComponent from "../LoadingComponent.vue";
describe("Loading.vue", () => {
test("Test loading content with received props", () => {
const wrapper = mount(LoadingComponent, {
props: {
title: "MockTitle",
message: "MockMessage",
},
});
expect(wrapper.html()).toContain("MockTitle");
expect(wrapper.html()).toContain("MockMessage");
});
test("Test default text if props title isnt passed", () => {
const wrapper = mount(LoadingComponent, {
props: {
message: "MockMessage",
},
});
expect(wrapper.html()).toContain("Confirme em sua carteira");
expect(wrapper.html()).toContain("MockMessage");
});
});

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import CustomModal from "@/components//CustomModal/CustomModal.vue";
const windowSize = ref<number>(window.innerWidth);
const qrCode = ref<string>("");
const isPixValid = ref<boolean>(false);
const showWarnModal = ref<boolean>(true);
const releaseSignature = ref<string>("");
// Emits
const emit = defineEmits(["pixValidated"]);
onMounted(() => {
window.addEventListener(
"resize",
() => (windowSize.value = window.innerWidth)
);
});
onUnmounted(() => {
window.removeEventListener(
"resize",
() => (windowSize.value = window.innerWidth)
);
});
</script>
<template>
<div class="page">
<div class="text-container">
<span
class="text font-extrabold lg:text-2xl text-xl sm:max-w-[30rem] max-w-[24rem]"
>
Utilize o QR Code ou copie o código para realizar o Pix
</span>
<span class="text font-medium lg:text-md text-sm max-w-[28rem]">
Após realizar o Pix no banco de sua preferência, clique no botão abaixo
para liberação dos tokens.
</span>
</div>
<div class="main-container max-w-md text-black">
<div
class="flex-col items-center justify-center flex w-full bg-white sm:p-8 p-4 rounded-lg break-normal"
>
<img alt="Qr code image" :src="qrCode" class="w-48 h-48" />
<span class="text-center font-bold">Código pix</span>
<div class="break-words w-4/5">
<span class="text-center text-xs">
{{ qrCode }}
</span>
</div>
<img
alt="Copy PIX code"
src="@/assets/copyPix.svg?url"
width="16"
height="16"
class="pt-2 lg:mb-5 cursor-pointer"
/>
</div>
<CustomButton
:is-disabled="isPixValid == false"
:text="'Enviar para a rede'"
@button-clicked="emit('pixValidated', releaseSignature)"
/>
</div>
<CustomModal
v-if="showWarnModal && windowSize <= 500"
@close-modal="showWarnModal = false"
:isRedirectModal="false"
/>
</div>
</template>
<style scoped>
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
::placeholder {
/* Most modern browsers support this now. */
color: #9ca3af;
}
h4 {
color: #080808;
font-size: 14px;
}
h2 {
color: #080808;
}
.form-input {
@apply rounded-lg border border-gray-200 p-2 text-black;
}
.form-label {
@apply font-semibold tracking-wide text-emerald-50;
}
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
}
.bottom-position {
top: -20px;
right: 50%;
transform: translateX(50%);
}
.text-container {
@apply flex flex-col items-center justify-center gap-4;
}
.text {
@apply text-white text-center;
}
.blur-container {
@apply flex flex-col justify-center items-center px-8 py-6 gap-2 rounded-lg shadow-md shadow-gray-600 backdrop-blur-md mt-6 max-w-screen-sm;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -1,19 +1,17 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useUser } from '@/composables/useUser';
import SpinnerComponent from '@/components/ui/SpinnerComponent.vue';
import CustomButton from '@/components/ui/CustomButton.vue';
import { debounce } from '@/utils/debounce';
import { verifyNetworkLiquidity } from '@/utils/networkLiquidity';
import type { ValidDeposit } from '@/model/ValidDeposit';
import { decimalCount } from '@/utils/decimalCount';
import { getTokenImage, getNetworkImage } from '@/utils/imagesPath';
import { onClickOutside } from '@vueuse/core';
import { Networks } from '@/config/networks';
import { TokenEnum } from '@/model/NetworkEnum';
import { getContract } from '@/blockchain/provider';
import { reputationAbi } from '@/blockchain/abi';
import { type Address } from 'viem';
import { ref, watch } from "vue";
import { useUser } from "@/composables/useUser";
import SpinnerComponent from "@/components/SpinnerComponent.vue";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import { debounce } from "@/utils/debounce";
import { verifyNetworkLiquidity } from "@/utils/networkLiquidity";
import { NetworkEnum } from "@/model/NetworkEnum";
import type { ValidDeposit } from "@/model/ValidDeposit";
import { decimalCount } from "@/utils/decimalCount";
import { getTokenImage } from "@/utils/imagesPath";
import { onClickOutside } from "@vueuse/core";
import { TokenEnum } from "@/model/NetworkEnum";
// Store reference
const user = useUser();
@@ -21,7 +19,7 @@ const selectTokenToggle = ref<boolean>(false);
const {
walletAddress,
network,
networkName,
selectedToken,
depositsValidList,
loadingNetworkLiquidity,
@@ -35,106 +33,15 @@ const tokenValue = ref<number>(0);
const enableConfirmButton = ref<boolean>(false);
const hasLiquidity = ref<boolean>(true);
const validDecimals = ref<boolean>(true);
const identification = ref<string>('');
const identification = ref<string>("");
const selectedDeposits = ref<ValidDeposit[]>();
const reputationLimit = ref<number | null>(null);
const exceedsReputationLimit = ref<boolean>(false);
import ChevronDown from '@/assets/chevronDown.svg';
import { useOnboard } from '@web3-onboard/vue';
import { getParticipantID } from '@/blockchain/events';
import ChevronDown from "@/assets/chevronDown.svg";
import { useOnboard } from "@web3-onboard/vue";
import { getPixKey } from "@/blockchain/events";
// Emits
const emit = defineEmits(['tokenBuy']);
const castAddrToKey = (address: Address): bigint => {
return BigInt(address) << BigInt(12);
};
const getUserCredit = async (userAddress: Address): Promise<bigint> => {
try {
const { address, abi, client } = await getContract(true);
const userKey = castAddrToKey(userAddress);
const userCredit = await client.readContract({
address,
abi,
functionName: 'userRecord',
args: [userKey],
});
return userCredit as bigint;
} catch (error) {
console.error('Error fetching user credit:', error);
return BigInt(0);
}
};
const getReputationAddress = async (): Promise<Address | null> => {
try {
const { address, abi, client } = await getContract(true);
const reputationAddr = await client.readContract({
address,
abi,
functionName: 'reputation',
});
return reputationAddr as Address;
} catch (error) {
console.error('Error fetching reputation address:', error);
return null;
}
};
const getSpendLimit = async (userCredit: bigint): Promise<bigint> => {
try {
const reputationAddr = await getReputationAddress();
if (!reputationAddr) return BigInt(0);
const { client } = await getContract(true);
const spendLimit = await client.readContract({
address: reputationAddr,
abi: reputationAbi,
functionName: 'limiter',
args: [userCredit],
});
return spendLimit as bigint;
} catch (error) {
console.error('Error fetching spend limit:', error);
return BigInt(0);
}
};
const checkReputationLimit = async (inputValue: number): Promise<void> => {
exceedsReputationLimit.value = false;
if (!walletAddress.value) {
reputationLimit.value = null;
return;
}
if (inputValue === 0) {
return;
}
try {
const userCredit = await getUserCredit(walletAddress.value);
const spendLimitRaw = await getSpendLimit(userCredit);
const spendLimitNumber = Number(spendLimitRaw);
reputationLimit.value = spendLimitNumber;
exceedsReputationLimit.value = spendLimitNumber < inputValue;
enableConfirmButton.value = !exceedsReputationLimit.value;
} catch (error) {
console.error('Error checking reputation limit:', error);
reputationLimit.value = null;
exceedsReputationLimit.value = false;
}
};
const emit = defineEmits(["tokenBuy"]);
// Blockchain methods
const connectAccount = async (): Promise<void> => {
@@ -144,11 +51,11 @@ const connectAccount = async (): Promise<void> => {
const emitConfirmButton = async (): Promise<void> => {
const deposit = selectedDeposits.value?.find(
(d) => d.network === network.value,
(d) => d.network === Number(networkName.value)
);
if (!deposit) return;
deposit.participantID = await getParticipantID(deposit.seller, deposit.token);
emit('tokenBuy', deposit, tokenValue.value);
deposit.pixKey = await getPixKey(deposit.seller, deposit.token);
emit("tokenBuy", deposit, tokenValue.value);
};
// Debounce methods
@@ -164,7 +71,6 @@ const handleInputEvent = (event: any): void => {
}
validDecimals.value = true;
checkReputationLimit(tokenValue.value);
verifyLiquidity();
};
@@ -184,14 +90,15 @@ const handleSelectedToken = (token: TokenEnum): void => {
// Verify if there is a valid deposit to buy
const verifyLiquidity = (): void => {
enableConfirmButton.value = false;
if (!walletAddress.value) return;
const selDeposits = verifyNetworkLiquidity(
tokenValue.value,
walletAddress.value,
depositsValidList.value,
depositsValidList.value
);
selectedDeposits.value = selDeposits;
hasLiquidity.value = !!selDeposits.find((d) => d.network === network.value);
hasLiquidity.value = !!selDeposits.find(
(d) => d.network === Number(networkName.value)
);
enableOrDisableConfirmButton();
};
@@ -201,7 +108,7 @@ const enableOrDisableConfirmButton = (): void => {
return;
}
if (!selectedDeposits.value.find((d) => d.network === network.value)) {
if (!selectedDeposits.value.find((d) => d.network === networkName.value)) {
enableConfirmButton.value = false;
return;
}
@@ -209,7 +116,7 @@ const enableOrDisableConfirmButton = (): void => {
enableConfirmButton.value = true;
};
watch(network, (): void => {
watch(networkName, (): void => {
verifyLiquidity();
enableOrDisableConfirmButton();
});
@@ -218,13 +125,6 @@ watch(walletAddress, (): void => {
verifyLiquidity();
});
const availableNetworks = computed(() => {
if (!selectedDeposits.value) return [];
return Object.values(Networks).filter((network) =>
selectedDeposits.value?.some((d) => d.network.id === network.id),
);
});
// Add form submission handler
const handleSubmit = async (e: Event): Promise<void> => {
e.preventDefault();
@@ -251,12 +151,14 @@ const handleSubmit = async (e: Event): Promise<void> => {
</div>
<form class="main-container" @submit="handleSubmit">
<div class="backdrop-blur -z-10 w-full h-full"></div>
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-5 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-5 rounded-lg border-y-10"
>
<div class="flex justify-between sm:w-full items-center">
<input
type="number"
name="tokenAmount"
class="border-none outline-none text-lg text-gray-900 sm:flex-1 max-w-[60%]"
class="border-none outline-none text-lg text-gray-900"
v-bind:class="{
'font-semibold': tokenValue != undefined,
'text-xl': tokenValue != undefined,
@@ -266,7 +168,7 @@ const handleSubmit = async (e: Event): Promise<void> => {
step=".01"
required
/>
<div class="relative overflow-visible ml-auto sm:ml-0">
<div class="relative overflow-visible">
<button
ref="tokenDropdownRef"
class="flex flex-row items-center p-2 bg-gray-300 hover:bg-gray-200 focus:outline-indigo-800 focus:outline-2 rounded-3xl min-w-fit gap-2 transition-colors"
@@ -329,12 +231,24 @@ const handleSubmit = async (e: Event): Promise<void> => {
</p>
<div class="flex gap-2">
<img
v-for="network in availableNetworks"
:key="network.id"
:alt="`${network.name} image`"
:src="getNetworkImage(network.name)"
alt="Rootstock image"
src="@/assets/rootstock.svg?url"
width="24"
height="24"
v-if="
selectedDeposits &&
selectedDeposits.find((d) => d.network == NetworkEnum.rootstock)
"
/>
<img
alt="Ethereum image"
src="@/assets/ethereum.svg?url"
width="24"
height="24"
v-if="
selectedDeposits &&
selectedDeposits.find((d) => d.network == NetworkEnum.sepolia)
"
/>
</div>
</div>
@@ -358,10 +272,7 @@ const handleSubmit = async (e: Event): Promise<void> => {
<div
class="flex justify-center"
v-else-if="
!hasLiquidity &&
!loadingNetworkLiquidity &&
tokenValue > 0 &&
!exceedsReputationLimit
!hasLiquidity && !loadingNetworkLiquidity && tokenValue > 0
"
>
<span class="text-red-500 font-normal text-sm"
@@ -369,22 +280,11 @@ const handleSubmit = async (e: Event): Promise<void> => {
demanda</span
>
</div>
<div
class="flex justify-center"
v-if="
exceedsReputationLimit &&
!loadingNetworkLiquidity &&
reputationLimit !== null
"
>
<span class="text-red-500 font-normal text-sm"
>O valor excede o limite permitido pela sua reputação. Limite
máximo: {{ reputationLimit }} {{ selectedToken }}</span
>
</div>
</div>
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
>
<input
type="text"
v-model="identification"
@@ -401,7 +301,6 @@ const handleSubmit = async (e: Event): Promise<void> => {
v-if="walletAddress"
type="submit"
text="Confirmar Oferta"
:isDisabled="!enableConfirmButton"
/>
<CustomButton
v-else
@@ -413,7 +312,6 @@ const handleSubmit = async (e: Event): Promise<void> => {
</template>
<style scoped>
@reference "tailwindcss";
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
@@ -436,12 +334,12 @@ const handleSubmit = async (e: Event): Promise<void> => {
@apply text-white text-center;
}
input[type='number'] {
input[type="number"] {
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useUser } from '@/composables/useUser';
import CustomButton from '@/components/ui/CustomButton.vue';
import { postProcessKey } from '@/utils/pixKeyFormat';
import { TokenEnum } from '@/model/NetworkEnum';
import { getTokenImage } from '@/utils/imagesPath';
import { useOnboard } from '@web3-onboard/vue';
import ChevronDown from '@/assets/chevron.svg';
import { ref, computed } from "vue";
import { useUser } from "@/composables/useUser";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import { postProcessKey } from "@/utils/pixKeyFormat";
import { TokenEnum } from "@/model/NetworkEnum";
import { getTokenImage } from "@/utils/imagesPath";
import { useOnboard } from "@web3-onboard/vue";
import ChevronDown from "@/assets/chevron.svg";
// Import the bank list
import bankList from '@/utils/files/isbpList.json';
import type { Participant } from '@/utils/bbPay';
import bankList from "@/utils/files/isbpList.json";
import type { Participant } from "@/utils/bbPay";
// Define Bank interface
interface Bank {
@@ -26,17 +26,17 @@ const formRef = ref<HTMLFormElement | null>(null);
const user = useUser();
const { walletAddress, selectedToken } = user;
const offer = ref<string>('');
const identification = ref<string>('');
const account = ref<string>('');
const branch = ref<string>('');
const accountType = ref<string>('');
const offer = ref<string>("");
const identification = ref<string>("");
const account = ref<string>("");
const branch = ref<string>("");
const accountType = ref<string>("");
const selectTokenToggle = ref<boolean>(false);
const savingsVariation = ref<string>('');
const savingsVariation = ref<string>("");
const errors = ref<{ [key: string]: string }>({});
// Bank selection
const bankSearchQuery = ref<string>('');
const bankSearchQuery = ref<string>("");
const showBankList = ref<boolean>(false);
const selectedBank = ref<Bank | null>(null);
@@ -44,7 +44,7 @@ const filteredBanks = computed(() => {
if (!bankSearchQuery.value) return [];
return bankList
.filter((bank) =>
bank.longName.toLowerCase().includes(bankSearchQuery.value.toLowerCase()),
bank.longName.toLowerCase().includes(bankSearchQuery.value.toLowerCase())
)
.slice(0, 5);
});
@@ -56,7 +56,7 @@ const handleBankSelect = (bank: Bank) => {
};
// Emits
const emit = defineEmits(['approveTokens']);
const emit = defineEmits(["approveTokens"]);
// Methods
const connectAccount = async (): Promise<void> => {
@@ -71,16 +71,16 @@ const handleSubmit = (e: Event): void => {
const data: Participant = {
offer: offer.value,
chainID: user.network.value.id,
chainID: user.networkId.value,
identification: processedIdentification,
bankIspb: selectedBank.value?.ISPB,
accountType: accountType.value,
account: account.value,
branch: branch.value,
savingsVariation: savingsVariation.value || '',
savingsVariation: savingsVariation.value || "",
};
emit('approveTokens', data);
emit("approveTokens", data);
};
// Token selection
@@ -113,7 +113,7 @@ const handleSelectedToken = (token: TokenEnum): void => {
<form ref="formRef" @submit="handleSubmit" class="main-container">
<!-- Offer input -->
<div
class="flex justify-between items-center w-full bg-white sm:px-10 px-6 py-5 rounded-lg gap-4"
class="flex justify-between items-center w-full bg-white sm:px-10 px-6 py-5 rounded-lg border-y-10 gap-4"
>
<input
type="number"
@@ -185,7 +185,9 @@ const handleSelectedToken = (token: TokenEnum): void => {
</div>
</div>
<!-- CPF or CNPJ input -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
>
<input
type="text"
v-model="identification"
@@ -198,7 +200,9 @@ const handleSelectedToken = (token: TokenEnum): void => {
/>
</div>
<!-- Bank selection -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
>
<div class="relative">
<input
type="text"
@@ -231,7 +235,9 @@ const handleSelectedToken = (token: TokenEnum): void => {
}}</span>
</div>
<!-- Account and Branch inputs -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
>
<div class="flex gap-4">
<div class="flex-1">
<input
@@ -256,7 +262,9 @@ const handleSelectedToken = (token: TokenEnum): void => {
</div>
</div>
<!-- Account Type Selection -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
>
<div class="flex gap-4">
<div class="flex-1">
<select
@@ -279,7 +287,7 @@ const handleSelectedToken = (token: TokenEnum): void => {
v-if="accountType === '2'"
type="text"
v-model="savingsVariation"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full bg-white sm:px-10 px-6 py-4 rounded-lg"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full bg-white sm:px-10 px-6 py-4 rounded-lg border-y-10"
:class="{ 'text-xl font-medium': savingsVariation }"
placeholder="Variação da poupança"
required
@@ -297,7 +305,6 @@ const handleSelectedToken = (token: TokenEnum): void => {
</template>
<style scoped>
@reference "tailwindcss";
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
@@ -320,13 +327,13 @@ const handleSelectedToken = (token: TokenEnum): void => {
@apply text-white text-center;
}
input[type='number'] {
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref } from "vue";
import { useUser } from "@/composables/useUser";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import { debounce } from "@/utils/debounce";
import { decimalCount } from "@/utils/decimalCount";
import { getTokenImage } from "@/utils/imagesPath";
import { useOnboard } from "@web3-onboard/vue";
// Store
const user = useUser();
const { walletAddress } = user;
// Reactive state
const tokenValue = ref<number>(0);
const enableSelectButton = ref<boolean>(false);
const hasLiquidity = ref<boolean>(true);
const validDecimals = ref<boolean>(true);
// Emits
const emit = defineEmits(["tokenBuy"]);
// Blockchain methods
const connectAccount = async (): Promise<void> => {
const { connectWallet } = useOnboard();
await connectWallet();
};
// Debounce methods
const handleInputEvent = (event: any): void => {
const { value } = event.target;
tokenValue.value = Number(value);
if (decimalCount(String(tokenValue.value)) > 2) {
validDecimals.value = false;
enableSelectButton.value = false;
return;
}
validDecimals.value = true;
};
</script>
<template>
<div class="page">
<div class="text-container">
<span class="text font-extrabold text-5xl max-w-[29rem]"
>Adquira cripto com apenas um Pix</span
>
<span class="text font-medium text-base max-w-[28rem]"
>Digite um valor, confira a oferta, conecte sua carteira e receba os
tokens após realizar o Pix</span
>
</div>
<div class="main-container">
<div
class="flex flex-col w-full bg-white px-10 py-5 rounded-lg border-y-10"
>
<div class="flex justify-between w-full items-center">
<input
type="number"
class="border-none outline-none text-lg text-gray-900 w-fit"
v-bind:class="{
'font-semibold': tokenValue != undefined,
'text-xl': tokenValue != undefined,
}"
@input="debounce(handleInputEvent, 500)($event)"
placeholder="0 "
step=".01"
/>
<div
class="flex flex-row p-2 px-3 bg-gray-300 rounded-3xl min-w-fit gap-1"
>
<img
alt="Token image"
class="w-fit"
:src="getTokenImage(user.selectedToken.value)"
/>
<span class="text-gray-900 text-lg w-fit" id="token">{{
user.selectedToken
}}</span>
</div>
</div>
<div class="custom-divide py-2"></div>
<div class="flex justify-between pt-2" v-if="hasLiquidity">
<p class="text-gray-500 font-normal text-sm w-auto">
~ R$ {{ tokenValue.toFixed(2) }}
</p>
<div class="flex gap-2">
<img
alt="Polygon image"
src="@/assets/polygon.svg?url"
width="24"
height="24"
/>
<img
alt="Ethereum image"
src="@/assets/ethereum.svg?url"
width="24"
height="24"
/>
</div>
</div>
<div class="flex pt-2 justify-center" v-if="!validDecimals">
<span class="text-red-500 font-normal text-sm"
>Por favor utilize no máximo 2 casas decimais</span
>
</div>
<div class="flex pt-2 justify-center" v-else-if="!hasLiquidity">
<span class="text-red-500 font-normal text-sm"
>Atualmente não liquidez nas redes para sua demanda</span
>
</div>
</div>
<CustomButton
v-if="walletAddress"
:text="'Conectar carteira'"
@buttonClicked="emit('tokenBuy')"
/>
<CustomButton
v-if="!walletAddress"
:text="'Conectar carteira'"
@buttonClicked="connectAccount()"
/>
</div>
</div>
</template>
<style scoped>
.custom-divide {
width: 100%;
border-bottom: 1px solid #d1d5db;
}
.bottom-position {
top: -20px;
right: 50%;
transform: translateX(50%);
}
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
.text-container {
@apply flex flex-col items-center justify-center gap-4;
}
.text {
@apply text-white text-center;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import CustomButton from '@/components/ui/CustomButton.vue';
import CustomButton from "@/components/CustomButton/CustomButton.vue";
// Emits
const emit = defineEmits(['sendNetwork']);
const emit = defineEmits(["sendNetwork"]);
// props and store references
const props = defineProps({
@@ -27,7 +27,9 @@ const props = defineProps({
>
</div>
<div class="main-container">
<div class="flex flex-col w-full bg-white px-10 py-5 rounded-lg">
<div
class="flex flex-col w-full bg-white px-10 py-5 rounded-lg border-y-10"
>
<div>
<p>Tokens ofertados</p>
<p class="text-2xl text-gray-900">
@@ -57,7 +59,6 @@ const props = defineProps({
</template>
<style scoped>
@reference "tailwindcss";
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
@@ -78,8 +79,8 @@ p {
@apply font-medium text-base;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -10,10 +10,10 @@ const getCustomClass = () => {
return [
`w-${props.width}`,
`h-${props.height}`,
'fill-white',
'text-gray-200',
'animate-spin',
'dark:text-gray-600',
`fill-white`,
"text-gray-200",
"animate-spin",
"dark:text-gray-600",
];
};
</script>

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useOnboard } from '@web3-onboard/vue';
import { Networks } from '@/config/networks';
import { useUser } from '@/composables/useUser';
import { ref, computed, watch, onMounted } from "vue";
import { useOnboard } from "@web3-onboard/vue";
import { Networks } from "../model/Networks";
import { useUser } from "@/composables/useUser";
const { connectedWallet } = useOnboard();
const user = useUser();
const { network } = user;
const { networkId, networkName } = user;
const isWrongNetwork = ref(false);
const targetNetworkName = computed(() => network.value.name);
const targetNetworkName = computed(() => Networks[networkName.value].chainName);
const checkNetwork = () => {
if (connectedWallet.value) {
const chainId = connectedWallet.value.chains[0].id;
isWrongNetwork.value = Number(chainId) !== network.value.id;
isWrongNetwork.value = Number(chainId) !== networkId.value;
} else {
isWrongNetwork.value = false; // No wallet connected yet
}
@@ -23,24 +23,23 @@ const checkNetwork = () => {
const switchNetwork = async () => {
try {
if (connectedWallet.value && connectedWallet.value.provider) {
const chainId = network.value.id.toString(16);
await connectedWallet.value.provider.request({
method: 'wallet_switchEthereumChain',
method: "wallet_switchEthereumChain",
params: [
{
chainId: `0x${chainId}`,
chainId: Networks[networkName.value].chainId,
},
],
});
}
} catch (error) {
console.error('Failed to switch network:', error);
console.error("Failed to switch network:", error);
}
};
onMounted(checkNetwork);
watch(connectedWallet, checkNetwork);
watch(network, checkNetwork, { immediate: true });
watch(networkId, checkNetwork, { immediate: true });
</script>
<template>
@@ -66,9 +65,7 @@ watch(network, checkNetwork, { immediate: true });
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition:
transform 0.3s ease,
opacity 0.3s ease;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,

View File

@@ -1,34 +1,22 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useUser } from '@/composables/useUser';
import { onClickOutside } from '@vueuse/core';
import { getNetworkImage } from '@/utils/imagesPath';
import { Networks } from '@/config/networks';
import { useOnboard } from '@web3-onboard/vue';
import { ref, watch } from "vue";
import { useUser } from "@/composables/useUser";
import { onClickOutside } from "@vueuse/core";
import { NetworkEnum } from "@/model/NetworkEnum";
import { getNetworkImage } from "@/utils/imagesPath";
import { Networks } from "@/model/Networks";
import ChevronDown from '@/assets/chevronDown.svg';
import TwitterIcon from '@/assets/twitterIcon.svg';
import LinkedinIcon from '@/assets/linkedinIcon.svg';
import GithubIcon from '@/assets/githubIcon.svg';
import { connectProvider } from '@/blockchain/provider';
import { DEFAULT_NETWORK } from '@/config/networks';
import type { NetworkConfig } from '@/model/NetworkEnum';
import { useOnboard } from "@web3-onboard/vue";
interface MenuOption {
label: string;
route?: string;
action?: () => void;
showInDesktop?: boolean;
showInMobile?: boolean;
isDynamic?: boolean;
dynamicLabel?: () => string;
dynamicRoute?: () => string;
showVersion?: boolean;
}
import ChevronDown from "@/assets/chevronDown.svg";
import TwitterIcon from "@/assets/twitterIcon.svg";
import LinkedinIcon from "@/assets/linkedinIcon.svg";
import GithubIcon from "@/assets/githubIcon.svg";
import { connectProvider } from "@/blockchain/provider";
// Use the new composable
const user = useUser();
const { walletAddress, sellerView, network } = user;
const { walletAddress, sellerView, networkId } = user;
const menuOpenToggle = ref<boolean>(false);
const infoMenuOpenToggle = ref<boolean>(false);
@@ -47,39 +35,40 @@ const connnectWallet = async (): Promise<void> => {
watch(connectedWallet, async (newVal: any) => {
connectProvider(newVal.provider);
const addresses = await newVal.provider.request({ method: 'eth_accounts' });
const addresses = await newVal.provider.request({ method: "eth_accounts" });
user.setWalletAddress(addresses.shift());
});
watch(connectedChain, (newVal: any) => {
// Check if connected chain is valid, otherwise default to Sepolia
// Check if connected chain is valid, otherwise default to Sepolia (NetworkEnum.SEPOLIA)
if (
!newVal ||
!Object.values(Networks).some((network) => network.id === Number(newVal.id))
!Object.values(Networks).some(
(network) => Number(network.chainId) === Number(newVal.id)
)
) {
console.log(
'Invalid or unsupported network detected, defaulting to Sepolia',
"Invalid or unsupported network detected, defaulting to Sepolia"
);
user.setNetwork(DEFAULT_NETWORK);
user.setNetworkId(user.networkId.value);
return;
}
user.setNetworkById(newVal?.id);
user.setNetworkId(newVal?.id);
});
const formatWalletAddress = (): string => {
if (!walletAddress.value) throw new Error('Wallet not connected');
const walletAddressLength = walletAddress.value.length;
const initialText = walletAddress.value.substring(0, 5);
const finalText = walletAddress.value.substring(
walletAddressLength - 4,
walletAddressLength,
walletAddressLength
);
return `${initialText}...${finalText}`;
};
const disconnectUser = async (): Promise<void> => {
user.setWalletAddress(null);
await disconnectWallet({ label: connectedWallet.value?.label || '' });
user.setWalletAddress("");
await disconnectWallet({ label: connectedWallet.value?.label || "" });
closeMenu();
};
@@ -87,24 +76,16 @@ const closeMenu = (): void => {
menuOpenToggle.value = false;
};
const networkChange = async (network: NetworkConfig): Promise<void> => {
const networkChange = async (network: NetworkEnum): Promise<void> => {
currencyMenuOpenToggle.value = false;
// If wallet is connected, try to change chain in wallet
if (connectedWallet.value) {
const chainId = network.id.toString(16);
try {
await setChain({
chainId: `0x${chainId}`,
wallet: connectedWallet.value.label,
chainId: Networks[network].chainId,
wallet: connectedWallet.value?.label || "",
});
user.setNetwork(network);
user.setNetworkId(network);
} catch (error) {
console.log('Error changing network', error);
}
} else {
// If no wallet connected, just update the network state
user.setNetwork(network);
console.log("Error changing network", error);
}
};
@@ -119,83 +100,11 @@ onClickOutside(currencyRef, () => {
onClickOutside(infoMenuRef, () => {
infoMenuOpenToggle.value = false;
});
const infoMenuOptions: MenuOption[] = [
{
label: 'Explorar Transações',
route: '/explore',
showInDesktop: true,
showInMobile: false,
},
{
label: 'Perguntas frequentes',
route: '/faq',
showInDesktop: true,
showInMobile: false,
},
{
label: 'Versões',
route: '/versions',
showInDesktop: true,
showInMobile: false,
},
];
const walletMenuOptions: MenuOption[] = [
{
label: 'Quero vender',
isDynamic: true,
dynamicLabel: () => (sellerView.value ? 'Quero comprar' : 'Quero vender'),
dynamicRoute: () => (sellerView.value ? '/' : '/seller'),
showInDesktop: false,
showInMobile: true,
},
{
label: 'Explorar Transações',
route: '/explore',
showInDesktop: false,
showInMobile: true,
},
{
label: 'Gerenciar Ofertas',
route: '/manage_bids',
showInDesktop: true,
showInMobile: true,
},
{
label: 'Perguntas frequentes',
route: '/faq',
showInDesktop: false,
showInMobile: true,
},
{
label: 'Versões',
route: '/versions',
showInDesktop: false,
showInMobile: true,
},
{
label: 'Desconectar',
route: '/',
action: disconnectUser,
showInDesktop: true,
showInMobile: true,
},
];
const handleMenuOptionClick = (option: MenuOption): void => {
if (!option.action) {
closeMenu();
}
};
</script>
<template>
<header class="z-20">
<RouterLink
:to="'/'"
class="default-button flex items-center md:h-auto md:py-2 h-10 py-0"
>
<RouterLink :to="'/'" class="default-button">
<img
alt="P2Pix logo"
class="logo hidden md:inline-block"
@@ -205,7 +114,7 @@ const handleMenuOptionClick = (option: MenuOption): void => {
/>
<img
alt="P2Pix logo"
class="logo inline-block md:hidden h-10"
class="logo inline-block md:hidden w-10/12"
width="40"
height="40"
src="@/assets/logo2.svg?url"
@@ -217,11 +126,13 @@ const handleMenuOptionClick = (option: MenuOption): void => {
<button
ref="infoMenuRef"
class="default-button hidden md:inline-block cursor-pointer"
@click="[
@click="
[
(infoMenuOpenToggle = !infoMenuOpenToggle),
(menuOpenToggle = false),
(currencyMenuOpenToggle = false),
]"
]
"
>
<h1
class="topbar-text topbar-link"
@@ -241,45 +152,26 @@ const handleMenuOptionClick = (option: MenuOption): void => {
>
<div class="mt-2">
<div class="bg-white rounded-md z-10 -left-36 w-52">
<template
v-for="(option, index) in infoMenuOptions.filter(
(opt) => opt.showInDesktop,
)"
:key="index"
<div class="menu-button gap-2 px-4 rounded-md cursor-pointer">
<span
class="text-gray-900 py-4 text-end font-semibold text-sm"
>
Documentação
</span>
</div>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
<RouterLink
v-if="option.route"
:to="option.route"
:to="'/faq'"
class="menu-button gap-2 px-4 rounded-md cursor-pointer"
>
<span
class="text-gray-900 py-4 text-end font-semibold text-sm whitespace-nowrap"
>
{{ option.label }}
Perguntas frequentes
</span>
</RouterLink>
<div
v-else
class="menu-button gap-2 px-4 rounded-md cursor-pointer"
>
<span
class="text-gray-900 py-4 text-end font-semibold text-sm"
>
{{ option.label }}
</span>
</div>
<div
v-if="
index <
infoMenuOptions.filter((opt) => opt.showInDesktop)
.length -
1
"
class="w-full flex justify-center"
>
<hr class="w-4/5" />
</div>
</template>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
@@ -325,28 +217,46 @@ const handleMenuOptionClick = (option: MenuOption): void => {
</div>
</transition>
</div>
<RouterLink
:to="'/faq'"
v-if="!walletAddress"
class="default-button inline-block md:hidden"
>
FAQ
</RouterLink>
<RouterLink
:to="sellerView ? '/' : '/seller'"
class="default-button whitespace-nowrap w-40 sm:w-44 md:w-36 hidden md:inline-block"
>
<div class="topbar-text topbar-link text-center mx-auto inline-block">
{{ sellerView ? 'Quero comprar' : 'Quero vender' }}
{{ sellerView ? "Quero comprar" : "Quero vender" }}
</div>
</RouterLink>
<div class="flex flex-col relative">
<RouterLink
:to="sellerView ? '/' : '/seller'"
v-if="!walletAddress"
class="default-button sm:whitespace-normal whitespace-nowrap inline-block md:hidden w-40 sm:w-44 md:w-36"
>
<div class="topbar-text topbar-link text-center mx-auto inline-block">
{{ sellerView ? "Quero comprar" : "Quero vender" }}
</div>
</RouterLink>
<div class="flex flex-col relative" v-if="walletAddress">
<div
ref="currencyRef"
class="top-bar-info cursor-pointer h-10 group hover:bg-gray-50 transition-all duration-500 ease-in-out"
:class="{ 'bg-gray-50': currencyMenuOpenToggle }"
@click="[
@click="
[
(currencyMenuOpenToggle = !currencyMenuOpenToggle),
(menuOpenToggle = false),
(infoMenuOpenToggle = false),
]"
]
"
>
<img
alt="Choosed network image"
:src="getNetworkImage(network.name)"
:src="getNetworkImage(NetworkEnum[user.networkName.value])"
height="24"
width="24"
/>
@@ -354,7 +264,11 @@ const handleMenuOptionClick = (option: MenuOption): void => {
class="default-text hidden sm:inline-block text-gray-50 group-hover:text-gray-900 transition-all duration-500 ease-in-out whitespace-nowrap text-ellipsis overflow-hidden"
:class="{ '!text-gray-900': currencyMenuOpenToggle }"
>
{{ user.network.value.name || 'Invalid Chain' }}
{{
Networks[user.networkName.value]
? Networks[user.networkName.value].chainName
: "Invalid Chain"
}}
</span>
<div
class="transition-all duration-500 ease-in-out mt-1"
@@ -376,20 +290,20 @@ const handleMenuOptionClick = (option: MenuOption): void => {
class="mt-2 bg-white rounded-md border border-gray-300 drop-shadow-md shadow-md overflow-clip"
>
<div
v-for="network in Networks"
:key="network.id"
v-for="(chainData, network) in Networks"
:key="network"
class="menu-button p-4 gap-2 cursor-pointer hover:bg-gray-200 flex items-center !justify-start whitespace-nowrap transition-colors duration-150 ease-in-out"
@click="networkChange(network)"
>
<img
:alt="network.name + ' image'"
:alt="chainData.chainName + ' image'"
width="20"
height="20"
:src="getNetworkImage(network.name)"
:src="getNetworkImage(NetworkEnum[network])"
class="mr-2 ml-1"
/>
<span class="text-gray-900 font-semibold text-sm">
{{ network.name }}
{{ chainData.chainName }}
</span>
</div>
<div class="w-full flex justify-center">
@@ -402,7 +316,7 @@ const handleMenuOptionClick = (option: MenuOption): void => {
<button
type="button"
v-if="!walletAddress"
class="border-amber-500 border-2 sm:rounded !rounded-lg default-button hidden md:inline-block"
class="border-amber-500 border-2 rounded default-button hidden md:inline-block"
@click="connnectWallet()"
>
Conectar carteira
@@ -410,7 +324,7 @@ const handleMenuOptionClick = (option: MenuOption): void => {
<button
type="button"
v-if="!walletAddress"
class="border-amber-500 border-2 sm:rounded !rounded-lg default-button inline-block md:hidden h-10"
class="border-amber-500 border-2 rounded default-button inline-block md:hidden"
@click="connnectWallet()"
>
Conectar
@@ -421,11 +335,13 @@ const handleMenuOptionClick = (option: MenuOption): void => {
ref="walletAddressRef"
class="top-bar-info cursor-pointer h-10 group hover:bg-gray-50 transition-all duration-500 ease-in-out"
:class="{ 'bg-gray-50': menuOpenToggle }"
@click="[
@click="
[
(menuOpenToggle = !menuOpenToggle),
(currencyMenuOpenToggle = false),
(infoMenuOpenToggle = false),
]"
]
"
>
<img alt="Account image" src="@/assets/account.svg?url" />
<span
@@ -453,41 +369,24 @@ const handleMenuOptionClick = (option: MenuOption): void => {
<div class="pl-4 mt-2">
<div
class="bg-white rounded-md z-10 border border-gray-300 drop-shadow-md shadow-md overflow-clip"
>
<template
v-for="(option, index) in walletMenuOptions.filter(
(opt) => opt.showInDesktop,
)"
:key="index"
>
<RouterLink
v-if="option.route && !option.action"
:to="option.route"
to="/manage_bids"
class="redirect_button menu-button"
@click="closeMenu()"
>
{{ option.label }}
Gerenciar Ofertas
</RouterLink>
<RouterLink
v-else-if="option.route && option.action"
:to="option.route"
class="redirect_button menu-button"
@click="option.action"
>
{{ option.label }}
</RouterLink>
<div
v-if="
index <
walletMenuOptions.filter((opt) => opt.showInDesktop)
.length -
1
"
class="w-full flex justify-center"
>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
</template>
<RouterLink
to="/"
class="redirect_button menu-button"
@click="disconnectUser"
>
Desconectar
</RouterLink>
</div>
</div>
</div>
@@ -499,49 +398,32 @@ const handleMenuOptionClick = (option: MenuOption): void => {
v-show="menuOpenToggle"
class="mobile-menu fixed w-4/5 text-gray-900 inline-block md:hidden"
>
<div class="pl-4 h-full">
<div class="pl-4 mt-2 h-full">
<div class="bg-white rounded-md z-10 h-full">
<template
v-for="(option, index) in walletMenuOptions.filter(
(opt) => opt.showInMobile,
)"
:key="index"
>
<div class="menu-button" @click="handleMenuOptionClick(option)">
<div class="menu-button" @click="closeMenu()">
<RouterLink
v-if="option.isDynamic"
:to="option.dynamicRoute ? option.dynamicRoute() : '/'"
class="redirect_button"
:class="{ 'mt-2': index === 0 }"
:to="sellerView ? '/' : '/seller'"
class="redirect_button mt-2"
>
{{ option.dynamicLabel ? option.dynamicLabel() : option.label }}
</RouterLink>
<RouterLink
v-else-if="option.route && !option.action"
:to="option.route"
class="redirect_button"
>
{{ option.label }}
</RouterLink>
<RouterLink
v-else-if="option.route && option.action"
:to="option.route"
class="redirect_button"
@click.stop="option.action"
>
{{ option.label }}
{{ sellerView ? "Quero comprar" : "Quero vender" }}
</RouterLink>
</div>
<div
v-if="
index <
walletMenuOptions.filter((opt) => opt.showInMobile).length - 1
"
class="w-full flex justify-center"
>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
</template>
<div class="menu-button" @click="closeMenu()">
<RouterLink to="/manage_bids" class="redirect_button">
Gerenciar Ofertas
</RouterLink>
</div>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
<div class="menu-button" @click="disconnectUser">
<RouterLink to="/" class="redirect_button">
Desconectar
</RouterLink>
</div>
<div class="w-full flex justify-center">
<hr class="w-4/5" />
</div>
@@ -575,22 +457,22 @@ const handleMenuOptionClick = (option: MenuOption): void => {
v-show="currencyMenuOpenToggle"
class="mobile-menu fixed w-4/5 text-gray-900 inline-block md:hidden"
>
<div class="pl-4 h-full">
<div class="pl-4 mt-2 h-full">
<div class="bg-white rounded-md z-10 h-full">
<div
v-for="network in Networks"
:key="network.id"
v-for="(chainData, network) in Networks"
:key="network"
class="menu-button gap-2 sm:px-4 rounded-md cursor-pointer py-2 px-4"
@click="networkChange(network)"
>
<img
:alt="network.name + 'image'"
:alt="chainData.chainName + 'image'"
width="20"
height="20"
:src="getNetworkImage(network.name)"
:src="getNetworkImage(NetworkEnum[network])"
/>
<span class="text-gray-900 py-4 text-end font-bold text-sm">
{{ network.name }}
{{ chainData.chainName }}
</span>
</div>
</div>
@@ -600,7 +482,6 @@ const handleMenuOptionClick = (option: MenuOption): void => {
</template>
<style scoped>
@reference "tailwindcss";
header {
@apply flex flex-row justify-between w-full items-center;
}
@@ -641,14 +522,8 @@ a:hover {
}
.mobile-menu {
top: 60px;
right: 10px;
left: auto;
max-height: calc(100vh - 60px);
overflow-y: auto;
border-radius: 8px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
margin-top: 1400px;
bottom: 0px;
height: auto;
}
</style>

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import TopBar from "../TopBar.vue";
import { useUser } from "@/composables/useUser";
import { createPinia, setActivePinia } from "pinia";
describe("TopBar.vue", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it("should render connect wallet button", () => {
const wrapper = mount(TopBar);
expect(wrapper.html()).toContain("Conectar carteira");
});
it("should render button to change to seller view when in buyer screen", () => {
const wrapper = mount(TopBar);
expect(wrapper.html()).toContain("Quero vender");
});
it("should render button to change to seller view when in buyer screen", () => {
const user = useUser();
user.setSellerView(true);
const wrapper = mount(TopBar);
expect(wrapper.html()).toContain("Quero comprar");
});
it("should render the P2Pix logo correctly", () => {
const wrapper = mount(TopBar);
const img = wrapper.findAll(".logo");
expect(img.length).toBe(2);
});
});

View File

@@ -1,162 +0,0 @@
<script setup lang="ts">
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
const props = withDefaults(
defineProps<{
text: string;
isDisabled?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: string;
iconPosition?: 'left' | 'right';
fullWidth?: boolean;
loading?: boolean;
}>(),
{
isDisabled: false,
variant: 'primary',
size: 'xl',
iconPosition: 'left',
fullWidth: true,
loading: false,
},
);
const emit = defineEmits(['buttonClicked']);
const handleClick = () => {
if (!props.isDisabled && !props.loading) {
emit('buttonClicked');
}
};
</script>
<template>
<button
type="button"
:class="[
'button',
`variant-${variant}`,
`size-${size}`,
{ 'is-disabled': isDisabled || loading, 'full-width': fullWidth },
]"
:disabled="isDisabled || loading"
@click="handleClick"
>
<span v-if="loading" class="loader"></span>
<template v-else>
<img
v-if="icon && iconPosition === 'left'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
<span class="button-text">{{ text }}</span>
<img
v-if="icon && iconPosition === 'right'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
</template>
</button>
</template>
<style scoped>
@reference "tailwindcss";
.button {
@apply rounded-lg font-semibold transition-all duration-200 cursor-pointer flex items-center justify-center gap-2;
}
.button:hover:not(.is-disabled) {
@apply transform scale-[1.02];
}
.button.is-disabled {
@apply opacity-70 cursor-not-allowed;
}
.button.full-width {
@apply w-full;
}
/* Variantes */
.variant-primary {
@apply bg-amber-400 text-gray-900 border-2 border-amber-400;
}
.variant-primary:hover:not(.is-disabled) {
@apply bg-amber-500 border-amber-500;
}
.variant-secondary {
@apply bg-gray-200 text-gray-900 border-2 border-gray-300;
}
.variant-secondary:hover:not(.is-disabled) {
@apply bg-gray-300 border-gray-400;
}
.variant-outline {
@apply bg-transparent text-gray-900 border-2 border-amber-400;
}
.variant-outline:hover:not(.is-disabled) {
@apply bg-amber-400/10;
}
.variant-ghost {
@apply bg-transparent text-gray-900 border-2 border-transparent;
}
.variant-ghost:hover:not(.is-disabled) {
@apply bg-gray-100;
}
/* Tamanhos */
.size-sm {
@apply px-2 py-1 text-xs;
}
.size-sm .button-icon {
@apply w-3 h-3;
}
.size-md {
@apply px-3 py-2 text-sm;
}
.size-md .button-icon {
@apply w-4 h-4;
}
.size-lg {
@apply px-4 py-3 text-base;
}
.size-lg .button-icon {
@apply w-5 h-5;
}
.size-xl {
@apply p-4 text-base;
}
.size-xl .button-icon {
@apply w-5 h-5;
}
.button-icon {
@apply flex-shrink-0;
}
.button-text {
@apply font-semibold;
}
/* Loader animation */
.loader {
@apply w-5 h-5 border-2 border-gray-900 border-t-transparent rounded-full animate-spin;
}
</style>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
type FormCardPadding = 'sm' | 'md' | 'lg';
const props = withDefaults(
defineProps<{
padding?: FormCardPadding;
fullWidth?: boolean;
noBorder?: boolean;
}>(),
{
padding: 'md',
fullWidth: true,
noBorder: false,
},
);
</script>
<template>
<div
:class="[
'form-card',
`padding-${padding}`,
{ 'full-width': fullWidth, 'no-border': noBorder },
]"
>
<slot></slot>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.form-card {
@apply flex flex-col bg-white rounded-lg;
}
.form-card:not(.no-border) {
@apply border-y;
}
.form-card.full-width {
@apply w-full;
}
.padding-sm {
@apply px-4 py-3;
}
.padding-md {
@apply sm:px-10 px-6 py-5;
}
.padding-lg {
@apply px-12 py-8;
}
</style>

View File

@@ -1,147 +0,0 @@
<script setup lang="ts">
type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
type IconButtonSize = 'sm' | 'md' | 'lg';
type IconPosition = 'left' | 'right';
const props = withDefaults(
defineProps<{
text: string;
icon?: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
iconPosition?: IconPosition;
disabled?: boolean;
fullWidth?: boolean;
}>(),
{
variant: 'outline',
size: 'md',
iconPosition: 'left',
disabled: false,
fullWidth: false,
},
);
const emit = defineEmits<{
click: [];
}>();
const handleClick = () => {
if (!props.disabled) {
emit('click');
}
};
</script>
<template>
<button
type="button"
:class="[
'icon-button',
`variant-${variant}`,
`size-${size}`,
{ 'is-disabled': disabled, 'full-width': fullWidth },
]"
:disabled="disabled"
@click="handleClick"
>
<img
v-if="icon && iconPosition === 'left'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
<span class="button-text">{{ text }}</span>
<img
v-if="icon && iconPosition === 'right'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
</button>
</template>
<style scoped>
@reference "tailwindcss";
.icon-button {
@apply flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 cursor-pointer;
}
.icon-button:hover:not(.is-disabled) {
@apply transform scale-[1.02];
}
.icon-button.is-disabled {
@apply opacity-60 cursor-not-allowed;
}
.icon-button.full-width {
@apply w-full;
}
/* Variantes */
.variant-primary {
@apply bg-amber-400 text-gray-900 border-2 border-amber-400;
}
.variant-primary:hover:not(.is-disabled) {
@apply bg-amber-500 border-amber-500;
}
.variant-secondary {
@apply bg-gray-200 text-gray-900 border-2 border-gray-300;
}
.variant-secondary:hover:not(.is-disabled) {
@apply bg-gray-300 border-gray-400;
}
.variant-outline {
@apply bg-transparent text-gray-900 border-2 border-amber-300;
}
.variant-outline:hover:not(.is-disabled) {
@apply bg-amber-300/10;
}
.variant-ghost {
@apply bg-transparent text-gray-900 border-2 border-transparent;
}
.variant-ghost:hover:not(.is-disabled) {
@apply bg-gray-100;
}
/* Tamanhos */
.size-sm {
@apply px-2 py-1 text-xs;
}
.size-sm .button-icon {
@apply w-3 h-3;
}
.size-md {
@apply px-3 py-2 text-sm;
}
.size-md .button-icon {
@apply w-4 h-4;
}
.size-lg {
@apply px-4 py-3 text-base;
}
.size-lg .button-icon {
@apply w-5 h-5;
}
.button-text {
@apply font-semibold;
}
.button-icon {
@apply flex-shrink-0;
}
</style>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
export type StatusType = 'open' | 'expired' | 'completed' | 'pending';
const props = defineProps<{
status: StatusType;
customText?: string;
}>();
const statusConfig = computed(() => {
const configs: Record<StatusType, { text: string; color: string }> = {
open: {
text: 'Em Aberto',
color: 'bg-amber-300',
},
expired: {
text: 'Expirado',
color: 'bg-[#94A3B8]',
},
completed: {
text: 'Finalizado',
color: 'bg-emerald-300',
},
pending: {
text: 'Pendente',
color: 'bg-gray-300',
},
};
return configs[props.status];
});
const displayText = computed(() => {
return props.customText || statusConfig.value.text;
});
</script>
<template>
<div :class="[statusConfig.color, 'status-badge']">
{{ displayText }}
</div>
</template>
<style scoped>
@reference "tailwindcss";
.status-badge {
@apply text-xs sm:text-base font-medium text-gray-900 rounded-lg text-center px-2 py-1;
}
</style>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
const version =
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev';
</script>
<template>
<div
class="fixed bottom-2 left-2 text-xs font-mono text-gray-50/40 hover:text-gray-50/80 transition-opacity pointer-events-none select-none z-10"
:title="`P2Pix ${version}`"
aria-label="Application version"
>
v{{ version }}
</div>
</template>

View File

@@ -1,468 +0,0 @@
import { NetworkConfig } from '@/model/NetworkEnum';
import { ref, computed, type Ref } from 'vue';
import { sepolia, rootstock, rootstockTestnet } from 'viem/chains';
export interface Transaction {
id: string;
type: 'deposit' | 'lock' | 'release' | 'return';
timestamp: string;
blockTimestamp: string;
seller?: string;
buyer?: string | null;
amount: string;
token: string;
blockNumber: string;
transactionHash: string;
}
export interface AnalyticsData {
totalVolume: string;
totalTransactions: string;
totalLocks: string;
totalDeposits: string;
totalReleases: string;
}
export function useGraphQL(network: Ref<NetworkConfig>) {
const searchAddress = ref('');
const selectedType = ref('all');
const loading = ref(false);
const error = ref<string | null>(null);
const analyticsLoading = ref(false);
const transactionsData = ref<Transaction[]>([]);
const analyticsData = ref<AnalyticsData>({
totalVolume: '0',
totalTransactions: '0',
totalLocks: '0',
totalDeposits: '0',
totalReleases: '0',
});
const executeQuery = async (query: string, variables: any = {}) => {
const url = network.value.subgraphUrls[0]; // TODO: try all available URLs
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.errors) {
throw new Error(data.errors[0]?.message || 'GraphQL error');
}
return data.data;
} catch (err) {
console.error('GraphQL query error:', err);
throw err;
}
};
const fetchAllActivity = async () => {
loading.value = true;
error.value = null;
const query = `
query GetAllActivity($first: Int = 50) {
depositAddeds(first: $first, orderBy: "blockTimestamp", orderDirection: "desc") {
id
seller
token
amount
blockNumber
blockTimestamp
transactionHash
}
depositWithdrawns(first: $first, orderBy: "blockTimestamp", orderDirection: "desc") {
id
seller
token
amount
blockNumber
blockTimestamp
transactionHash
}
lockAddeds(first: $first, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockID
seller
amount
blockNumber
blockTimestamp
transactionHash
}
lockReleaseds(first: $first, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockId
amount
blockNumber
blockTimestamp
transactionHash
}
lockReturneds(first: $first, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockId
blockNumber
blockTimestamp
transactionHash
}
}
`;
try {
const data = await executeQuery(query, { first: 50 });
transactionsData.value = processActivityData(data);
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch transactions';
} finally {
loading.value = false;
}
};
const fetchUserActivity = async (userAddress: string) => {
loading.value = true;
error.value = null;
const query = `
query GetUserActivity($userAddress: String!, $first: Int = 50) {
depositAddeds(first: $first, where: { seller: $userAddress }, orderBy: "blockTimestamp", orderDirection: "desc") {
id
seller
token
amount
blockNumber
blockTimestamp
transactionHash
}
depositWithdrawns(first: $first, where: { seller: $userAddress }, orderBy: "blockTimestamp", orderDirection: "desc") {
id
seller
token
amount
blockNumber
blockTimestamp
transactionHash
}
lockAddeds(first: $first, where: { buyer: $userAddress }, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockID
seller
amount
blockNumber
blockTimestamp
transactionHash
}
lockReleaseds(first: $first, where: { buyer: $userAddress }, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockId
amount
blockNumber
blockTimestamp
transactionHash
}
lockReturneds(first: $first, where: { buyer: $userAddress }, orderBy: "blockTimestamp", orderDirection: "desc") {
id
buyer
lockId
blockNumber
blockTimestamp
transactionHash
}
}
`;
try {
const data = await executeQuery(query, { userAddress, first: 50 });
transactionsData.value = processActivityData(data);
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to fetch user transactions';
} finally {
loading.value = false;
}
};
const clearData = () => {
transactionsData.value = [];
analyticsData.value = {
totalVolume: '0',
totalTransactions: '0',
totalLocks: '0',
totalDeposits: '0',
totalReleases: '0',
};
};
const fetchAnalytics = async () => {
analyticsLoading.value = true;
const query = `
query GetAnalytics {
depositAddeds(first: 1000) {
amount
blockTimestamp
}
depositWithdrawns(first: 1000) {
amount
blockTimestamp
}
lockAddeds(first: 1000) {
amount
blockTimestamp
}
lockReleaseds(first: 1000) {
amount
blockTimestamp
}
lockReturneds(first: 1000) {
blockTimestamp
}
}
`;
try {
const data = await executeQuery(query);
analyticsData.value = processAnalyticsData(data);
} catch (err) {
console.error('Failed to fetch analytics:', err);
} finally {
analyticsLoading.value = false;
}
};
const processActivityData = (data: any): Transaction[] => {
if (!data) return [];
const activities: Transaction[] = [];
if (data.depositAddeds) {
data.depositAddeds.forEach((deposit: any) => {
activities.push({
id: deposit.id,
blockNumber: deposit.blockNumber,
blockTimestamp: deposit.blockTimestamp,
transactionHash: deposit.transactionHash,
type: 'deposit',
seller: deposit.seller,
buyer: undefined,
amount: deposit.amount,
token: deposit.token,
timestamp: formatTimestamp(deposit.blockTimestamp),
});
});
}
if (data.depositWithdrawns) {
data.depositWithdrawns.forEach((withdrawal: any) => {
activities.push({
id: withdrawal.id,
blockNumber: withdrawal.blockNumber,
blockTimestamp: withdrawal.blockTimestamp,
transactionHash: withdrawal.transactionHash,
type: 'deposit', // Treat as deposit withdrawal
seller: withdrawal.seller,
buyer: undefined,
amount: withdrawal.amount,
token: withdrawal.token,
timestamp: formatTimestamp(withdrawal.blockTimestamp),
});
});
}
if (data.lockAddeds) {
data.lockAddeds.forEach((lock: any) => {
activities.push({
id: lock.id,
blockNumber: lock.blockNumber,
blockTimestamp: lock.blockTimestamp,
transactionHash: lock.transactionHash,
type: 'lock',
seller: lock.seller,
buyer: lock.buyer,
amount: lock.amount,
token: lock.token,
timestamp: formatTimestamp(lock.blockTimestamp),
});
});
}
if (data.lockReleaseds) {
data.lockReleaseds.forEach((release: any) => {
activities.push({
id: release.id,
blockNumber: release.blockNumber,
blockTimestamp: release.blockTimestamp,
transactionHash: release.transactionHash,
type: 'release',
seller: undefined, // Release doesn't have seller info
buyer: release.buyer,
amount: release.amount,
token: 'BRZ', // Default token
timestamp: formatTimestamp(release.blockTimestamp),
});
});
}
if (data.lockReturneds) {
data.lockReturneds.forEach((returnTx: any) => {
activities.push({
id: returnTx.id,
blockNumber: returnTx.blockNumber,
blockTimestamp: returnTx.blockTimestamp,
transactionHash: returnTx.transactionHash,
type: 'return',
seller: undefined, // Return doesn't have seller info
buyer: returnTx.buyer,
amount: '0', // Return doesn't have amount
token: 'BRZ', // Default token
timestamp: formatTimestamp(returnTx.blockTimestamp),
});
});
}
return activities.sort(
(a, b) => parseInt(b.blockTimestamp) - parseInt(a.blockTimestamp),
);
};
const formatTimestamp = (timestamp: string): string => {
const now = Date.now() / 1000;
const diff = now - parseInt(timestamp);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
return `${Math.floor(diff / 86400)} days ago`;
};
const formatAmount = (amount: string): string => {
const num = parseFloat(amount);
if (num >= 1000000000000000)
return `${(num / 1000000000000000).toFixed(1)}Q`;
if (num >= 1000000000000) return `${(num / 1000000000000).toFixed(1)}T`;
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`;
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
if (num < 1) return num.toFixed(4);
return num.toFixed(2);
};
const processAnalyticsData = (data: any): AnalyticsData => {
if (!data) {
return {
totalVolume: '0',
totalTransactions: '0',
totalLocks: '0',
totalDeposits: '0',
totalReleases: '0',
};
}
let totalVolume = 0;
let totalTransactions = 0;
let totalLocks = 0;
let totalDeposits = 0;
let totalReleases = 0;
if (data.depositAddeds) {
data.depositAddeds.forEach((deposit: any) => {
totalVolume += parseFloat(deposit.amount || '0');
totalTransactions++;
totalDeposits++;
});
}
if (data.depositWithdrawns) {
data.depositWithdrawns.forEach((withdrawal: any) => {
totalVolume += parseFloat(withdrawal.amount || '0');
totalTransactions++;
});
}
if (data.lockAddeds) {
data.lockAddeds.forEach((lock: any) => {
totalVolume += parseFloat(lock.amount || '0');
totalTransactions++;
totalLocks++;
});
}
if (data.lockReleaseds) {
data.lockReleaseds.forEach((release: any) => {
totalVolume += parseFloat(release.amount || '0');
totalTransactions++;
totalReleases++;
});
}
if (data.lockReturneds) {
data.lockReturneds.forEach((returnTx: any) => {
totalTransactions++;
});
}
const result = {
totalVolume: formatAmount(totalVolume.toString()),
totalTransactions: totalTransactions.toString(),
totalLocks: totalLocks.toString(),
totalDeposits: totalDeposits.toString(),
totalReleases: totalReleases.toString(),
};
return result;
};
const filteredTransactions = computed(() => {
let filtered = transactionsData.value;
if (selectedType.value !== 'all') {
filtered = filtered.filter((tx) => tx.type === selectedType.value);
}
if (searchAddress.value) {
const searchLower = searchAddress.value.toLowerCase();
filtered = filtered.filter(
(tx) =>
tx.seller?.toLowerCase().includes(searchLower) ||
tx.buyer?.toLowerCase().includes(searchLower),
);
}
return filtered;
});
return {
searchAddress,
selectedType,
transactions: filteredTransactions,
analytics: analyticsData,
loading,
error,
analyticsLoading,
fetchAllActivity,
fetchUserActivity,
fetchAnalytics,
clearData,
};
}

View File

@@ -1,25 +1,25 @@
import { ref } from 'vue';
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { Participant } from '../utils/bbPay';
import type { Address } from 'viem';
import { DEFAULT_NETWORK, Networks } from '@/config/networks';
import { TokenEnum, NetworkConfig } from '@/model/NetworkEnum';
import { ref } from "vue";
import { NetworkEnum, TokenEnum } from "../model/NetworkEnum";
import type { ValidDeposit } from "@/model/ValidDeposit";
import type { Participant } from "../utils/bbPay";
import { NetworkById } from "@/model/Networks";
const walletAddress = ref<Address | null>(null);
const balance = ref('');
const network = ref(DEFAULT_NETWORK);
const selectedToken = ref<TokenEnum>(TokenEnum.BRZ);
const walletAddress = ref("");
const balance = ref("");
const networkId = ref(11155111);
const networkName = ref(NetworkEnum.sepolia);
const selectedToken = ref(TokenEnum.BRZ);
const loadingLock = ref(false);
const sellerView = ref(false);
const depositsValidList = ref<ValidDeposit[]>([]);
const loadingWalletTransactions = ref(false);
const loadingNetworkLiquidity = ref(false);
const seller = ref<Participant>({} as Participant);
const sellerId = ref('');
const sellerId = ref("");
export function useUser() {
// Actions become regular functions
const setWalletAddress = (address: Address | null) => {
const setWalletAddress = (address: string) => {
walletAddress.value = address;
};
@@ -31,29 +31,9 @@ export function useUser() {
selectedToken.value = token;
};
const setNetwork = (chain: NetworkConfig) => {
network.value = chain;
};
const setNetworkById = (id: string | number) => {
let chainId: number;
if (typeof id === 'string') {
// Parse hex string or number string to number
if (id.startsWith('0x')) {
chainId = parseInt(id, 16);
} else {
chainId = parseInt(id, 10);
}
} else {
chainId = id;
}
// Find network by chain ID
const chain = Object.values(Networks).find((n) => n.id === chainId);
if (chain) {
network.value = chain;
}
const setNetworkId = (network: string | number) => {
networkName.value = NetworkById(network) || NetworkEnum.sepolia;
networkId.value = Number(network);
};
const setLoadingLock = (isLoading: boolean) => {
@@ -95,7 +75,8 @@ export function useUser() {
// State
walletAddress,
balance,
network,
networkId,
networkName,
selectedToken,
loadingLock,
sellerView,
@@ -109,8 +90,7 @@ export function useUser() {
setWalletAddress,
setBalance,
setSelectedToken,
setNetwork,
setNetworkById,
setNetworkId,
setLoadingLock,
setSellerView,
setDepositsValidList,

View File

@@ -1,60 +0,0 @@
import { mainnet, sepolia, rootstock, rootstockTestnet } from 'viem/chains';
import { NetworkConfig } from '@/model/NetworkEnum';
// TODO: import addresses from p2pix-smart-contracts deployments
export const Networks: { [key: string]: NetworkConfig } = {
mainnet: {
...mainnet,
rpcUrls: { default: { http: [import.meta.env.VITE_MAINNET_API_URL] } },
contracts: {
...mainnet.contracts,
p2pix: { address: import.meta.env.VITE_MAINNET_P2PIX_ADDRESS },
},
tokens: {
BRZ: { address: import.meta.env.VITE_MAINNET_TOKEN_ADDRESS },
},
subgraphUrls: [import.meta.env.VITE_MAINNET_SUBGRAPH_URL],
},
rootstock: {
...rootstock,
rpcUrls: { default: { http: [import.meta.env.VITE_RSK_API_URL] } },
contracts: {
...rootstock.contracts,
p2pix: { address: import.meta.env.VITE_RSK_P2PIX_ADDRESS },
},
tokens: {
BRZ: { address: import.meta.env.VITE_RSK_TOKEN_ADDRESS },
},
subgraphUrls: [import.meta.env.VITE_RSK_SUBGRAPH_URL],
},
};
/** @public */
export const NetworksTestnet: { [key: string]: NetworkConfig } = {
sepolia: {
...sepolia,
rpcUrls: { default: { http: [import.meta.env.VITE_SEPOLIA_API_URL] } },
contracts: {
...sepolia.contracts,
p2pix: { address: import.meta.env.VITE_SEPOLIA_P2PIX_ADDRESS },
},
tokens: {
BRZ: { address: import.meta.env.VITE_SEPOLIA_TOKEN_ADDRESS },
},
subgraphUrls: [import.meta.env.VITE_SEPOLIA_SUBGRAPH_URL],
},
rootstockTestnet: {
...rootstockTestnet,
rpcUrls: { default: { http: [import.meta.env.VITE_RSK_API_URL] } },
contracts: {
...rootstockTestnet.contracts,
p2pix: { address: import.meta.env.VITE_RSK_P2PIX_ADDRESS },
},
tokens: {
BRZ: { address: import.meta.env.VITE_RSK_TOKEN_ADDRESS },
},
subgraphUrls: [import.meta.env.VITE_RSK_SUBGRAPH_URL],
},
};
export const DEFAULT_NETWORK = Networks.mainnet;

View File

@@ -1,12 +1,12 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import './assets/main.css';
import './assets/transitions.css';
import "./assets/main.css";
import "./assets/transitions.css";
const app = createApp(App);
app.use(router);
app.mount('#app');
app.mount("#app");

View File

@@ -1,6 +0,0 @@
export interface AppVersion {
tag: string;
ipfsHash: string;
releaseDate: string;
description?: string;
}

5
src/model/Bank.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface Bank {
COMPE: string;
ISPB: string;
longName: string;
}

View File

@@ -1,11 +1,11 @@
export type Faq = Section[];
type Section = {
export type Section = {
name: string;
items: Question[];
};
type Question = {
export type Question = {
title: string;
content: string;
isOpen?: boolean;

View File

@@ -1,9 +0,0 @@
import type { Address } from 'viem';
export enum LockStatus {
// from DataTypes.sol
Inexistent = 0, // Uninitialized Lock
Active = 1, // Valid Lock
Expired = 2, // Expired Lock
Released = 3, // Already released Lock
}

View File

@@ -1,10 +1,18 @@
import type { Chain, ChainContract } from 'viem';
export enum NetworkEnum {
sepolia = 11155111,
rootstock = 31,
}
export const getNetworkSubgraphURL = (network: NetworkEnum | number) => {
const networkMap: Record<number, string> = {
[NetworkEnum.sepolia]: import.meta.env.VITE_SEPOLIA_SUBGRAPH_URL || "",
[NetworkEnum.rootstock]: import.meta.env.VITE_RSK_SUBGRAPH_URL || "",
};
return networkMap[typeof network === "number" ? network : network] || "";
};
export enum TokenEnum {
BRZ = 'BRZ',
BRZ = "BRZ",
// BRX = 'BRX'
}
export type NetworkConfig = Chain & {
tokens: Record<TokenEnum, ChainContract>;
subgraphUrls: string[];
};

37
src/model/Networks.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NetworkEnum } from "@/model/NetworkEnum";
export const NetworkById = (
chainId: string | number
): NetworkEnum | undefined => {
const normalizedChainId =
typeof chainId === "number" ? chainId : Number(chainId);
for (const [network, details] of Object.entries(Networks)) {
if (Number(details.chainId) === normalizedChainId) {
return network as unknown as NetworkEnum;
}
}
return undefined;
};
export const Networks = {
[NetworkEnum.sepolia]: {
chainId: "0xAA36A7",
chainName: "Sepolia Testnet",
},
[NetworkEnum.rootstock]: {
chainId: "0x1F",
chainName: "Rootstock Testnet",
rpcUrls: ["https://public-node.testnet.rsk.co/"],
iconUrls: [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAoCAYAAACWwljjAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAPOSURBVHgBxVhNUhpBFH6vGdxp4S4LoSYnEE8gnEA4AbpMJUQ4gXgCRJK1egLxBOIJJCdwJElVllMu49Cd1z04zD/dY1H5qihmut/M93VPv59uhHdAXFaPAaEDgA2/BaeA4hq/zG+gIBAKQoyr9yshid4Jdn+2oQAYFIC4rA2zxUhgS3yrDqEAjGdIDD/YYG09aRl7L7vYd10wgPkMlcoNfdvtFhjCXJBAeyO2S5gLQuFo25bEIxjCCt8oN2Z46I+Mu4A4SbjwojQBi1+BDl5LP+JNYlhtQRmPsjjQN1ILldwY7JTXOuD9bWL/jxO8dFy7oL9TyMcIu/PeSghxlLduQUA9jwPXiAk98HLw5jFiaFfAEjRLImPR0qi7z+2VmArZ7zzqcDAS01ljCKqf7QSjxb7jKkIhTohu6rOCq64RjsNiFEo7x7ocSNMvlddhPWb0CQ6gAAw4HKZpKGFDcWhzSEG6kbQCm4dLbi9m+XlpBTHea2D31zTSNtxrAGMNdcP5FPuxfhlKdCHgASUJxcd7zUcobkAPXvkzWGyf7uVCt2M2DtkMljaHSxu92WWLAz8OjWsD+juD/4tzcpqBSh3yQrmwoNFFMZNuDB7bJRsp/hzMMQqeT+NQ96KtNEBK+SG+23XgHgUyy8FPjpPozy3M4sZwh1/nLRMOK26Mn50Z5IHjA6XkBugJSn1XHkeBbK8dJsxsl0jMEOUpm0o9+gkX+7+TI0E+0x6Hsk0ijyNYQ/4OAqWn2aF+5cLxEoRq6idqtyEPtFhp/XyMNI2p9ADFUc/iYL5h7YzEXEEyptj04mvVHxkGP4F8MS4sWDsqRr4DbyGZRiIcqCKtpRMYeTMcpVVAFewqMVPSjUkMVQTBp6BPVKeiTqN65E0qP1AvIArWC98qcQsms39oDeBEtoXFKFgLbQ76ZKiXiRH2E01UF9Go+kGDh32/LWHZAD2OQ7mGdLO4ndrqWaHZyNyD6XJUWEq6yIQqReOweCe49ivD2DNUIutjJgXpHwyUtyPbY/IMWehfBA0IZxQSQoW9rKXL+ltq0oKqYC+RB6yLKys4xEw/Idde5R02cTGOcgh1LSNnid+nihIqcN0tr48MhL89L2uoG+Dqv5Px/IwqAhkqnEi296M1OyLPqVCgdKhcuKNjlUnQL4X78cRk1E1JlMkBME1sFE0gRrRJZGs3iT44bRZP5z0wQJHzIZMMbpztN1t+FDhsMBe0YNfatimHDetgLGiZGkYapqPwYt6YIAWPDYI9fSrETfjkwwSFT2EVrV/USY+r+/GGNp2I7zoW/gdR9aOdZ/lPGgAAAABJRU5ErkJggg==",
],
nativeCurrency: {
name: "tRBTC",
symbol: "tRBTC",
decimals: 18,
},
blockExplorerUrls: ["https://explorer.testnet.rootstock.io/"],
},
};

11
src/model/Pix.ts Normal file
View File

@@ -0,0 +1,11 @@
export type Pix = {
pixKey: string;
merchantCity?: string;
merchantName?: string;
value?: number;
transactionId?: string;
message?: string;
cep?: string;
currency?: number;
countryCode?: string;
};

View File

@@ -1,8 +1,6 @@
import { Address } from 'viem';
import type { Pix } from "./Pix";
export type UnreleasedLock = {
lockID: bigint;
sellerAddress: Address;
tokenAddress: Address;
amount: number;
lockID: string;
pix: Pix;
};

View File

@@ -1,12 +1,11 @@
import type { Address } from 'viem';
import type { NetworkConfig } from '@/model/NetworkEnum';
import { NetworkEnum } from "./NetworkEnum";
export type ValidDeposit = {
token: Address;
token: string;
blockNumber: number;
remaining: number;
seller: Address;
participantID: string;
network: NetworkConfig;
seller: string;
pixKey: string;
network: NetworkEnum;
open?: boolean;
};

View File

@@ -1,15 +1,13 @@
import type { LockStatus } from '@/model/LockStatus';
import type { Address } from 'viem';
import { LockStatus } from "@/blockchain/events"
export type WalletTransaction = {
token?: Address;
token: `0x${string}`;
blockNumber: number;
blockTimestamp?: number;
amount: number;
seller: string;
buyer: string;
event: string;
lockStatus?: LockStatus;
lockStatus: LockStatus;
transactionHash: string;
transactionID?: string;
};

120
src/model/mock/EventMock.ts Normal file
View File

@@ -0,0 +1,120 @@
import { vi } from "vitest";
export const MockEvents = [
{
blockNumber: 1,
blockHash: "0x8",
transactionIndex: 1,
removed: false,
address: "0x0",
data: "0x0",
topics: ["0x0", "0x0"],
transactionHash: "0x0",
logIndex: 1,
event: "DepositAdded",
eventSignature: "DepositAdded(address,uint256,address,uint256)",
args: [
"0x0",
{
type: "BigNumber",
hex: "0x00",
},
"0x0",
{
type: "BigNumber",
hex: "0x6c6b935b8bbd400000",
},
],
getBlock: vi.fn(),
removeListener: vi.fn(),
getTransaction: vi.fn(),
getTransactionReceipt: vi.fn(),
},
{
blockNumber: 2,
blockHash: "0x8",
transactionIndex: 2,
removed: false,
address: "0x0",
data: "0x0",
topics: ["0x0", "0x0"],
transactionHash: "0x0",
logIndex: 2,
event: "LockAdded",
eventSignature: "LockAdded(address,uint256,address,uint256)",
args: [
"0x0",
{
type: "BigNumber",
hex: "0x00",
},
"0x0",
{
type: "BigNumber",
hex: "0x6c6b935b8bbd400000",
},
],
getBlock: vi.fn(),
removeListener: vi.fn(),
getTransaction: vi.fn(),
getTransactionReceipt: vi.fn(),
},
{
blockNumber: 3,
blockHash: "0x8",
transactionIndex: 3,
removed: false,
address: "0x0",
data: "0x0",
topics: ["0x0", "0x0"],
transactionHash: "0x0",
logIndex: 3,
event: "LockReleased",
eventSignature: "LockReleased(address,uint256,address,uint256)",
args: [
"0x0",
{
type: "BigNumber",
hex: "0x00",
},
"0x0",
{
type: "BigNumber",
hex: "0x6c6b935b8bbd400000",
},
],
getBlock: vi.fn(),
removeListener: vi.fn(),
getTransaction: vi.fn(),
getTransactionReceipt: vi.fn(),
},
{
blockNumber: 4,
blockHash: "0x8",
transactionIndex: 4,
removed: false,
address: "0x0",
data: "0x0",
topics: ["0x0", "0x0"],
transactionHash: "0x0",
logIndex: 4,
event: "LockReleased",
eventSignature: "LockReleased(address,uint256,address,uint256)",
args: [
"0x0",
{
type: "BigNumber",
hex: "0x00",
},
"0x0",
{
type: "BigNumber",
hex: "0x6c6b935b8bbd400000",
},
],
getBlock: vi.fn(),
removeListener: vi.fn(),
getTransaction: vi.fn(),
getTransactionReceipt: vi.fn(),
},
];

View File

@@ -0,0 +1,45 @@
import type { ValidDeposit } from "../ValidDeposit";
import { NetworkEnum } from "@/model/NetworkEnum";
export const MockValidDeposits: ValidDeposit[] = [
{
blockNumber: 1,
token: "1",
remaining: 70,
seller: "mockedSellerAddress",
pixKey: "123456789",
network: NetworkEnum.sepolia,
},
{
blockNumber: 2,
token: "2",
remaining: 200,
seller: "mockedSellerAddress",
pixKey: "123456789",
network: NetworkEnum.sepolia,
},
{
blockNumber: 3,
token: "3",
remaining: 1250,
seller: "mockedSellerAddress",
pixKey: "123456789",
network: NetworkEnum.sepolia,
},
{
blockNumber: 4,
token: "4",
remaining: 4000,
seller: "mockedSellerAddress",
pixKey: "123456789",
network: NetworkEnum.sepolia,
},
{
blockNumber: 5,
token: "5",
remaining: 2000,
seller: "mockedSellerAddress",
pixKey: "123456789",
network: NetworkEnum.sepolia,
},
];

View File

@@ -0,0 +1,54 @@
import type { WalletTransaction } from "../WalletTransaction";
export const MockWalletTransactions: WalletTransaction[] = [
{
blockNumber: 1,
token: "0x1",
amount: 70,
seller: "0xmockedSellerAddress",
buyer: "0xmockedBuyerAddress",
event: "Deposit",
lockStatus: 0,
transactionHash: "1",
},
{
blockNumber: 2,
token: "0x2",
amount: 200,
seller: "0xmockedSellerAddress",
buyer: "0xmockedBuyerAddress",
event: "Lock",
lockStatus: 1,
transactionHash: "2",
},
{
blockNumber: 3,
token: "0x3",
amount: 1250,
seller: "0xmockedSellerAddress",
buyer: "0xmockedBuyerAddress",
event: "Release",
lockStatus: 2,
transactionHash: "3",
},
{
blockNumber: 4,
token: "0x4",
amount: 4000,
seller: "0xmockedSellerAddress",
buyer: "0xmockedBuyerAddress",
event: "Deposit",
lockStatus: 0,
transactionHash: "4",
},
{
blockNumber: 5,
token: "0x5",
amount: 2000,
seller: "mockedSellerAddress",
buyer: "mockedBuyerAddress",
event: "Deposit",
lockStatus: 3,
transactionHash: "5",
},
];

View File

@@ -1,57 +1,38 @@
import {
createRouter,
createWebHistory,
createWebHashHistory,
} from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import FaqView from '@/views/FaqView.vue';
import ManageBidsView from '@/views/ManageBidsView.vue';
import SellerView from '@/views/SellerView.vue';
import ExploreView from '@/views/ExploreView.vue';
import VersionsView from '@/views/VersionsView.vue';
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import FaqView from "@/views/FaqView.vue";
import ManageBidsView from "@/views/ManageBidsView.vue";
import SellerView from "@/views/SellerView.vue";
const router = createRouter({
history:
import.meta.env.MODE === 'production' && import.meta.env.BASE_URL === './'
? createWebHashHistory()
: createWebHistory(import.meta.env.BASE_URL),
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
path: "/",
name: "home",
component: HomeView,
props: true,
},
{
path: '/:lockID',
name: 'redirect buy',
path: "/:lockID",
name: "redirect buy",
component: HomeView,
},
{
path: '/seller',
name: 'seller',
path: "/seller",
name: "seller",
component: SellerView,
},
{
path: '/manage_bids',
name: 'manage bids',
path: "/manage_bids",
name: "manage bids",
component: ManageBidsView,
},
{
path: '/faq',
name: 'faq',
path: "/faq",
name: "faq",
component: FaqView,
},
{
path: '/explore',
name: 'explore',
component: ExploreView,
},
{
path: '/versions',
name: 'versions',
component: VersionsView,
},
],
});

4
src/shims-vue.d.ts vendored
View File

@@ -1,5 +1,5 @@
declare module '*.vue' {
import { DefineComponent } from 'vue';
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent;
export default component;
}

View File

@@ -1,11 +1,11 @@
module.exports = {
process() {
return {
code: 'module.exports = {};',
code: `module.exports = {};`,
};
},
getCacheKey() {
// The output is always the same.
return 'svgTransform';
return "svgTransform";
},
};

76
src/utils/QrCodePix.ts Normal file
View File

@@ -0,0 +1,76 @@
import qrcode from "qrcode";
import type { QRCodeToDataURLOptions } from "qrcode";
import { crc16ccitt } from "crc";
import type { Pix } from "@/model/Pix";
const pix = ({
pixKey,
merchantCity = "city",
merchantName = "name",
value,
message,
cep,
transactionId = "***",
currency = 986,
countryCode = "BR",
}: Pix) => {
const payloadKeyString = generatePixKey(pixKey, message);
const payload: string[] = [
formatEMV("00", "01"), //Payload Format Indicator
formatEMV("26", payloadKeyString), // Merchant Account Information
formatEMV("52", "0000"), //Merchant Category Code
formatEMV("53", String(currency)), // Transaction Currency
];
if (String(value) === "0") {
value = undefined;
}
if (value) {
payload.push(formatEMV("54", value.toFixed(2)));
}
payload.push(formatEMV("58", countryCode.toUpperCase())); // Country Code
payload.push(formatEMV("59", merchantName)); // Merchant Name
payload.push(formatEMV("60", merchantCity)); // Merchant City
if (cep) {
payload.push(formatEMV("61", cep)); // Postal Code
}
payload.push(formatEMV("62", formatEMV("05", transactionId))); // Additional Data Field Template
payload.push("6304");
const stringPayload = payload.join("");
const crcResult = crc16ccitt(stringPayload)
.toString(16)
.toUpperCase()
.padStart(4, "0");
const payloadPIX = `${stringPayload}${crcResult}`;
return {
payload: (): string => payloadPIX,
base64QrCode: (options?: QRCodeToDataURLOptions): Promise<string> =>
qrcode.toDataURL(payloadPIX, options),
};
};
const generatePixKey = (pixKey: string, message?: string): string => {
const payload: string[] = [
formatEMV("00", "BR.GOV.BCB.PIX"),
formatEMV("01", pixKey),
];
if (message) {
payload.push(formatEMV("02", message));
}
return payload.join("");
};
const formatEMV = (id: string, param: string): string => {
const len = param?.length?.toString().padStart(2, "0");
return `${id}${len}${param}`;
};
export { pix };

View File

@@ -0,0 +1,24 @@
import { it, expect, vi, type Mock } from "vitest";
import { debounce } from "../debounce";
vi.useFakeTimers();
describe("debounce function test", () => {
let mockFunction: Mock;
let debounceFunction: Function;
beforeEach(() => {
mockFunction = vi.fn();
debounceFunction = debounce(mockFunction, 1000);
});
it("debounce function will be executed just once", () => {
for (let i = 0; i < 100; i++) {
debounceFunction();
}
vi.runAllTimers();
expect(mockFunction).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,12 @@
import { it, expect } from "vitest";
import { decimalCount } from "../decimalCount";
describe("decimalCount function test", () => {
it("decimalCount should return length 1 of decimal", () => {
expect(decimalCount("4.1")).toEqual(1);
});
it("decimalCount should return length 0 because no decimal found", () => {
expect(decimalCount("5")).toEqual(0);
});
});

View File

@@ -0,0 +1,25 @@
import { MockValidDeposits } from "@/model/mock/ValidDepositMock";
import { it, expect, vi } from "vitest";
import { verifyNetworkLiquidity } from "../networkLiquidity";
vi.useFakeTimers();
describe("verifyNetworkLiquidity function test", () => {
it("verifyNetworkLiquidity should return an element from valid deposit list when searching for other deposits", () => {
const liquidityElement = verifyNetworkLiquidity(
MockValidDeposits[0].remaining,
"strangeWalletAddress",
MockValidDeposits
);
expect(liquidityElement).toEqual(MockValidDeposits[0]);
});
it("verifyNetworkLiquidity should return undefined when all deposits on valid deposit list match connected wallet addres", () => {
const liquidityElement = verifyNetworkLiquidity(
MockValidDeposits[0].remaining,
MockValidDeposits[0].seller,
[MockValidDeposits[0]]
);
expect(liquidityElement).toEqual(undefined);
});
});

View File

@@ -1,3 +1,5 @@
import { off } from "process";
export interface Participant {
offer: string;
chainID: number;
@@ -9,12 +11,13 @@ export interface Participant {
savingsVariation?: string;
}
interface ParticipantWithID extends Participant {
export interface ParticipantWithID extends Participant {
id: string;
}
export interface Offer {
amount: number;
lockId: string;
sellerId: string;
}
@@ -22,10 +25,11 @@ export interface Offer {
// https://apoio.developers.bb.com.br/sandbox/spec/665797498bb48200130fc32c
export const createParticipant = async (participant: Participant) => {
console.log("Creating participant", participant);
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/register`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
chainID: participant.chainID,
@@ -37,41 +41,37 @@ export const createParticipant = async (participant: Participant) => {
codigoIspb: participant.bankIspb,
}),
});
if (!response.ok) {
throw new Error(`Error creating participant: ${response.statusText}`);
}
const data = await response.json();
if (data.errors || data.erros) {
throw new Error(`Error creating participant: ${JSON.stringify(data)}`);
}
return { ...participant, id: data.numeroParticipante } as ParticipantWithID;
return { ...participant, id: data.id } as ParticipantWithID;
};
export const createSolicitation = async (offer: Offer) => {
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/request`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: offer.amount,
pixTarget: offer.sellerId.split('-').pop(),
pixTarget: offer.sellerId,
}),
});
return response.json();
};
export const getSolicitation = async (
id: bigint,
): Promise<{ pixTimestamp: `0x${string}`; signature: `0x${string}` }> => {
export const getSolicitation = async (id: string) => {
const response = await fetch(
`${import.meta.env.VITE_APP_API_URL}/release/${id}`,
`${import.meta.env.VITE_APP_API_URL}/release/${id}`
);
const obj = await response.json();
const obj: any = response.json();
return {
pixTimestamp: obj.pixTimestamp,
signature: obj.signature,
id: obj.numeroSolicitacao,
lockId: obj.codigoConciliacaoSolicitacao,
amount: obj.valorSolicitacao,
qrcode: obj.pix.textoQrCode,
status: obj.valorSomatorioPagamentosEfetivados >= obj.valorSolicitacao,
signature: obj.assinatura,
};
};

View File

@@ -1,6 +1,6 @@
export const decimalCount = (numStr: string): number => {
if (numStr.includes('.')) {
return numStr.split('.')[1].length;
if (numStr.includes(".")) {
return numStr.split(".")[1].length;
}
return 0;
};

View File

@@ -1,15 +1,30 @@
import type { TokenEnum } from '@/model/NetworkEnum';
import { Networks } from '@/config/networks';
import type { TokenEnum } from "@/model/NetworkEnum";
export const imagesPath = import.meta.glob<string>("@/assets/*.{png,svg}", {
eager: true,
query: "?url",
import: "default",
});
export const getNetworkImage = (networkName: string): string => {
const normalizedName = networkName.toLowerCase().replace(/[^a-z0-9]/g, '-');
return new URL(`../assets/networks/${normalizedName}.svg`, import.meta.url)
.href;
try {
const path = Object.keys(imagesPath).find((key) =>
key.endsWith(`${networkName.toLowerCase()}.svg`)
);
return path ? imagesPath[path] : "";
} catch (error) {
console.error("Error fetching network image");
const path = Object.keys(imagesPath).find((key) =>
key.endsWith(`invalidIcon.svg`)
);
return path ? imagesPath[path] : "";
return "";
}
};
export const getTokenImage = (tokenName: TokenEnum): string => {
return new URL(
`../assets/tokens/${tokenName.toLowerCase()}.svg`,
import.meta.url,
).href;
const path = Object.keys(imagesPath).find((key) =>
key.endsWith(`${tokenName.toLowerCase()}.svg`)
);
return path ? imagesPath[path] : "";
};

View File

@@ -1,17 +1,16 @@
import type { ValidDeposit } from '@/model/ValidDeposit';
import type { Address } from 'viem';
import type { ValidDeposit } from "@/model/ValidDeposit";
const verifyNetworkLiquidity = (
tokenValue: number,
walletAddress: Address,
validDepositList: ValidDeposit[],
walletAddress: string,
validDepositList: ValidDeposit[]
): ValidDeposit[] => {
const filteredDepositList = validDepositList
.filter((element) => {
const remaining = element.remaining;
if (
tokenValue! <= remaining &&
tokenValue! != 0 &&
tokenValue!! <= remaining &&
tokenValue!! != 0 &&
element.seller !== walletAddress
) {
return true;
@@ -25,14 +24,14 @@ const verifyNetworkLiquidity = (
const uniqueNetworkDeposits = filteredDepositList.reduce(
(acc: ValidDeposit[], current) => {
const existingNetwork = acc.find(
(deposit) => deposit.network === current.network,
(deposit) => deposit.network === current.network
);
if (!existingNetwork) {
acc.push(current);
}
return acc;
},
[],
[]
);
return uniqueNetworkDeposits;
};

View File

@@ -1,4 +1,16 @@
export const pixFormatValidation = (pixKey: string): boolean => {
const cpf = /(^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$)/g;
const cnpj = /(^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$)/g;
const telefone = /(^[0-9]{2})?(\s|-)?(9?[0-9]{4})-?([0-9]{4}$)/g;
if (pixKey.match(cpf) || pixKey.match(cnpj) || pixKey.match(telefone)) {
return true;
}
return false;
};
export const postProcessKey = (pixKey: string): string => {
pixKey = pixKey.replace(/[-.()/]/g, '');
pixKey = pixKey.replace(/[-.()/]/g, "");
return pixKey;
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +0,0 @@
import type { AppVersion } from '@/model/AppVersion';
export const appVersions: AppVersion[] = [
{
tag: '1.1.0',
ipfsHash: 'bafybeiaffdxrxoex3qh7kirnkkufnvpafb4gmkt7mjxufnnpbrq6tmqoha',
releaseDate: '2025-11-06',
description: 'Explorer and versioning features added',
},
{
tag: '1.0.0',
ipfsHash: 'bafybeiagfqnxnb5zdrks6dicfm7kxjdtzzzzm2ouluxgdseg2hrrotayzi',
releaseDate: '2023-01-28',
description: 'Initial release',
},
];
export function getLatestVersion(): AppVersion | null {
return appVersions.length > 0 ? appVersions[0] : null;
}
export function getIpfsUrl(ipfsHash: string): string {
return `https://${ipfsHash}.ipfs.dweb.link`;
}

View File

@@ -1,164 +0,0 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useUser } from '@/composables/useUser';
import { useGraphQL } from '@/composables/useGraphQL';
import FormCard from '@/components/ui/FormCard.vue';
import LoadingComponent from '@/components/ui/LoadingComponent.vue';
import AnalyticsCard from '@/components/Explorer/AnalyticsCard.vue';
import TransactionTable from '@/components/Explorer/TransactionTable.vue';
const user = useUser();
const { network } = user;
const {
searchAddress,
selectedType,
transactions,
analytics,
loading,
error,
analyticsLoading,
fetchAllActivity,
fetchUserActivity,
fetchAnalytics,
clearData,
} = useGraphQL(network);
const transactionTypes = [
{ key: 'all', label: 'Todas' },
{ key: 'deposit', label: 'Depósitos' },
{ key: 'lock', label: 'Bloqueios' },
{ key: 'release', label: 'Liberações' },
{ key: 'return', label: 'Retornos' },
];
const handleTypeFilter = (type: string) => {
selectedType.value = type;
};
watch(searchAddress, async (newAddress) => {
if (newAddress.trim()) {
await fetchUserActivity(newAddress.trim());
} else {
await fetchAllActivity();
}
});
watch(
network,
async () => {
clearData();
await Promise.all([fetchAllActivity(), fetchAnalytics()]);
},
{ deep: true },
);
onMounted(async () => {
await Promise.all([fetchAllActivity(), fetchAnalytics()]);
});
</script>
<template>
<div class="min-h-screen">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<AnalyticsCard
title="Volume Total"
:value="analytics.totalVolume"
:loading="analyticsLoading"
/>
<AnalyticsCard
title="Total de Transações"
:value="analytics.totalTransactions"
:loading="analyticsLoading"
/>
<AnalyticsCard
title="Total de Bloqueios"
:value="analytics.totalLocks"
:loading="analyticsLoading"
/>
<AnalyticsCard
title="Total de Depósitos"
:value="analytics.totalDeposits"
:loading="analyticsLoading"
/>
<AnalyticsCard
title="Total de Liberações"
:value="analytics.totalReleases"
:loading="analyticsLoading"
/>
</div>
<!-- Search and Filters -->
<FormCard padding="lg" class="mb-6">
<div class="space-y-4">
<!-- Search Input -->
<div class="flex-1">
<input
v-model="searchAddress"
type="text"
placeholder="Buscar por endereço de carteira..."
class="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
/>
</div>
<!-- Type Filters -->
<div class="flex flex-wrap gap-2">
<button
v-for="type in transactionTypes"
:key="type.key"
@click="handleTypeFilter(type.key)"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
selectedType === type.key
? 'bg-amber-400 text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
]"
>
{{ type.label }}
</button>
</div>
</div>
</FormCard>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<LoadingComponent title="Carregando transações..." />
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12">
<div class="text-red-600 text-lg mb-2"></div>
<p class="text-red-600 mb-2">Erro ao carregar transações</p>
<p class="text-gray-600 text-sm">{{ error }}</p>
</div>
<!-- Transactions Table -->
<FormCard v-else padding="lg">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Transações Recentes
</h2>
<p class="text-gray-600">
{{ transactions.length }} transações encontradas
</p>
</div>
<TransactionTable
:transactions="transactions"
:network-explorer-url="network.blockExplorers?.default.url || ''"
/>
</FormCard>
</div>
</div>
</template>
<style scoped>
.container {
max-width: 1200px;
}
</style>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { Faq } from '@/model/Faq';
import { ref } from 'vue';
import { marked } from 'marked';
import faqContent from '@/utils/files/faqContent.json';
import type { Faq } from "@/model/Faq";
import { ref } from "vue";
import { marked } from "marked";
import faqContent from "@/utils/files/faqContent.json";
const faq = ref<Faq>(faqContent);
@@ -23,27 +23,26 @@ const openItem = (index: number) => {
faq.value[selectedSection.value].items[index].content = marked(
faq.value[selectedSection.value].items[index].content
) as string;
);
console.log(marked(faq.value[selectedSection.value].items[index].content));
};
</script>
<template>
<div class="page">
<div class="text-container">
<span
class="text font-extrabold sm:text-5xl text-3xl sm:max-w-[50rem] max-w-[90%]"
<span class="text font-extrabold text-5xl max-w-[50rem]"
>Perguntas Frequentes
</span>
<span
class="text font-medium sm:text-base text-sm sm:max-w-[40rem] max-w-[90%]"
<span class="text font-medium text-base max-w-[40rem]"
>Não conseguiu uma resposta para sua dúvida? Acesse a comunidade do
Discord para falar diretamente conosco.</span
>
</div>
<div class="faq-container">
<div class="sumario-section">
<h1 class="sumario-title">Sumário</h1>
<div class="flex justify-between w-10/12 mt-20">
<div>
<h1 class="text-3xl text-white font-bold">Sumário</h1>
<h3
:class="index == selectedSection ? 'selected-sumario' : 'sumario'"
v-for="(f, index) in faq"
@@ -54,7 +53,7 @@ const openItem = (index: number) => {
</h3>
</div>
<div class="content-section">
<div class="w-4/6">
<div
v-for="(item, index) in faq[selectedSection].items"
v-bind:key="item.title"
@@ -63,16 +62,16 @@ const openItem = (index: number) => {
<img
alt="plus"
src="@/assets/plus.svg?url"
class="icon"
class="mr-3"
v-if="!item.isOpen"
/>
<img
alt="minus"
alt="plus"
src="@/assets/minus.svg?url"
class="icon"
class="mr-3"
v-if="item.isOpen"
/>
<h4 class="item-title">{{ item.title }}</h4>
<h4 class="text-white">{{ item.title }}</h4>
</div>
<div class="content" v-if="item.isOpen" v-html="item.content"></div>
<div class="hr"></div>
@@ -83,53 +82,23 @@ const openItem = (index: number) => {
</template>
<style scoped>
@reference "tailwindcss";
.page {
@apply flex flex-col items-center justify-center w-full mt-8 sm:mt-16 px-4;
}
.text-container {
@apply flex flex-col items-center justify-center gap-4 mb-8 sm:mb-12;
}
.text {
@apply text-white text-center;
}
.faq-container {
@apply flex flex-col sm:flex-row sm:justify-between w-full sm:w-10/12 max-w-7xl gap-8 sm:gap-0 mt-8 sm:mt-20;
}
.sumario-section {
@apply w-full sm:w-auto sm:min-w-[200px];
}
.sumario-title {
@apply text-xl sm:text-3xl text-white font-bold mb-4 sm:mb-0;
}
.sumario {
@apply text-white mt-6 sm:mt-6 cursor-pointer text-sm sm:text-base;
margin-top: 24px;
cursor: pointer;
}
.selected-sumario {
@apply text-white font-bold mt-6 sm:mt-6 cursor-pointer text-sm sm:text-base;
font-weight: bolder;
margin-top: 24px;
cursor: pointer;
}
.content-section {
@apply w-full sm:w-4/6;
}
.icon {
@apply mr-3 flex-shrink-0 w-5 h-5 sm:w-6 sm:h-6;
}
.item-title {
@apply text-white font-semibold text-sm sm:text-base;
.page {
@apply flex flex-col items-center justify-center w-full mt-16;
}
div.content {
@apply pt-6 text-white text-sm sm:text-base;
padding-top: 24px;
color: white;
}
.content :deep(ul) {
@@ -144,12 +113,9 @@ div.content {
@apply list-disc m-1 p-1;
}
.content :deep(p) {
@apply mb-2;
}
.hr {
@apply border border-gray-700 my-6;
border: 1px solid #1f2937;
margin: 24px 0;
}
h3 {
@@ -160,4 +126,12 @@ h2,
h4 {
font-weight: 600;
}
.text-container {
@apply flex flex-col items-center justify-center gap-4;
}
.text {
@apply text-white text-center;
}
</style>

Some files were not shown because too many files have changed in this diff Show More