138 Commits

Author SHA1 Message Date
hueso
8d6dc80f55 fix for marked package update
Some checks failed
Tests / unit (push) Has been cancelled
Deploy to IPFS / build (push) Has been cancelled
Deploy to IPFS / deploy-rsync (push) Has been cancelled
Deploy to IPFS / deploy-ipfs (push) Has been cancelled
Deploy to IPFS / deploy-pinata (push) Has been cancelled
Lint & Format / lint (push) Has been cancelled
2026-06-08 18:23:27 -03:00
hueso
cea50bb22c remove legacy function isTestNetEnvironment 2026-06-08 18:06:48 -03:00
hueso
a9d370ce9e update tsconfig for typescript 7 2026-06-08 18:06:48 -03:00
fe8cedf048 fix: rename rootstock mainnet icon to match network slug 2026-06-06 09:58:19 -03:00
arthur
c47968333f test: change vitest config (#7)
Bootstrap mínimo do harness Vitest para o p2pix-front-end. Adiciona configuração, polyfills de DOM, e cobertura inicial de utils + 1 componente simples — apenas o que roda contra `develop` sem dependências
  externas.

Co-authored-by: Arthur Abeilice <afa7789@gmail.com>
Reviewed-on: https://git.p2pix.co/doiim/p2pix-front-end/pulls/7
2026-06-02 01:41:01 +00:00
arthur
7887f50b1a ci(deploy): install contracts submodule via yarn before SPA build (#15)
The p2pix-smart-contracts submodule ships a yarn v4 lockfile that bun
cannot consume (UnsupportedYarnLockfileVersion). Install yarn in the
build container, check out the submodule, and run yarn install via a
new install:contracts package.json script. Then run wagmi:gen so
src/blockchain/abi.ts exists before vite build.

Co-authored-by: Arthur Abeilice <afa7789@gmail.com>
Reviewed-on: https://git.p2pix.co/doiim/p2pix-front-end/pulls/15
Co-authored-by: arthur <abeilice@kosmos.org>
Co-committed-by: arthur <abeilice@kosmos.org>
2026-06-02 01:41:01 +00:00
hueso
e182347f5e run CI/CD on dev branch and tags 2026-06-02 01:41:01 +00:00
9f04c09704 fix(ui): remove unnecessary line breaks in component templates for consistency 2026-06-02 01:41:01 +00:00
36cd57b59e fix(ui): os devs do bb usaram classe com numero errado, ao atualizar p/ nova versão quebrou devido a cor padrão, remover classe p/ adaptar p/ tailwind4 2026-06-02 01:41:01 +00:00
0bc4e4ccf3 chore(knip): remove unnecessary ignore pattern from knip.json 2026-06-02 01:41:01 +00:00
00390ab0c3 chore(knip): add config and remove dead code/deps
- knip.json: scope to src, ignore submodule/worktrees, mark
  generated abi.ts as unresolved-allowed, honor @public JSDoc tag
- drop 14 orphaned files (12 ui components, model/Bank, model/Pix)
- drop 18 unused deps (urql, tanstack, wagmi/{core,vue}, graphql,
  permissionless, graphql-codegen suite, axe-core, lighthouse,
  vue/test-utils)
- drop 4 unused exports and de-export 9 internal-only types
- mark NetworksTestnet as @public (in-flight testnet support)
2026-06-02 01:41:01 +00:00
5dc630acdf chore: remove zkPix submodule 2026-06-02 01:41:01 +00:00
arthur
9828a44cf8 break actions in multiple (#12)
feat: add linting workflow for code quality checks
feat: add testing workflow for unit tests and coverage reporting
chore: update .gitignore to include additional files
Co-authored-by: Arthur Abeilice <afa7789@gmail.com>
Reviewed-on: https://git.p2pix.co/doiim/p2pix-front-end/pulls/12
Co-authored-by: arthur <abeilice@kosmos.org>
Co-committed-by: arthur <abeilice@kosmos.org>
2026-06-02 01:41:01 +00:00
hueso
616302ffbe clean AI slop 2026-06-02 01:41:01 +00:00
cee0cc1296 refactor(eslint): use defineConfigWithVueTs and extends instead of map 2026-06-02 01:41:01 +00:00
72b9b64da8 chore(scripts): update lint commands to remove directory scope 2026-06-02 01:41:01 +00:00
e2a03786e1 chore(eslint): scope rules via files instead of negated ignores 2026-06-02 01:41:01 +00:00
918e6fecde chore(eslint): move src/tests scoping into config via negated ignores 2026-06-02 01:41:01 +00:00
0cdc69d4da chore(scripts): scope lint to src and tests instead of repo-wide 2026-06-02 01:41:01 +00:00
ee783ea727 docs(README): drop stale liftlearning sonarcloud badges 2026-06-02 01:41:01 +00:00
0761f74e1a docs(README): drop self-clone link, list current per-network env vars 2026-06-02 01:41:01 +00:00
289910df95 chore(scripts,docs): make submodule bootstrap explicit, drop auto-install on start 2026-06-02 01:41:01 +00:00
c7a285c4a1 chore(scripts): simplify wagmi:gen script by removing redundant .env check 2026-06-02 01:41:01 +00:00
c9ca178b24 chore(scripts): drop redundant lifecycle wrappers, inline submodule setup into wagmi:gen 2026-06-02 01:41:01 +00:00
555eac9e7c fix(scripts): contracts:setup preserves existing .env, handles macOS cp 2026-06-02 01:41:01 +00:00
10569db3b1 chore(scripts): use wagmi binary and stop clobbering submodule .env 2026-06-02 01:41:01 +00:00
2e2d2ff6d7 chore(submodules): remove branch specification for p2pix-smart-contracts and zkPix 2026-06-02 01:41:01 +00:00
e7bf32b6c3 chore(codegen): restore minimal wagmi.config.ts (hardhat plugin)
Reverts wagmi.config.ts to the pre-existing simpler shape (hardhat
plugin auto-discovers artifacts) and drops our hand-rolled readFileSync
bootstrap. The plugin emits ABIs for every compiled contract; unused
ones are tree-shaken out of the production bundle, and the file itself
is gitignored so the extra bytes never hit the repo.

The plugin runs `npx hardhat compile` internally, which loads
hardhat.config.ts at import time and throws when ALCHEMY_API_KEY is
missing — env vars from our wrapper script don't propagate into the
plugin's subprocess. Fix: prewagmi:gen now bootstraps a placeholder
.env inside the submodule (gitignored there via *.env), instead of
running our own `hardhat compile` with inline env vars.

- wagmi.config.ts: hardhat({ project: 'p2pix-smart-contracts' }), no
  explicit contracts/loadAbi
- package.json: contracts:compile -> contracts:setup
  (bun install + create .env if absent); the wagmi hardhat plugin
  handles the actual compile
2026-06-02 01:41:01 +00:00
4a09a323bd chore(codegen): drop @wagmi/cli actions plugin (no callers in src/, generated file shrinks ~63%) 2026-06-02 01:41:01 +00:00
6c5c487874 chore(codegen): keep src/blockchain/abi.ts as wagmi output + infra polish
Path revert (reduces churn / preserves existing imports):
- wagmi.config.ts: out -> src/blockchain/abi.ts (was src/generated.ts)
- Restore the 4 import sites (sellerMethods, events, provider,
  BuyerSearchComponent) to import from './abi' / '@/blockchain/abi'
- .gitignore: ignore src/blockchain/abi.ts (was src/generated.ts)

The file keeps its original location and is no longer committed — it is
regenerated from the smart-contracts submodule artifacts on every
prestart via `bun run wagmi:gen`.

Infra polish:
- package.json: contracts:compile uses pushd/popd (instead of cd) so the
  shell returns to the project root after the submodule build, even when
  the script is the leaf of a longer chain
- .gitmodules: zkPix submodule pinned to `dev`
2026-06-02 01:41:01 +00:00
4e65ab7ff0 chore(codegen): drop committed src/blockchain/abi.ts in favor of generated src/generated.ts
The 58KB src/blockchain/abi.ts was the output of the legacy hardhat-based
wagmi config and was being committed verbatim. Since wagmi.config.ts now
emits src/generated.ts (gitignored, regenerated on every prestart via
`bun run wagmi:gen`), keeping the old artifact in the tree was just dead
weight.

- Remove src/blockchain/abi.ts from tracking
- Repoint 4 imports (sellerMethods, events, provider, BuyerSearchComponent)
  from `@/blockchain/abi` / `./abi` to `@/generated`
- contracts:compile now injects ALCHEMY_API_KEY=placeholder + a default
  test MNEMONIC so hardhat.config.ts can load without a manual .env in
  the submodule (matches the placeholder values used in p2pix-front-end's
  submodule .env)
2026-06-02 01:41:01 +00:00
7c17e940da chore(codegen): wire wagmi ABI codegen against smart-contracts submodule
- wagmi.config.ts: read ABIs from p2pix-smart-contracts/artifacts/contracts
  (P2Pix, Reputation, MockToken) and emit src/generated.ts with the
  @wagmi/cli actions plugin
- package.json: add scripts
  * contracts:compile — installs submodule deps + runs `hardhat compile`
  * wagmi:gen — runs @wagmi/cli to (re)generate src/generated.ts
  * prewagmi:gen — chains contracts:compile before wagmi:gen
  * prestart — runs wagmi:gen, so `bun start` always has fresh ABIs
- .gitignore: ignore src/generated.ts (regenerated on every prestart)

Note: GraphQL/subgraph codegen is intentionally out of scope here — it
will land in its own branch.
2026-06-02 01:41:01 +00:00
bdaffbd889 chore: add p2pix-smart-contracts (dev) and zkPix (feat/fake-bb-sandbox) submodules 2026-06-02 01:41:01 +00:00
9c948d7da4 refactor: standardize quote styles to single quotes across all files 2026-06-02 01:41:01 +00:00
af897e7dd4 feat: add Tailwind CSS reference to multiple components for styling consistency 2026-06-02 01:41:01 +00:00
663a0bce46 add @web3-onboard back as stop-gap until reown migration 2026-06-02 01:41:01 +00:00
4469ccb30a refactor: update configuration files to use ES module syntax and integrate Tailwind CSS 2026-06-02 01:41:01 +00:00
98c6e04a16 feat: render app version in bottom-left footer 2026-06-02 01:41:01 +00:00
d63cb8c6d3 refactor: clean up code formatting and improve readability across multiple components
- Standardized the use of quotes and spacing in various files.
- Removed unnecessary line breaks and trailing spaces in components.
- Improved the structure of computed properties and methods for better clarity.
- Enhanced the consistency of prop definitions and emit events in Vue components.
- Updated the GraphQL composable to streamline error handling and data processing.
- Refactored network configuration files for better organization and readability.
- Cleaned up model files by removing redundant lines and ensuring consistent formatting.
- Adjusted router configuration for improved readability.
- Enhanced utility functions for better maintainability and clarity.
2026-06-02 01:41:01 +00:00
c481d9d0a5 migrate eslint to flat config (eslint 10) 2026-06-02 01:41:01 +00:00
63f5ee017b refactor: update TypeScript configuration to extend correct Vue tsconfig files 2026-06-02 01:41:01 +00:00
dacbeac019 update Dockerfile to use latest bun image 2026-06-02 01:41:01 +00:00
18efb7543e use oven-sh/setup-bun action in CI 2026-06-02 01:41:01 +00:00
6caf34b579 switch to bun + adopt e2e dependency stack 2026-06-02 01:41:01 +00:00
hueso
ec1053c660 simplify networks config 2026-06-02 01:41:01 +00:00
hueso
46be71046a Merge branch 'develop' 2026-03-13 01:11:07 -03:00
hueso
ebe03eb439 deploy to branch 2026-03-13 01:08:54 -03:00
hueso
abeef0bd85 deploy to node 2026-03-05 22:30:50 -03:00
hueso
ea5773c7d0 CI: build-only (no type-check) 2026-03-05 22:19:37 -03:00
hueso
95c3692bcb piñata soft fail 2026-03-05 22:19:27 -03:00
hueso
7cda8d5573 import contracts from submodule 2026-03-05 21:59:10 -03:00
hueso
6cfe478177 manually trigger IPFS action 2026-03-05 21:58:59 -03:00
hueso
f31fa15887 add basic error handling on subgraph queries 2026-02-06 15:02:18 -03:00
hueso
ad5b0a3a93 fix: chainID as hex on web3onboard call 2026-02-06 15:02:18 -03:00
hueso
6979ba0402 fix: sellerId as string 2026-02-06 15:02:18 -03:00
Jefferson Mantovani
43b955296a chore: add group to pinata deploy action 2026-01-22 15:52:44 -03:00
Jefferson Mantovani
1cf9898e2d Merge pull request #11 from doiim/feat/update-app
chore: update app version
2026-01-22 11:16:45 -03:00
Jefferson Mantovani
9c8ba43339 fix: pinata install in ipfs script 2026-01-22 11:10:51 -03:00
Jefferson Mantovani
b655a3c4b6 chore: update app version 2026-01-22 11:08:18 -03:00
Jefferson Mantovani
42016d0101 Merge pull request #10 from doiim/feat/version-update
chore: test pinata upload
2026-01-22 11:02:06 -03:00
Jefferson Mantovani
674948120c fix: remove old github workflows 2026-01-22 11:00:50 -03:00
Jefferson Mantovani
3c8e9c0262 fix: ipfs release script 2026-01-22 10:58:55 -03:00
Jefferson Mantovani
d686fca363 chore: test pinata upload 2026-01-22 10:53:33 -03:00
Jefferson Mantovani
183fd698a9 chore: test pinata upload 2026-01-22 10:52:37 -03:00
filipesoccol
100aab6b42 Merge pull request #9 from doiim/feat/reputation-input-limit
feat: show the reputation limit in the buy input
2025-11-17 13:37:26 -03:00
Jefferson Mantovani
9a506acfa6 feat: show the reputation limit in the buy input 2025-11-14 10:01:24 -03:00
Jefferson Mantovani
d603753654 Merge branch 'develop' 2025-11-06 16:19:57 -03:00
Jefferson Mantovani
b4f5134156 chore: register version 1.1.0 2025-11-06 16:19:42 -03:00
Jefferson Mantovani
290e339f0c Merge branch 'develop' 2025-11-06 11:15:26 -03:00
Jefferson Mantovani
4c721e4431 chore: register version 1.1.0 2025-11-06 11:15:10 -03:00
Jefferson Mantovani
1adef2dbb8 Merge branch 'develop' 2025-11-06 11:12:30 -03:00
Jefferson Mantovani
bf75cd766a chore: register version 1.1.0 2025-11-06 11:11:42 -03:00
Jefferson Mantovani
d3eae76f91 Merge branch 'develop' 2025-11-06 11:07:41 -03:00
Jefferson Mantovani
a4163a2ba6 chore: register version 1.1.0 2025-11-06 11:06:02 -03:00
Jefferson Mantovani
fad52d79d2 Merge release/1.1.0 into develop 2025-11-06 10:57:27 -03:00
Jefferson Mantovani
f64ea2ddf1 Merge release/1.1.0 into main 2025-11-06 10:57:02 -03:00
Jefferson Mantovani
e67c8fcc77 chore: bump version to 1.1.0 2025-11-06 10:56:42 -03:00
Jefferson Mantovani
ac670235cd fix: layout 2025-11-06 10:28:27 -03:00
Jefferson Mantovani
38201bb254 feat: versioning 2025-11-06 10:28:14 -03:00
Jefferson Mantovani
fece86e305 refactor: add transaction timestamp 2025-11-06 10:27:17 -03:00
filipesoccol
b27b07fe47 Merge pull request #8 from doiim/feat/app-footer
feat: add footer with app version
2025-10-31 18:20:29 -03:00
Jefferson Mantovani
d33d7f8538 feat: add footer with app version 2025-10-31 07:14:44 -03:00
Jefferson Mantovani
57714fac9b Merge pull request #7 from doiim/feat/transaction-explorer
feat: add transactions explorer page
2025-10-24 17:12:43 -03:00
Jefferson Mantovani
9eee78fa91 fix: network selection 2025-10-24 16:43:39 -03:00
Jefferson Mantovani
4b4ade2bfa Merge branch 'develop' into feat/transaction-explorer 2025-10-24 15:43:58 -03:00
filipesoccol
364cdd3b60 Merge pull request #6 from doiim/refactor/network-dynamize
Refactor/network dynamize
2025-10-24 15:10:59 -03:00
Jefferson Mantovani
799f7cfe09 feat: add transactions explorer page 2025-10-23 15:40:09 -03:00
Jefferson Mantovani
2117638305 fix network icons 2025-10-15 19:11:30 -03:00
hueso
a3e3f0506c add rsk testnet icon 2025-10-12 18:57:36 -03:00
hueso
976c48ac4b optimized icon importing 2025-10-12 18:57:25 -03:00
hueso
7bcf5d90c2 remove deprecated NetworkEnum 2025-10-11 22:07:32 -03:00
hueso
358ae7410f fixed network selection (hex string) and icons 2025-10-11 22:07:32 -03:00
hueso
a906fa136d refactored network selection 2025-10-11 22:07:32 -03:00
filipesoccol
7ec73e8c6f Merge pull request #5 from jeffmant/refactor/ux-improvements
Some checks failed
CI script / lint (push) Has been cancelled
CI script / build (push) Has been cancelled
CI script / SonarCloud (push) Has been cancelled
Deploy FrontEnd / deploy-staging (push) Has been cancelled
Deploy FrontEnd / deploy-production (push) Has been cancelled
Refactor/ux improvements
2025-10-10 15:07:46 -03:00
Jefferson Mantovani
84afed78fb refactor: organize components 2025-10-10 11:39:54 -03:00
Jefferson Mantovani
2b6be86b2e fix: copy qrcode to clipboard 2025-10-08 20:56:02 -03:00
Jefferson Mantovani
fdc03068f2 refactor: listing component improvements 2025-10-08 20:55:39 -03:00
Jefferson Mantovani
c58e91e073 refactor: organize componentes files 2025-10-08 20:29:51 -03:00
filipesoccol
13c0fcc681 Merge pull request #4 from jeffmant/refactor/network-dynamize
refactor: dynamize network config
2025-10-08 13:39:33 -03:00
Jefferson Mantovani
5c1d560d0c refactor: dynamize network config 2025-10-06 21:57:47 -03:00
hueso
a24ee193d4 rootstock testnet re-deployment
Some checks failed
Deploy FrontEnd / deploy-staging (push) Has been cancelled
Deploy FrontEnd / deploy-production (push) Has been cancelled
CI script / lint (push) Has been cancelled
CI script / build (push) Has been cancelled
CI script / SonarCloud (push) Has been cancelled
2025-08-01 14:07:41 -03:00
hueso
9b325ac917 removed redundant getSellerParticipantId causing trouble with buyer flow 2025-08-01 14:07:28 -03:00
hueso
c3d770f713 removed unused imports 2025-07-31 21:01:54 -03:00
hueso
3ef1694217 unified networks list 2025-07-31 15:43:10 -03:00
hueso
2b707e81c2 rootstock testnet fix 2025-07-18 14:56:10 -03:00
Filipe Soccol
f6a9ab854c Merge remote-tracking branch 'origin/type-check' 2025-07-11 15:07:15 -03:00
Filipe Soccol
474af2fbfc Merge branch 'buy-refactor' 2025-07-11 15:06:42 -03:00
hueso
4af059f6b7 removed inappropiate wait; moar type checks 2025-07-04 21:11:33 -03:00
hueso
23163be99d fixed pixTarget treated as pixTimestamp 2025-07-04 21:09:32 -03:00
Filipe Soccol
c1542707c2 Merge remote-tracking branch 'origin/type-check' into buy-refactor 2025-06-30 08:58:01 -03:00
hueso
dd351acb2e Stronger typings💪 2025-06-29 18:19:30 -03:00
Filipe Soccol
2370051243 Update all code to be able to release. Still having issues on Release transaction. 2025-06-28 12:16:36 -03:00
Filipe Soccol
ed5d3b5726 Fixed spinner in offers. 2025-06-27 16:47:06 -03:00
Filipe Soccol
2e246f7560 Removed all tests and test libraries. 2025-06-27 16:42:25 -03:00
Filipe Soccol
81c8b04c7a Refactored variable names to be concise. 2025-06-27 16:34:29 -03:00
Filipe Soccol
cf61f5ecfd Fix issues with locking and solicitation. 2025-06-27 15:59:34 -03:00
hueso
73ba77ca4f fix build error 2025-06-18 18:46:24 -03:00
Filipe Soccol
c7b2f1643c Updated buy flux. Already able to lock. 2025-06-18 12:01:42 -03:00
Filipe Soccol
fa2def812c Updated code to fetch available offers using subgraph and multicall. 2025-06-06 17:38:00 -03:00
Filipe Soccol
8a1dab4764 Set properly the deposit call on smart-contract. 2025-05-21 11:34:08 -03:00
Filipe Soccol
75c02ed1b9 Fixes bbPay interface with proper calls. 2025-05-21 11:30:22 -03:00
Filipe Soccol
8eb10f493f Adjusted request for create participants. 2025-05-21 10:43:00 -03:00
Filipe Soccol
1ec4780e14 Fixed some reactivity for network selected. 2025-04-15 17:56:32 -03:00
Filipe Soccol
0186afe971 Fix seller parameter for offer. 2025-04-15 16:33:07 -03:00
Filipe Soccol
bca93282ac Merge branch 'libraries-refactor' into buy-refactor 2025-04-15 16:07:09 -03:00
Filipe Soccol
fe06c46c3f Small adjusts and set viem to older stable version. 2025-04-15 16:06:07 -03:00
Filipe Soccol
4908dff58b Adjust linter errors. 2025-04-15 15:40:37 -03:00
Filipe Soccol
9fa2b34a5d Update all to useUSer composabe. Still some bugs to resolve. 2025-04-01 12:04:24 -03:00
Filipe Soccol
e93cac6086 Migrated project to Viem, removing Ethers completelly. Not finished tests. 2025-03-31 10:26:57 -03:00
hueso
3227e3209c fixed type check errors 2025-03-04 18:46:10 -03:00
hueso
54cff28ba0 removed residual mumbai tests 2025-03-04 18:38:14 -03:00
hueso
d5f9c8f6fa updated to typescript v5 (for compatibility with web3-onboard) 2025-03-04 18:35:58 -03:00
Filipe Soccol
0f17a67e00 Correct bugs and adjust Buyer form. 2024-12-03 16:18:10 -03:00
Filipe Soccol
c90f468d3c Finished refactoring for Sellet flow. 2024-12-02 12:17:47 -03:00
Filipe Soccol
c4dae86b5f Cleaned code and prepared for API communications. 2024-12-01 11:50:08 -03:00
Filipe Soccol
92f6cb4d35 Added Sellet from with all required fields. 2024-11-27 09:55:38 -03:00
Ronyell Henrique
b956c8ec2b Merge pull request #61 from liftlearning/develop
V2
2023-03-01 18:54:36 -03:00
Bruno Esteves
1d429f039a Merge pull request #30 from liftlearning/develop
V1
2023-02-06 19:47:58 -03:00
116 changed files with 8494 additions and 11041 deletions

View File

@@ -1,18 +0,0 @@
/* 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",
},
};

View File

@@ -1,60 +0,0 @@
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 }}

View File

@@ -1,64 +0,0 @@
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 }}

139
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
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"

30
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
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

28
.github/workflows/tests.yml vendored Normal file
View File

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

3
.gitmodules vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -3,13 +3,6 @@
</p> </p>
<br /> <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. 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 # Table of Contents
@@ -59,46 +52,69 @@ See [Vite Configuration Reference](https://vitejs.dev/config/).
## Dependencies ## Dependencies
### API ### API + RPC
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/)
### Alchemy Keys Copy `.env.example` to `.env` and set the per-network variables:
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`
You can also replace it with your own Alchemy Keys if you have one. | 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 |
## Build Setup ## 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: 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 yarn ### Run with bun
```sh
# Clone the repo
git clone https://github.com/liftlearning/P2Pix-Front-End
cd P2Pix-Front-End
# Install dependencies with yarn ```sh
yarn install # Pull the smart-contracts submodule (skip if you cloned with --recurse-submodules)
git submodule update --init
# 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
# Type-Check, Compile and Minify for Production # Type-Check, Compile and Minify for Production
yarn build bun run build
# Compile and Hot-Reload for Development (port 3000) # Compile and Hot-Reload for Development (port 3000)
yarn start bun start
# Lint with [ESLint](https://eslint.org/) # Lint with [ESLint](https://eslint.org/)
yarn lint bun run lint
``` ```
### Run with docker-compose ### Run with docker-compose
```sh ```sh
# Clone the repo # Pull the smart-contracts submodule (skip if you cloned with --recurse-submodules)
git clone https://github.com/liftlearning/P2Pix-Front-End git submodule update --init
cd P2Pix-Front-End
#1. Install [Docker](https://docs.docker.com/install/linux/docker-ce/ubuntu/); #1. Install [Docker](https://docs.docker.com/install/linux/docker-ce/ubuntu/);
#2. Install [Docker Compose](https://docs.docker.com/compose/install/). #2. Install [Docker Compose](https://docs.docker.com/compose/install/).
# Run docker-compose up command
docker-compose up docker-compose up
``` ```
### Backend Communication
Backend Repo: `https://gitea.kosmos.org/hueso/helpix`
Backend Endpoint: `https://api.p2pix.co/release/1279331`
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {api-key}" \
-d '{"query": "{ depositAddeds { id seller token amount } }"}' \
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 = { module.exports = {
presets: [ presets: [
[ [
"@babel/preset-env", '@babel/preset-env',
{ {
modules: false, modules: false,
}, },
@@ -10,8 +10,8 @@ module.exports = {
env: { env: {
test: { test: {
presets: [ presets: [
["@babel/preset-env", { targets: { node: "current" } }], ['@babel/preset-env', { targets: { node: 'current' } }],
"@babel/preset-typescript", '@babel/preset-typescript',
], ],
}, },
}, },

1204
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

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

2
env.d.ts vendored
View File

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

31
eslint.config.js Normal file
View File

@@ -0,0 +1,31 @@
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 },
);

21
knip.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$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"]
}

1
p2pix-smart-contracts Submodule

Submodule p2pix-smart-contracts added at c4db98ae00

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
@import './base.css'; @import "./base.css" layer(base);
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
#app { #app {
width: 100%; width: 100%;
@@ -28,3 +26,9 @@ a,
.main-container { .main-container {
@apply flex w-full md:max-w-lg flex-col justify-center items-center px-4 sm:px-8 py-4 sm:py-6 gap-4 rounded-lg border border-gray-500 backdrop-blur-md drop-shadow-lg shadow-lg mt-10; @apply flex w-full md:max-w-lg flex-col justify-center items-center px-4 sm:px-8 py-4 sm:py-6 gap-4 rounded-lg border border-gray-500 backdrop-blur-md drop-shadow-lg shadow-lg mt-10;
} }
input[type="number"] {
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 79 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

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

@@ -19,3 +19,14 @@
opacity: 0; opacity: 0;
transform: translateY(15px); transform: translateY(15px);
} }
.resize-enter-active,
.resize-leave-active {
max-height: 100px;
transition: all 0.3s ease;
}
.resize-enter-from,
.resize-leave-to {
max-height: 0px;
}

View File

@@ -1,121 +0,0 @@
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 { useEtherStore } from "@/store/ether";
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 etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.sepolia);
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0x4A2886EAEc931e04297ed336Cc55c4eb7C75BA00"
);
});
it("getTokenAddress Polygon", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.polygon);
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0xC86042E9F2977C62Da8c9dDF7F9c40fde4796A29"
);
});
it("getTokenAddress Rootstock", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.rootstock);
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0xfE841c74250e57640390f46d914C88d22C51e82e"
);
});
it("getTokenAddress Default", () => {
expect(getTokenAddress(TokenEnum.BRZ)).toBe(
"0x4A2886EAEc931e04297ed336Cc55c4eb7C75BA00"
);
});
it("getP2PixAddress Ethereum", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.sepolia);
expect(getP2PixAddress()).toBe(
"0x2414817FF64A114d91eCFA16a834d3fCf69103d4"
);
});
it("getP2PixAddress Polygon", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.polygon);
expect(getP2PixAddress()).toBe(
"0x4A2886EAEc931e04297ed336Cc55c4eb7C75BA00"
);
});
it("getP2PixAddress Rootstock", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.rootstock);
expect(getP2PixAddress()).toBe(
"0x98ba35eb14b38D6Aa709338283af3e922476dE34"
);
});
it("getP2PixAddress Default", () => {
expect(getP2PixAddress()).toBe(
"0x2414817FF64A114d91eCFA16a834d3fCf69103d4"
);
});
it("getProviderUrl Ethereum", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.sepolia);
expect(getProviderUrl()).toBe(import.meta.env.VITE_GOERLI_API_URL);
});
it("getProviderUrl Polygon", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.polygon);
expect(getProviderUrl()).toBe(import.meta.env.VITE_MUMBAI_API_URL);
});
it("getProviderUrl Rootstock", () => {
const etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.rootstock);
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 etherStore = useEtherStore();
etherStore.setNetworkId(NetworkEnum.sepolia);
expect(isPossibleNetwork(0x5)).toBe(true);
expect(isPossibleNetwork(5)).toBe(true);
expect(isPossibleNetwork(0x13881)).toBe(true);
expect(isPossibleNetwork(80001)).toBe(true);
expect(isPossibleNetwork(NaN)).toBe(false);
expect(isPossibleNetwork(0x55)).toBe(false);
});
});

View File

@@ -1,70 +0,0 @@
import { useEtherStore } from "@/store/ether";
import { NetworkEnum, TokenEnum } from "@/model/NetworkEnum";
import { JsonRpcProvider } from "ethers";
const Tokens: { [key in NetworkEnum]: { [key in TokenEnum]: string } } = {
[NetworkEnum.sepolia]: {
BRZ: "0x3eBE67A2C7bdB2081CBd34ba3281E90377462289",
// BRX: "0x3eBE67A2C7bdB2081CBd34ba3281E90377462289",
},
[NetworkEnum.rootstock]: {
BRZ: "0xfE841c74250e57640390f46d914C88d22C51e82e",
// BRX: "0xfE841c74250e57640390f46d914C88d22C51e82e",
},
};
export const getTokenByAddress = (address: string) => {
for (const network of Object.values(NetworkEnum).filter(
(v) => !isNaN(Number(v))
)) {
for (const token of Object.keys(Tokens[network as NetworkEnum])) {
if (address === Tokens[network as NetworkEnum][token as TokenEnum]) {
return token as TokenEnum;
}
}
}
return null;
};
const getTokenAddress = (token: TokenEnum, network?: NetworkEnum): string => {
const etherStore = useEtherStore();
return Tokens[network ? network : etherStore.networkName][token];
};
const getP2PixAddress = (network?: NetworkEnum): string => {
const etherStore = useEtherStore();
const possibleP2PixAddresses: { [key in NetworkEnum]: string } = {
[NetworkEnum.sepolia]: "0xb7cD135F5eFD9760981e02E2a898790b688939fe",
[NetworkEnum.rootstock]: "0x98ba35eb14b38D6Aa709338283af3e922476dE34",
};
return possibleP2PixAddresses[network ? network : etherStore.networkName];
};
const getProviderUrl = (network?: NetworkEnum): string => {
const etherStore = useEtherStore();
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 || etherStore.networkName];
};
const getProviderByNetwork = (network: NetworkEnum): JsonRpcProvider => {
console.log("network", network);
return new JsonRpcProvider(getProviderUrl(network), network);
};
const isPossibleNetwork = (networkChain: NetworkEnum): boolean => {
return Number(networkChain) in NetworkEnum;
};
export {
getTokenAddress,
getProviderUrl,
isPossibleNetwork,
getP2PixAddress,
getProviderByNetwork,
};

View File

@@ -1,97 +1,78 @@
import { getContract, getProvider } from "./provider"; import { getContract } from './provider';
import { getP2PixAddress, getTokenAddress } from "./addresses"; import { ChainContract } from 'viem';
import { parseEther, type Address, type TransactionReceipt } from 'viem';
import p2pix from "@/utils/smart_contract_files/P2PIX.json"; export const addLock = async (
sellerAddress: Address,
import { tokenAddress: Address,
solidityPackedKeccak256,
encodeBytes32String,
Signature,
Contract,
getBytes,
Wallet,
parseEther,
} from "ethers";
import type { TokenEnum } from "@/model/NetworkEnum";
const addLock = async (
seller: string,
token: string,
amount: number
): Promise<string> => {
const p2pContract = await getContract();
const lock = await p2pContract.lock(
seller,
token,
parseEther(String(amount)), // BigNumber
[],
[]
);
const lock_rec = await lock.wait();
const [t] = lock_rec.events;
return String(t.args.lockID);
};
const releaseLock = async (
pixKey: string,
amount: number, amount: number,
e2eId: string, ): Promise<bigint> => {
lockId: string const { address, abi, wallet, client, account } = await getContract();
): Promise<any> => { const parsedAmount = parseEther(amount.toString());
const mockBacenSigner = new Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
);
const messageToSign = solidityPackedKeccak256( if (!wallet) {
["bytes32", "uint256", "bytes32"], throw new Error('Wallet not connected');
[pixKey, parseEther(String(amount)), encodeBytes32String(e2eId)] }
);
const messageHashBytes = getBytes(messageToSign); const { result, request } = await client.simulateContract({
const flatSig = await mockBacenSigner.signMessage(messageHashBytes); address,
const provider = getProvider(); abi,
functionName: 'lock',
args: [sellerAddress, tokenAddress, parsedAmount, [], []],
account,
});
const hash = await wallet.writeContract(request);
const receipt = await client.waitForTransactionReceipt({ hash });
const sig = Signature.from(flatSig); if (!receipt.status)
console.log(sig); throw new Error('Transaction failed: ' + receipt.transactionHash);
const signer = await provider.getSigner();
const p2pContract = new Contract(getP2PixAddress(), p2pix.abi, signer);
const release = await p2pContract.release( return result;
BigInt(lockId),
encodeBytes32String(e2eId),
flatSig
);
await release.wait();
return release;
}; };
const cancelDeposit = async (depositId: bigint): Promise<any> => { export const withdrawDeposit = async (
const contract = await getContract();
const cancel = await contract.cancelDeposit(depositId);
await cancel.wait();
return cancel;
};
const withdrawDeposit = async (
amount: string, amount: string,
token: TokenEnum token: Address,
): Promise<any> => { ): Promise<boolean> => {
const contract = await getContract(); const { address, abi, wallet, client, account } = await getContract();
const withdraw = await contract.withdraw( if (!wallet) {
getTokenAddress(token), throw new Error('Wallet not connected');
parseEther(String(amount)), }
[]
);
await withdraw.wait();
return withdraw; const { request } = await client.simulateContract({
address,
abi,
functionName: 'withdraw',
args: [token, parseEther(amount), []],
account,
});
const hash = await wallet.writeContract(request);
const receipt = await client.waitForTransactionReceipt({ hash });
return receipt.status === 'success';
}; };
export { cancelDeposit, withdrawDeposit, addLock, releaseLock }; export const releaseLock = async (
lockID: bigint,
pixTimestamp: `0x${string}` & { lenght: 34 },
signature: `0x${string}`,
): Promise<TransactionReceipt> => {
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,
});
const hash = await wallet.writeContract(request);
return client.waitForTransactionReceipt({ hash });
};

View File

@@ -1,131 +1,168 @@
import { useEtherStore } from "@/store/ether"; import { useUser } from '@/composables/useUser';
import { Contract, formatEther, Interface } from "ethers"; import { formatEther, toHex, stringToHex } from 'viem';
import type { PublicClient, Address } from 'viem';
import p2pix from "@/utils/smart_contract_files/P2PIX.json"; import { Networks } from '@/config/networks';
import { getContract } from "./provider"; import { getContract } from './provider';
import type { ValidDeposit } from "@/model/ValidDeposit"; import { p2PixAbi } from './abi';
import { import type { ValidDeposit } from '@/model/ValidDeposit';
getP2PixAddress, import type { NetworkConfig } from '@/model/NetworkEnum';
getProviderByNetwork, import type { UnreleasedLock } from '@/model/UnreleasedLock';
getTokenAddress, import { ChainContract } from 'viem';
} from "./addresses";
import { NetworkEnum } from "@/model/NetworkEnum";
import type { UnreleasedLock } from "@/model/UnreleasedLock";
import type { Pix } from "@/model/Pix";
const getNetworksLiquidity = async (): Promise<void> => { const getNetworksLiquidity = async (): Promise<void> => {
const etherStore = useEtherStore(); const user = useUser();
etherStore.setLoadingNetworkLiquidity(true); user.setLoadingNetworkLiquidity(true);
const depositLists: ValidDeposit[][] = []; const depositLists: ValidDeposit[][] = [];
for (const network of Object.values(NetworkEnum).filter( for (const network of Object.values(Networks)) {
(v) => !isNaN(Number(v)) const deposits = await getValidDeposits(
)) { user.network.value.tokens[user.selectedToken.value].address,
console.log("getNetworksLiquidity", network); network,
const p2pContract = new Contract(
getP2PixAddress(network as NetworkEnum),
p2pix.abi,
getProviderByNetwork(network as NetworkEnum)
);
depositLists.push(
await getValidDeposits(
getTokenAddress(etherStore.selectedToken, network as NetworkEnum),
network as NetworkEnum,
p2pContract
)
); );
if (deposits) depositLists.push(deposits);
} }
etherStore.setDepositsValidList(depositLists.flat()); const allDeposits = depositLists.flat();
etherStore.setLoadingNetworkLiquidity(false); user.setDepositsValidList(allDeposits);
user.setLoadingNetworkLiquidity(false);
}; };
const getPixKey = async (seller: string, token: string): Promise<string> => { const getParticipantID = async (
const p2pContract = await getContract(); seller: Address,
const pixKeyHex = await p2pContract.getPixTarget(seller, token); token: Address,
): Promise<string> => {
const { address, abi, client } = await getContract();
const participantIDHex = await client.readContract({
address,
abi,
functionName: 'getPixTarget',
args: [seller, token],
});
// Remove '0x' prefix and convert hex to UTF-8 string // 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');
const bytes = new Uint8Array( const bytes = new Uint8Array(
pixKeyHex hexString
.slice(2) .slice(2)
.match(/.{1,2}/g) .match(/.{1,2}/g)!
.map((byte: string) => parseInt(byte, 16)) .map((byte: string) => parseInt(byte, 16)),
); );
// Remove null bytes from the end of the string // 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 ( const getValidDeposits = async (
token: string, token: Address,
network: NetworkEnum, network: NetworkConfig,
contract?: Contract contractInfo?: { client: PublicClient; address: Address },
): Promise<ValidDeposit[]> => { ): Promise<ValidDeposit[]> => {
let p2pContract: Contract; let client: PublicClient, abi;
if (contract) { if (contractInfo) {
p2pContract = contract; ({ client } = contractInfo);
abi = p2PixAbi;
} else { } else {
p2pContract = await getContract(true); ({ abi, client } = await getContract(true));
} }
const filterDeposits = p2pContract.filters.DepositAdded(null); const body = {
const eventsDeposits = await p2pContract.queryFilter( query: `
filterDeposits {
// 0, depositAddeds(where: { token: "${token}" }) {
// "latest" seller
amount
blockTimestamp
blockNumber
}
}
`,
};
const depositLogs = await fetch(network.subgraphUrls[0], {
method: 'POST',
headers: {
'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[deposit.seller] = true;
return acc;
},
{} as Record<Address, boolean>,
); );
if (!contract) p2pContract = await getContract(); // get metamask provider contract
if (!contractInfo) {
// Get metamask provider contract
({ abi, client } = await getContract(true));
}
const depositList: { [key: string]: ValidDeposit } = {}; const depositList: { [key: string]: ValidDeposit } = {};
for (const deposit of eventsDeposits) { const sellersList = Object.keys(uniqueSellers) as Address[];
const IPix2Pix = new Interface(p2pix.abi); // Use multicall to batch all getBalance requests
const decoded = IPix2Pix.parseLog({ const balanceCalls = sellersList.map((seller) => ({
topics: deposit.topics, address: (network.contracts?.p2pix as ChainContract).address,
data: deposit.data, abi,
functionName: 'getBalance',
args: [seller, token],
}));
const balanceResults = await client.multicall({
contracts: balanceCalls as any,
}); });
// Get liquidity only for the selected token
if (decoded?.args.token != token) continue;
const mappedBalance = await p2pContract.getBalance(
decoded.args.seller,
token
);
let validDeposit: ValidDeposit | null = null;
if (mappedBalance) { // Process results into the depositList
validDeposit = { sellersList.forEach((seller, index) => {
token: token, const mappedBalance = balanceResults[index];
blockNumber: deposit.blockNumber,
remaining: Number(formatEther(mappedBalance)), if (!mappedBalance.error && mappedBalance.result) {
seller: decoded.args.seller, const validDeposit: ValidDeposit = {
token,
blockNumber: 0,
remaining: Number(formatEther(mappedBalance.result as bigint)),
seller,
network, network,
pixKey: "", participantID: '',
}; };
depositList[seller + token] = validDeposit;
} }
if (validDeposit) depositList[decoded.args.seller + token] = validDeposit; });
}
return Object.values(depositList); return Object.values(depositList);
}; };
const getUnreleasedLockById = async ( const getUnreleasedLockById = async (
lockID: string lockID: bigint,
): Promise<UnreleasedLock> => { ): Promise<UnreleasedLock> => {
const p2pContract = await getContract(); const { address, abi, client } = await getContract();
const pixData: Pix = {
pixKey: "",
};
const lock = await p2pContract.mapLocks(lockID); const [, , , amount, token, seller] = await client.readContract({
address,
const pixTarget = lock.pixTarget; abi,
const amount = formatEther(lock?.amount); functionName: 'mapLocks',
pixData.pixKey = pixTarget; args: [lockID],
pixData.value = Number(amount); });
return { return {
lockID: lockID, lockID,
pix: pixData, amount: Number(formatEther(amount)),
tokenAddress: token,
sellerAddress: seller,
}; };
}; };
@@ -133,5 +170,5 @@ export {
getValidDeposits, getValidDeposits,
getNetworksLiquidity, getNetworksLiquidity,
getUnreleasedLockById, getUnreleasedLockById,
getPixKey, getParticipantID,
}; };

View File

@@ -1,27 +1,64 @@
import p2pix from "@/utils/smart_contract_files/P2PIX.json"; import { p2PixAbi } from './abi';
import { updateWalletStatus } from "./wallet"; import { updateWalletStatus } from './wallet';
import { getProviderUrl, getP2PixAddress } from "./addresses"; import {
import { BrowserProvider, JsonRpcProvider, Contract } from "ethers"; createPublicClient,
createWalletClient,
custom,
http,
PublicClient,
WalletClient,
} from 'viem';
import { useUser } from '@/composables/useUser';
import type { NetworkConfig } from '@/model/NetworkEnum';
import type { ChainContract } from 'viem';
let provider: BrowserProvider | JsonRpcProvider | null = null; let walletClient: WalletClient | null = null;
const getProvider = (onlyAlchemyProvider: boolean = false) => { const getPublicClient = (): PublicClient => {
if (onlyAlchemyProvider) return new JsonRpcProvider(getProviderUrl()); // alchemy provider const user = useUser();
return provider; const rpcUrl = (user.network.value as NetworkConfig).rpcUrls.default.http[0];
const chain = user.network.value;
return createPublicClient({
chain,
transport: http(rpcUrl),
});
}; };
const getContract = async (onlyAlchemyProvider: boolean = false) => { const getWalletClient = (): WalletClient | null => {
const p = getProvider(onlyAlchemyProvider); return walletClient;
try { };
const signer = await p?.getSigner();
return new Contract(getP2PixAddress(), p2pix.abi, signer); const getContract = async (onlyRpcProvider = false) => {
} catch (err) { const client = getPublicClient();
return new Contract(getP2PixAddress(), p2pix.abi, p); 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');
} }
const [account] = wallet ? await wallet.getAddresses() : [null];
return { address, abi, client, wallet, account };
}; };
const connectProvider = async (p: any): Promise<void> => { const connectProvider = async (p: any): Promise<void> => {
provider = new BrowserProvider(p, "any"); const user = useUser();
const chain = user.network.value;
const [account] = await p!.request({ method: 'eth_requestAccounts' });
walletClient = createWalletClient({
account,
chain,
transport: custom(p),
});
await updateWalletStatus(); await updateWalletStatus();
}; };
export { getProvider, getContract, connectProvider };
export { getPublicClient, getWalletClient, getContract, connectProvider };

View File

@@ -1,54 +1,92 @@
import { getContract, getProvider } from "./provider"; import { getContract, getPublicClient, getWalletClient } from './provider';
import { getTokenAddress, getP2PixAddress } from "./addresses"; 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 { encodeBytes32String, Contract, parseEther } from "ethers"; const getP2PixAddress = (): Address => {
const user = useUser();
return (user.network.value.contracts?.p2pix as ChainContract).address;
};
import mockToken from "../utils/smart_contract_files/MockToken.json"; const approveTokens = async (participant: Participant): Promise<any> => {
import { useEtherStore } from "@/store/ether"; const user = useUser();
const publicClient = getPublicClient();
const walletClient = getWalletClient();
const approveTokens = async (tokenQty: string): Promise<any> => { if (!publicClient || !walletClient) {
const provider = getProvider(); throw new Error('Clients not initialized');
const signer = await provider?.getSigner(); }
const etherStore = useEtherStore();
const tokenContract = new Contract( user.setSeller(participant);
getTokenAddress(etherStore.selectedToken), const [account] = await walletClient.getAddresses();
mockToken.abi,
signer // Get token address
); const tokenAddress =
user.network.value.tokens[user.selectedToken.value].address;
// Check if the token is already approved // Check if the token is already approved
const approved = await tokenContract.allowance( const allowance = await publicClient.readContract({
await signer?.getAddress(), address: tokenAddress,
getP2PixAddress() abi: mockTokenAbi,
); functionName: 'allowance',
if (approved < parseEther(tokenQty)) { args: [account, getP2PixAddress()],
});
if (allowance < parseEther(participant.offer.toString())) {
// Approve tokens // Approve tokens
const apprv = await tokenContract.approve( const chain = user.network.value;
getP2PixAddress(), const hash = await walletClient.writeContract({
parseEther(tokenQty) address: tokenAddress,
); abi: mockTokenAbi,
await apprv.wait(); functionName: 'approve',
args: [getP2PixAddress(), parseEther(participant.offer.toString())],
account,
chain,
});
await publicClient.waitForTransactionReceipt({ hash });
return true; return true;
} }
return true; return true;
}; };
const addDeposit = async (tokenQty: string, pixKey: string): Promise<any> => { const addDeposit = async (): Promise<any> => {
const p2pContract = await getContract(); const { address, abi, client } = await getContract();
const etherStore = useEtherStore(); const walletClient = getWalletClient();
const user = useUser();
const deposit = await p2pContract.deposit( if (!walletClient) {
pixKey, throw new Error('Wallet client not initialized');
encodeBytes32String(""), }
getTokenAddress(etherStore.selectedToken),
parseEther(tokenQty),
true
);
await deposit.wait(); const [account] = await walletClient.getAddresses();
return deposit; 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({
address,
abi,
functionName: 'deposit',
args: [
user.network.value.id + '-' + sellerId.id,
toHex('', { size: 32 }),
user.network.value.tokens[user.selectedToken.value].address,
parseEther(user.seller.value.offer.toString()),
true,
],
account,
chain,
});
const receipt = await client.waitForTransactionReceipt({ hash });
return receipt;
}; };
export { approveTokens, addDeposit }; export { approveTokens, addDeposit };

View File

@@ -1,64 +1,46 @@
import { import { formatEther, type Address } from 'viem';
Contract, import { useUser } from '@/composables/useUser';
formatEther,
getAddress,
Interface,
Log,
LogDescription,
} from "ethers";
import { useEtherStore } from "@/store/ether";
import { getContract, getProvider } from "./provider"; import { getPublicClient, getWalletClient, getContract } from './provider';
import { getTokenAddress, isPossibleNetwork } from "./addresses";
import mockToken from "@/utils/smart_contract_files/MockToken.json"; import { getValidDeposits, getUnreleasedLockById } from './events';
import p2pix from "@/utils/smart_contract_files/P2PIX.json";
import { getValidDeposits } from "./events"; import type { ValidDeposit } from '@/model/ValidDeposit';
import type { WalletTransaction } from '@/model/WalletTransaction';
import type { ValidDeposit } from "@/model/ValidDeposit"; import type { UnreleasedLock } from '@/model/UnreleasedLock';
import type { WalletTransaction } from "@/model/WalletTransaction"; import { LockStatus } from '@/model/LockStatus';
import type { UnreleasedLock } from "@/model/UnreleasedLock";
import type { Pix } from "@/model/Pix";
export const updateWalletStatus = async (): Promise<void> => { export const updateWalletStatus = async (): Promise<void> => {
const etherStore = useEtherStore(); const user = useUser();
const provider = await getProvider(); const publicClient = getPublicClient();
const signer = await provider?.getSigner(); const walletClient = getWalletClient();
const { chainId } = await provider?.getNetwork(); if (!publicClient || !walletClient) {
if (!isPossibleNetwork(Number(chainId))) { console.error('Client not initialized');
window.alert("Invalid chain!:" + chainId);
return; return;
} }
etherStore.setNetworkId(Number(chainId));
const mockTokenContract = new Contract( // Get balance
getTokenAddress(etherStore.selectedToken), const [account] = await walletClient.getAddresses();
mockToken.abi, const balance = await publicClient.getBalance({ address: account });
signer
);
const walletAddress = await provider?.send("eth_requestAccounts", []); user.setWalletAddress(account);
const balance = await mockTokenContract.balanceOf(walletAddress[0]); user.setBalance(formatEther(balance));
etherStore.setBalance(formatEther(balance));
etherStore.setWalletAddress(getAddress(walletAddress[0]));
}; };
export const listValidDepositTransactionsByWalletAddress = async ( export const listValidDepositTransactionsByWalletAddress = async (
walletAddress: string walletAddress: Address,
): Promise<ValidDeposit[]> => { ): Promise<ValidDeposit[]> => {
const etherStore = useEtherStore(); const user = useUser();
const walletDeposits = await getValidDeposits( const walletDeposits = await getValidDeposits(
getTokenAddress(etherStore.selectedToken), user.network.value.tokens[user.selectedToken.value].address,
etherStore.networkName user.network.value,
); );
if (walletDeposits) { if (walletDeposits) {
return walletDeposits return walletDeposits
.filter((deposit) => deposit.seller == walletAddress) .filter((deposit) => deposit.seller == walletAddress)
.sort((a, b) => { .sort((a: ValidDeposit, b: ValidDeposit) => {
return b.blockNumber - a.blockNumber; return b.blockNumber - a.blockNumber;
}); });
} }
@@ -66,235 +48,353 @@ export const listValidDepositTransactionsByWalletAddress = async (
return []; return [];
}; };
const getLockStatus = async (id: [BigInt]): Promise<number> => { const getLockStatus = async (id: bigint): Promise<LockStatus> => {
const p2pContract = await getContract(); const { address, abi, client } = await getContract();
const res = await p2pContract.getLocksStatus([id]); const [sortedIDs, status] = await client.readContract({
return res[1][0]; address,
}; abi,
functionName: 'getLocksStatus',
const filterLockStatus = async ( args: [[id]],
transactions: Log[]
): Promise<WalletTransaction[]> => {
const txs: WalletTransaction[] = [];
for (const transaction of transactions) {
const IPix2Pix = new Interface(p2pix.abi);
const decoded = IPix2Pix.parseLog({
topics: transaction.topics,
data: transaction.data,
}); });
if (!decoded) continue; return status[0];
const tx: WalletTransaction = {
token: decoded.args.token ? decoded.args.token : "",
blockNumber: transaction.blockNumber,
amount: decoded.args.amount
? Number(formatEther(decoded.args.amount))
: -1,
seller: decoded.args.seller ? decoded.args.seller : "",
buyer: decoded.args.buyer ? decoded.args.buyer : "",
event: decoded.name,
lockStatus:
decoded.name == "LockAdded"
? await getLockStatus(decoded.args.lockID)
: -1,
transactionHash: transaction.transactionHash
? transaction.transactionHash
: "",
transactionID: decoded.args.lockID ? decoded.args.lockID.toString() : "",
};
txs.push(tx);
}
return txs;
}; };
export const listAllTransactionByWalletAddress = async ( export const listAllTransactionByWalletAddress = async (
walletAddress: string walletAddress: Address,
): Promise<WalletTransaction[]> => { ): Promise<WalletTransaction[]> => {
const p2pContract = await getContract(true); const user = useUser();
// Get deposits // Get the current network for the subgraph URL
const filterDeposits = p2pContract.filters.DepositAdded([walletAddress]); const network = user.network.value;
const eventsDeposits = await p2pContract.queryFilter(
filterDeposits,
0,
"latest"
);
console.log("Fetched all wallet deposits");
// Get locks // Query subgraph for all relevant transactions
const filterAddedLocks = p2pContract.filters.LockAdded([walletAddress]); const subgraphQuery = {
const eventsAddedLocks = await p2pContract.queryFilter( query: `
filterAddedLocks, {
0, depositAddeds(where: {seller: "${walletAddress.toLowerCase()}"}) {
"latest" id
); seller
console.log("Fetched all wallet locks"); token
amount
blockTimestamp
blockNumber
transactionHash
}
lockAddeds(where: {buyer: "${walletAddress.toLowerCase()}"}) {
buyer
lockID
seller
amount
blockTimestamp
blockNumber
transactionHash
}
lockReleaseds(where: {buyer: "${walletAddress.toLowerCase()}"}) {
buyer
lockId
blockTimestamp
blockNumber
transactionHash
}
depositWithdrawns(where: {seller: "${walletAddress.toLowerCase()}"}) {
seller
token
amount
blockTimestamp
blockNumber
transactionHash
}
}
`,
};
// Get released locks const response = await fetch(network.subgraphUrls[0], {
const filterReleasedLocks = p2pContract.filters.LockReleased([walletAddress]); method: 'POST',
const eventsReleasedLocks = await p2pContract.queryFilter( headers: {
filterReleasedLocks, 'Content-Type': 'application/json',
0, },
"latest" body: JSON.stringify(subgraphQuery),
); });
console.log("Fetched all wallet released locks");
// Get withdrawn deposits const data = await response.json();
const filterWithdrawnDeposits = p2pContract.filters.DepositWithdrawn([ // Convert all transactions to common WalletTransaction format
walletAddress, const transactions: WalletTransaction[] = [];
]);
const eventsWithdrawnDeposits = await p2pContract.queryFilter(
filterWithdrawnDeposits
);
console.log("Fetched all wallet withdrawn deposits");
const lockStatusFiltered = await filterLockStatus( // Process deposit added events
[ if (data.data?.depositAddeds) {
...eventsDeposits, for (const deposit of data.data.depositAddeds) {
...eventsAddedLocks, transactions.push({
...eventsReleasedLocks, token: deposit.token,
...eventsWithdrawnDeposits, blockNumber: parseInt(deposit.blockNumber),
].sort((a, b) => { blockTimestamp: parseInt(deposit.blockTimestamp),
return b.blockNumber - a.blockNumber; amount: parseFloat(formatEther(BigInt(deposit.amount))),
}) seller: deposit.seller,
); buyer: '',
event: 'DepositAdded',
lockStatus: undefined,
transactionHash: deposit.transactionHash,
});
}
}
return lockStatusFiltered; // Process lock added events
if (data.data?.lockAddeds) {
for (const lock of data.data.lockAddeds) {
// Get lock status from the contract
const lockStatus = await getLockStatus(BigInt(lock.lockID));
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',
lockStatus: lockStatus,
transactionHash: lock.transactionHash,
transactionID: lock.lockID.toString(),
});
}
}
// Process lock released events
if (data.data?.lockReleaseds) {
for (const release of data.data.lockReleaseds) {
transactions.push({
token: undefined, // 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: '',
buyer: release.buyer,
event: 'LockReleased',
lockStatus: undefined,
transactionHash: release.transactionHash,
transactionID: release.lockId.toString(),
});
}
}
// Process deposit withdrawn events
if (data.data?.depositWithdrawns) {
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,
transactionHash: withdrawal.transactionHash,
});
}
}
// Sort transactions by block number (newest first)
return transactions.sort((a, b) => b.blockNumber - a.blockNumber);
}; };
// get wallet's release transactions const listLockTransactionByWalletAddress = async (walletAddress: Address) => {
export const listReleaseTransactionByWalletAddress = async ( const user = useUser();
walletAddress: string const network = user.network.value;
): Promise<LogDescription[]> => {
const p2pContract = await getContract(true);
const filterReleasedLocks = p2pContract.filters.LockReleased([walletAddress]); // Query subgraph for lock added transactions
const eventsReleasedLocks = await p2pContract.queryFilter( const subgraphQuery = {
filterReleasedLocks, query: `
0, {
"latest" lockAddeds(where: {buyer: "${walletAddress.toLowerCase()}"}) {
); buyer
lockID
seller
amount
blockTimestamp
blockNumber
transactionHash
}
}
`,
};
return eventsReleasedLocks try {
.sort((a, b) => { // Fetch data from subgraph
return b.blockNumber - a.blockNumber; const response = await fetch(network.subgraphUrls[0], {
}) method: 'POST',
.map((lock) => { headers: {
const IPix2Pix = new Interface(p2pix.abi); 'Content-Type': 'application/json',
const decoded = IPix2Pix.parseLog({ },
topics: lock.topics, body: JSON.stringify(subgraphQuery),
data: lock.data,
}); });
return decoded;
const data = await response.json();
if (!data.data?.lockAddeds) {
return [];
}
// Transform the subgraph data to match the event log decode format
return data.data.lockAddeds
.sort((a: any, b: any) => {
return parseInt(b.blockNumber) - parseInt(a.blockNumber);
}) })
.filter((lock) => lock !== null); .map((lock: any) => {
try {
// Create a structure similar to the decoded event log
return {
eventName: 'LockAdded',
args: {
buyer: lock.buyer,
lockID: BigInt(lock.lockID),
seller: lock.seller,
token: undefined, // Token not available in LockAdded subgraph event
amount: BigInt(lock.amount),
},
// Add other necessary fields to match the original format
blockNumber: BigInt(lock.blockNumber),
transactionHash: lock.transactionHash,
};
} catch (error) {
console.error('Error processing subgraph data', error);
return null;
}
})
.filter((decoded: any) => decoded !== null);
} catch (error) {
console.error('Error fetching from subgraph:', error);
}
}; };
const listLockTransactionByWalletAddress = async ( const listLockTransactionBySellerAddress = async (sellerAddress: Address) => {
walletAddress: string const user = useUser();
): Promise<LogDescription[]> => { const network = user.network.value;
const p2pContract = await getContract(true);
const filterAddedLocks = p2pContract.filters.LockAdded([walletAddress]); // Query subgraph for lock added transactions where seller matches
const eventsReleasedLocks = await p2pContract.queryFilter(filterAddedLocks); const subgraphQuery = {
query: `
{
lockAddeds(where: {seller: "${sellerAddress.toLowerCase()}"}) {
buyer
lockID
seller
amount
blockTimestamp
blockNumber
transactionHash
}
}
`,
};
return eventsReleasedLocks try {
.sort((a, b) => { // Fetch data from subgraph
return b.blockNumber - a.blockNumber; const response = await fetch(network.subgraphUrls[0], {
}) method: 'POST',
.map((lock) => { headers: {
const IPix2Pix = new Interface(p2pix.abi); 'Content-Type': 'application/json',
const decoded = IPix2Pix.parseLog({ },
topics: lock.topics, body: JSON.stringify(subgraphQuery),
data: lock.data,
}); });
return decoded;
})
.filter((lock) => lock !== null);
};
const listLockTransactionBySellerAddress = async ( const data = await response.json();
sellerAddress: string
): Promise<LogDescription[]> => { if (!data.data?.lockAddeds) {
const p2pContract = await getContract(true); return [];
console.log("Will get locks as seller", sellerAddress); }
const filterAddedLocks = p2pContract.filters.LockAdded();
const eventsReleasedLocks = await p2pContract.queryFilter( // Transform the subgraph data to match the event log decode format
filterAddedLocks return data.data.lockAddeds
// 0, .sort((a: any, b: any) => {
// "latest" return parseInt(b.blockNumber) - parseInt(a.blockNumber);
);
return eventsReleasedLocks
.map((lock) => {
const IPix2Pix = new Interface(p2pix.abi);
const decoded = IPix2Pix.parseLog({
topics: lock.topics,
data: lock.data,
});
return decoded;
}) })
.filter((lock) => lock !== null) .map((lock: any) => {
.filter( try {
(lock) => lock.args.seller.toLowerCase() == sellerAddress.toLowerCase() // Create a structure similar to the decoded event log
); return {
eventName: 'LockAdded',
args: {
buyer: lock.buyer,
lockID: BigInt(lock.lockID),
seller: lock.seller,
token: undefined, // Token not available in LockAdded subgraph event
amount: BigInt(lock.amount),
},
// Add other necessary fields to match the original format
blockNumber: BigInt(lock.blockNumber),
transactionHash: lock.transactionHash,
};
} catch (error) {
console.error('Error processing subgraph data', error);
return null;
}
})
.filter((decoded: any) => decoded !== null);
} catch (error) {
console.error('Error fetching from subgraph:', error);
return [];
}
}; };
export const checkUnreleasedLock = async ( export const checkUnreleasedLock = async (
walletAddress: string walletAddress: Address,
): Promise<UnreleasedLock | undefined> => { ): Promise<UnreleasedLock | undefined> => {
const p2pContract = await getContract(); const { address, abi, client } = await getContract();
const pixData: Pix = {
pixKey: "",
};
const addedLocks = await listLockTransactionByWalletAddress(walletAddress); const addedLocks = await listLockTransactionByWalletAddress(walletAddress);
const lockStatus = await p2pContract.getLocksStatus(
addedLocks.map((lock) => lock.args?.lockID) if (!addedLocks.length) return undefined;
);
const unreleasedLockId = lockStatus[1].findIndex( const lockIds = addedLocks.map((lock: any) => lock.args.lockID);
(lockStatus: number) => lockStatus == 1
const [sortedIDs, status] = await client.readContract({
address,
abi,
functionName: 'getLocksStatus',
args: [lockIds],
});
const unreleasedLockId = status.findIndex(
(status: LockStatus) => status == LockStatus.Active,
); );
if (unreleasedLockId != -1) { if (unreleasedLockId !== -1)
const _lockID = lockStatus[0][unreleasedLockId]; return getUnreleasedLockById(sortedIDs[unreleasedLockId]);
const lock = await p2pContract.mapLocks(_lockID);
const pixTarget = lock.pixTarget;
const amount = formatEther(lock?.amount);
pixData.pixKey = pixTarget;
pixData.value = Number(amount);
return {
lockID: _lockID,
pix: pixData,
};
}
}; };
export const getActiveLockAmount = async ( export const getActiveLockAmount = async (
walletAddress: string walletAddress: Address,
): Promise<number> => { ): Promise<number> => {
const p2pContract = await getContract(true); const { address, abi, client } = await getContract(true);
const lockSeller = await listLockTransactionBySellerAddress(walletAddress); const lockSeller = await listLockTransactionBySellerAddress(walletAddress);
const lockStatus = await p2pContract.getLocksStatus( if (!lockSeller.length) return 0;
lockSeller.map((lock) => lock.args?.lockID)
const lockIds = lockSeller.map((lock: any) => lock.args.lockID);
const [sortedIDs, status] = await client.readContract({
address,
abi,
functionName: 'getLocksStatus',
args: [lockIds],
});
const mapLocksRequests = status.map((id: LockStatus) =>
client.readContract({
address: address,
abi,
functionName: 'mapLocks',
args: [BigInt(id)],
}),
); );
const activeLockAmount = await lockStatus[1].reduce( const mapLocksResults = await client.multicall({
async (sumValue: Promise<number>, currentStatus: number, index: number) => { contracts: mapLocksRequests as any,
const currValue = await sumValue; });
let valueToSum = 0;
if (currentStatus == 1) { return mapLocksResults.reduce((total: number, lock: any, index: number) => {
const lock = await p2pContract.mapLocks(lockStatus[0][index]); if (status[index] === 1) {
valueToSum = Number(formatEther(lock?.amount)); return total + Number(formatEther(lock.amount));
} }
return total;
return currValue + valueToSum; }, 0);
},
Promise.resolve(0)
);
return activeLockAmount;
}; };

View File

@@ -1,37 +0,0 @@
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,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { withdrawDeposit } from "@/blockchain/buyerMethods"; import { withdrawDeposit } from '@/blockchain/buyerMethods';
import { import {
getActiveLockAmount, getActiveLockAmount,
listAllTransactionByWalletAddress, listAllTransactionByWalletAddress,
listValidDepositTransactionsByWalletAddress, listValidDepositTransactionsByWalletAddress,
} from "@/blockchain/wallet"; } from '@/blockchain/wallet';
import CustomButton from "@/components/CustomButton/CustomButton.vue"; import CustomButton from '@/components/ui/CustomButton.vue';
import type { ValidDeposit } from "@/model/ValidDeposit"; import type { ValidDeposit } from '@/model/ValidDeposit';
import type { WalletTransaction } from "@/model/WalletTransaction"; import type { WalletTransaction } from '@/model/WalletTransaction';
import { useEtherStore } from "@/store/ether"; import { useUser } from '@/composables/useUser';
import { storeToRefs } from "pinia"; import { onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from "vue"; import ListingComponent from '@/components/ListingComponent/ListingComponent.vue';
import ListingComponent from "../ListingComponent/ListingComponent.vue";
// props // props
const props = defineProps<{ const props = defineProps<{
@@ -19,8 +18,8 @@ const props = defineProps<{
isCurrentStep: boolean; isCurrentStep: boolean;
}>(); }>();
const etherStore = useEtherStore(); const user = useUser();
const { walletAddress } = storeToRefs(etherStore); const { walletAddress } = useUser();
const lastWalletTransactions = ref<WalletTransaction[]>([]); const lastWalletTransactions = ref<WalletTransaction[]>([]);
const depositList = ref<ValidDeposit[]>([]); const depositList = ref<ValidDeposit[]>([]);
@@ -29,14 +28,14 @@ const activeLockAmount = ref<number>(0);
// methods // methods
const getWalletTransactions = async () => { const getWalletTransactions = async () => {
etherStore.setLoadingWalletTransactions(true); user.setLoadingWalletTransactions(true);
if (walletAddress.value) { if (walletAddress.value) {
const walletDeposits = await listValidDepositTransactionsByWalletAddress( const walletDeposits = await listValidDepositTransactionsByWalletAddress(
walletAddress.value walletAddress.value,
); );
const allUserTransactions = await listAllTransactionByWalletAddress( const allUserTransactions = await listAllTransactionByWalletAddress(
walletAddress.value walletAddress.value,
); );
activeLockAmount.value = await getActiveLockAmount(walletAddress.value); activeLockAmount.value = await getActiveLockAmount(walletAddress.value);
@@ -48,25 +47,28 @@ const getWalletTransactions = async () => {
lastWalletTransactions.value = allUserTransactions; lastWalletTransactions.value = allUserTransactions;
} }
} }
etherStore.setLoadingWalletTransactions(false); user.setLoadingWalletTransactions(false);
}; };
const callWithdraw = async (amount: string) => { const callWithdraw = async (amount: string) => {
if (amount) { if (amount) {
etherStore.setLoadingWalletTransactions(true); user.setLoadingWalletTransactions(true);
const withdraw = await withdrawDeposit(amount, etherStore.selectedToken); const withdraw = await withdrawDeposit(
amount,
user.network.value.tokens[user.selectedToken.value].address,
);
if (withdraw) { if (withdraw) {
console.log("Saque realizado!"); console.log('Saque realizado!');
await getWalletTransactions(); await getWalletTransactions();
} else { } else {
console.log("Não foi possível realizar o saque!"); console.log('Não foi possível realizar o saque!');
} }
etherStore.setLoadingWalletTransactions(false); user.setLoadingWalletTransactions(false);
} }
}; };
// Emits // Emits
const emit = defineEmits(["makeAnotherTransaction"]); const emit = defineEmits(['makeAnotherTransaction']);
// observer // observer
watch(props, async (): Promise<void> => { watch(props, async (): Promise<void> => {
@@ -87,20 +89,18 @@ onMounted(async () => {
</span> </span>
</div> </div>
<div class="main-container"> <div class="main-container">
<div <div class="flex flex-col w-full bg-white px-10 py-5 rounded-lg">
class="flex flex-col w-full bg-white px-10 py-5 rounded-lg border-y-10"
>
<div> <div>
<p>Tokens recebidos</p> <p>Tokens recebidos</p>
<p class="text-2xl text-gray-900"> <p class="text-2xl text-gray-900">
{{ props.tokenAmount }} {{ etherStore.selectedToken }} {{ props.tokenAmount }} {{ user.selectedToken }}
</p> </p>
</div> </div>
<div class="my-5"> <div class="my-5">
<p class="text-sm"> <p class="text-sm">
<b>Não encontrou os tokens? </b><br />Clique no botão abaixo para <b>Não encontrou os tokens? </b><br />Clique no botão abaixo para
<br /> <br />
cadastrar o {{ etherStore.selectedToken }} em sua carteira. cadastrar o {{ user.selectedToken }} em sua carteira.
</p> </p>
</div> </div>
<CustomButton :text="'Cadastrar token'" @buttonClicked="() => {}" /> <CustomButton :text="'Cadastrar token'" @buttonClicked="() => {}" />
@@ -130,6 +130,7 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
@reference "tailwindcss";
.page { .page {
@apply flex flex-col items-center justify-center w-full mt-16; @apply flex flex-col items-center justify-center w-full mt-16;
} }
@@ -150,12 +151,8 @@ p {
@apply font-medium text-base text-gray-900; @apply font-medium text-base text-gray-900;
} }
input[type="number"] { input[type='number']::-webkit-inner-spin-button,
-moz-appearance: textfield; input[type='number']::-webkit-outer-spin-button {
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
</style> </style>

View File

@@ -1,30 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch, computed } from 'vue';
import CustomButton from "@/components/CustomButton/CustomButton.vue"; import { useUser } from '@/composables/useUser';
import { debounce } from "@/utils/debounce"; import SpinnerComponent from '@/components/ui/SpinnerComponent.vue';
import { useEtherStore } from "@/store/ether"; import CustomButton from '@/components/ui/CustomButton.vue';
import { storeToRefs } from "pinia"; import { debounce } from '@/utils/debounce';
import { verifyNetworkLiquidity } from "@/utils/networkLiquidity"; import { verifyNetworkLiquidity } from '@/utils/networkLiquidity';
import { NetworkEnum } from "@/model/NetworkEnum"; import type { ValidDeposit } from '@/model/ValidDeposit';
import type { ValidDeposit } from "@/model/ValidDeposit"; import { decimalCount } from '@/utils/decimalCount';
import { decimalCount } from "@/utils/decimalCount"; import { getTokenImage, getNetworkImage } from '@/utils/imagesPath';
import SpinnerComponent from "./SpinnerComponent.vue"; import { onClickOutside } from '@vueuse/core';
import { getTokenImage } from "@/utils/imagesPath"; import { Networks } from '@/config/networks';
import { onClickOutside } from "@vueuse/core"; import { TokenEnum } from '@/model/NetworkEnum';
import { getContract } from '@/blockchain/provider';
import { TokenEnum } from "@/model/NetworkEnum"; import { reputationAbi } from '@/blockchain/abi';
import { type Address } from 'viem';
// Store reference // Store reference
const etherStore = useEtherStore(); const user = useUser();
const selectTokenToggle = ref<boolean>(false); const selectTokenToggle = ref<boolean>(false);
const { const {
walletAddress, walletAddress,
networkName, network,
selectedToken, selectedToken,
depositsValidList, depositsValidList,
loadingNetworkLiquidity, loadingNetworkLiquidity,
} = storeToRefs(etherStore); } = user;
// html references // html references
const tokenDropdownRef = ref<any>(null); const tokenDropdownRef = ref<any>(null);
@@ -34,14 +35,106 @@ const tokenValue = ref<number>(0);
const enableConfirmButton = ref<boolean>(false); const enableConfirmButton = ref<boolean>(false);
const hasLiquidity = ref<boolean>(true); const hasLiquidity = ref<boolean>(true);
const validDecimals = ref<boolean>(true); const validDecimals = ref<boolean>(true);
const identification = ref<string>('');
const selectedDeposits = ref<ValidDeposit[]>(); const selectedDeposits = ref<ValidDeposit[]>();
const reputationLimit = ref<number | null>(null);
const exceedsReputationLimit = ref<boolean>(false);
import ChevronDown from "@/assets/chevronDown.svg"; import ChevronDown from '@/assets/chevronDown.svg';
import { useOnboard } from "@web3-onboard/vue"; import { useOnboard } from '@web3-onboard/vue';
import { getPixKey } from "@/blockchain/events"; import { getParticipantID } from '@/blockchain/events';
// Emits // Emits
const emit = defineEmits(["tokenBuy"]); 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;
}
};
// Blockchain methods // Blockchain methods
const connectAccount = async (): Promise<void> => { const connectAccount = async (): Promise<void> => {
@@ -51,11 +144,11 @@ const connectAccount = async (): Promise<void> => {
const emitConfirmButton = async (): Promise<void> => { const emitConfirmButton = async (): Promise<void> => {
const deposit = selectedDeposits.value?.find( const deposit = selectedDeposits.value?.find(
(d) => d.network === networkName.value (d) => d.network === network.value,
); );
if (!deposit) return; if (!deposit) return;
deposit.pixKey = await getPixKey(deposit.seller, deposit.token); deposit.participantID = await getParticipantID(deposit.seller, deposit.token);
emit("tokenBuy", deposit, tokenValue.value); emit('tokenBuy', deposit, tokenValue.value);
}; };
// Debounce methods // Debounce methods
@@ -71,6 +164,7 @@ const handleInputEvent = (event: any): void => {
} }
validDecimals.value = true; validDecimals.value = true;
checkReputationLimit(tokenValue.value);
verifyLiquidity(); verifyLiquidity();
}; };
@@ -83,33 +177,39 @@ onClickOutside(tokenDropdownRef, () => {
}); });
const handleSelectedToken = (token: TokenEnum): void => { const handleSelectedToken = (token: TokenEnum): void => {
etherStore.setSelectedToken(token); user.setSelectedToken(token);
selectTokenToggle.value = false; selectTokenToggle.value = false;
}; };
// Verify if there is a valid deposit to buy // Verify if there is a valid deposit to buy
const verifyLiquidity = (): void => { const verifyLiquidity = (): void => {
enableConfirmButton.value = false; enableConfirmButton.value = false;
if (!walletAddress.value) return;
const selDeposits = verifyNetworkLiquidity( const selDeposits = verifyNetworkLiquidity(
tokenValue.value, tokenValue.value,
walletAddress.value, walletAddress.value,
depositsValidList.value depositsValidList.value,
); );
selectedDeposits.value = selDeposits; selectedDeposits.value = selDeposits;
hasLiquidity.value = !!selDeposits.find( hasLiquidity.value = !!selDeposits.find((d) => d.network === network.value);
(d) => d.network === networkName.value
);
enableOrDisableConfirmButton(); enableOrDisableConfirmButton();
}; };
const enableOrDisableConfirmButton = (): void => { const enableOrDisableConfirmButton = (): void => {
enableConfirmButton.value = if (!selectedDeposits.value) {
!!selectedDeposits.value && enableConfirmButton.value = false;
!!selectedDeposits.value.find((d) => d.network === networkName.value); return;
}
if (!selectedDeposits.value.find((d) => d.network === network.value)) {
enableConfirmButton.value = false;
return;
}
enableConfirmButton.value = true;
}; };
watch(networkName, (): void => { watch(network, (): void => {
verifyLiquidity(); verifyLiquidity();
enableOrDisableConfirmButton(); enableOrDisableConfirmButton();
}); });
@@ -117,6 +217,23 @@ watch(networkName, (): void => {
watch(walletAddress, (): void => { watch(walletAddress, (): void => {
verifyLiquidity(); 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();
if (walletAddress.value) {
await emitConfirmButton();
} else {
await connectAccount();
}
};
</script> </script>
<template> <template>
@@ -132,24 +249,24 @@ watch(walletAddress, (): void => {
tokens após realizar o Pix</span tokens após realizar o Pix</span
> >
</div> </div>
<div class="main-container"> <form class="main-container" @submit="handleSubmit">
<div class="backdrop-blur -z-10 w-full h-full"></div> <div class="backdrop-blur -z-10 w-full h-full"></div>
<div <div class="flex flex-col w-full bg-white sm:px-10 px-6 py-5 rounded-lg">
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"> <div class="flex justify-between sm:w-full items-center">
<input <input
type="number" type="number"
class="border-none outline-none text-lg text-gray-900" name="tokenAmount"
class="border-none outline-none text-lg text-gray-900 sm:flex-1 max-w-[60%]"
v-bind:class="{ v-bind:class="{
'font-semibold': tokenValue != undefined, 'font-semibold': tokenValue != undefined,
'text-xl': tokenValue != undefined, 'text-xl': tokenValue != undefined,
}" }"
@input="debounce(handleInputEvent, 500)($event)" @input="debounce(handleInputEvent, 500)($event)"
placeholder="0 " placeholder="0"
step=".01" step=".01"
required
/> />
<div class="relative overflow-visible"> <div class="relative overflow-visible ml-auto sm:ml-0">
<button <button
ref="tokenDropdownRef" 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" 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"
@@ -212,24 +329,12 @@ watch(walletAddress, (): void => {
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<img <img
alt="Rootstock image" v-for="network in availableNetworks"
src="@/assets/rootstock.svg?url" :key="network.id"
:alt="`${network.name} image`"
:src="getNetworkImage(network.name)"
width="24" width="24"
height="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>
</div> </div>
@@ -252,30 +357,63 @@ watch(walletAddress, (): void => {
</div> </div>
<div <div
class="flex justify-center" class="flex justify-center"
v-else-if="!hasLiquidity && !loadingNetworkLiquidity" v-else-if="
!hasLiquidity &&
!loadingNetworkLiquidity &&
tokenValue > 0 &&
!exceedsReputationLimit
"
> >
<span class="text-red-500 font-normal text-sm" <span class="text-red-500 font-normal text-sm"
>Atualmente não liquidez nas rede selecionada para sua >Atualmente não liquidez nas rede selecionada para sua
demanda</span demanda</span
> >
</div> </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>
<CustomButton </div>
v-if="!walletAddress"
:text="'Conectar carteira'" <div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
@buttonClicked="connectAccount()" <input
type="text"
v-model="identification"
maxlength="14"
:pattern="'^\\d{11}$|^\\d{14}$'"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
placeholder="Digite seu CPF ou CNPJ (somente números)"
required
/> />
</div>
<!-- Action buttons -->
<CustomButton <CustomButton
v-if="walletAddress" v-if="walletAddress"
:text="'Confirmar compra'" type="submit"
:is-disabled="!enableConfirmButton" text="Confirmar Oferta"
@buttonClicked="emitConfirmButton()" :isDisabled="!enableConfirmButton"
/> />
</div> <CustomButton
v-else
text="Conectar carteira"
@buttonClicked="connectAccount()"
/>
</form>
</div> </div>
</template> </template>
<style scoped> <style scoped>
@reference "tailwindcss";
.custom-divide { .custom-divide {
width: 100%; width: 100%;
border-bottom: 1px solid #d1d5db; border-bottom: 1px solid #d1d5db;
@@ -298,12 +436,18 @@ watch(walletAddress, (): void => {
@apply text-white text-center; @apply text-white text-center;
} }
input[type="number"] { input[type='number'] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
input[type="number"]::-webkit-inner-spin-button, input[type='number']::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
.custom-button {
@apply w-full py-3 px-6 rounded-lg font-semibold text-white bg-indigo-600
hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed
transition-colors duration-200;
}
</style> </style>

View File

@@ -0,0 +1,291 @@
<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,26 +0,0 @@
<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

@@ -1,27 +0,0 @@
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,27 +0,0 @@
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

@@ -0,0 +1,78 @@
<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

@@ -0,0 +1,311 @@
<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

@@ -0,0 +1,212 @@
<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,18 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { withdrawDeposit } from "@/blockchain/buyerMethods"; import type { ValidDeposit } from '@/model/ValidDeposit';
import { NetworkEnum } from "@/model/NetworkEnum"; import type { WalletTransaction } from '@/model/WalletTransaction';
import type { ValidDeposit } from "@/model/ValidDeposit"; import { useUser } from '@/composables/useUser';
import type { WalletTransaction } from "@/model/WalletTransaction"; import { ref, watch } from 'vue';
import { useEtherStore } from "@/store/ether"; import SpinnerComponent from '../ui/SpinnerComponent.vue';
import { storeToRefs } from "pinia"; import BalanceCard from './BalanceCard.vue';
import { ref, watch, onMounted } from "vue"; import TransactionCard from './TransactionCard.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 etherStore = useEtherStore(); const user = useUser();
// props // props
const props = defineProps<{ const props = defineProps<{
@@ -21,84 +16,14 @@ const props = defineProps<{
activeLockAmount: number; activeLockAmount: number;
}>(); }>();
const emit = defineEmits(["depositWithdrawn"]); const emit = defineEmits(['depositWithdrawn']);
const { loadingWalletTransactions } = user;
const { loadingWalletTransactions } = storeToRefs(etherStore);
const remaining = ref<number>(0.0);
const itemsToShow = ref<WalletTransaction[]>([]); 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 reference = ref<HTMLElement | null>(null); const callWithdraw = (amount: string) => {
const floating = ref<HTMLElement | null>(null); emit('depositWithdrawn', amount);
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 instanceof Array) {
// Here we are getting only the first element of the list because
// 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 etherStore.networkName == NetworkEnum.sepolia
? "Etherscan"
: "Polygonscan";
}; };
const showInitialItems = (): void => { const showInitialItems = (): void => {
@@ -106,46 +31,18 @@ const showInitialItems = (): void => {
}; };
const openEtherscanUrl = (transactionHash: string): void => { const openEtherscanUrl = (transactionHash: string): void => {
const networkUrl = const networkUrl = user.network.value.blockExplorers?.default.url;
etherStore.networkName == NetworkEnum.sepolia
? "sepolia.etherscan.io"
: "mumbai.polygonscan.com";
const url = `https://${networkUrl}/tx/${transactionHash}`; const url = `https://${networkUrl}/tx/${transactionHash}`;
window.open(url, "_blank"); window.open(url, '_blank');
}; };
const loadMore = (): void => { const loadMore = (): void => {
const itemsShowing = itemsToShow.value.length; const itemsShowing = itemsToShow.value.length;
itemsToShow.value?.push( 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 changes
watch(props, async (): Promise<void> => { watch(props, async (): Promise<void> => {
const itemsToShowQty = itemsToShow.value.length; const itemsToShowQty = itemsToShow.value.length;
@@ -162,183 +59,31 @@ showInitialItems();
</script> </script>
<template> <template>
<div class="main-container max-w-md" v-if="loadingWalletTransactions"> <div
class="main-container max-w-md flex justify-center items-center min-h-[200px] w-16 h-16"
v-if="loadingWalletTransactions"
>
Carregando ofertas...
<SpinnerComponent width="8" height="8"></SpinnerComponent> <SpinnerComponent width="8" height="8"></SpinnerComponent>
</div> </div>
<div class="main-container max-w-md" v-if="!loadingWalletTransactions"> <div class="main-container max-w-md" v-else>
<div <BalanceCard
class="w-full bg-white p-4 sm:p-6 rounded-lg"
v-if="props.validDeposits.length > 0" v-if="props.validDeposits.length > 0"
> :valid-deposits="props.validDeposits"
<div class="flex justify-between items-center"> :active-lock-amount="activeLockAmount"
<div> :selected-token="user.selectedToken.value"
<p class="text-sm leading-5 font-medium text-gray-600"> @withdraw="callWithdraw"
Saldo disponível
</p>
<p class="text-xl leading-7 font-semibold text-gray-900">
{{ getRemaining() }} {{ etherStore.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)} ${
etherStore.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">
<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>
<div <TransactionCard
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" v-for="item in itemsToShow"
:key="item.blockNumber" :key="item.blockNumber"
> :selected-token="user.selectedToken.value"
<div class="item-container"> :transaction="item"
<div class="flex flex-col self-start"> :network-name="user.network.value.name"
<span class="text-xs sm:text-sm leading-5 font-medium text-gray-600"> @open-explorer="openEtherscanUrl"
{{ 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 <div
class="flex flex-col justify-center items-center w-full mt-2 gap-2" class="flex flex-col justify-center items-center w-full mt-2 gap-2"
v-if=" v-if="
@@ -348,14 +93,14 @@ showInitialItems();
> >
<button <button
type="button" type="button"
class="text-white font-semibold" 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"
@click="loadMore()" @click="loadMore()"
> >
Carregar mais Carregar mais
</button> </button>
<span class="text-gray-300"> <span class="text-gray-300 text-sm">
({{ itemsToShow.length }} de {{ props.walletTransactions.length }} {{ itemsToShow.length }} de {{ props.walletTransactions.length }}
transações ) transações
</span> </span>
</div> </div>
@@ -366,62 +111,5 @@ showInitialItems();
</template> </template>
<style scoped> <style scoped>
.page { /* Minimal styles - most styles moved to child components */
@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"] {
-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> </style>

View File

@@ -0,0 +1,146 @@
<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

@@ -1,87 +0,0 @@
import { mount } from "@vue/test-utils";
import ListingComponent from "@/components/ListingComponent/ListingComponent.vue";
import { createPinia, setActivePinia } from "pinia";
import { expect } from "vitest";
import { MockValidDeposits } from "@/model/mock/ValidDepositMock";
import { MockWalletTransactions } from "@/model/mock/WalletTransactionMock";
import { useEtherStore } from "@/store/ether";
describe("ListingComponent.vue", () => {
beforeEach(() => {
setActivePinia(createPinia());
useEtherStore().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

@@ -1,27 +0,0 @@
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

@@ -1,196 +0,0 @@
<script setup lang="ts">
import { pix } from "@/utils/QrCodePix";
import { onMounted, onUnmounted, ref } from "vue";
import { debounce } from "@/utils/debounce";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import CustomModal from "@/components//CustomModal/CustomModal.vue";
import api from "@/services/index";
// props and store references
const props = defineProps({
pixTarget: String,
tokenValue: Number,
});
const windowSize = ref<number>(window.innerWidth);
const qrCode = ref<string>("");
const qrCodePayload = ref<string>("");
const isPixValid = ref<boolean>(false);
const isCodeInputEmpty = ref<boolean>(true);
const showWarnModal = ref<boolean>(true);
const e2eId = ref<string>("");
// Emits
const emit = defineEmits(["pixValidated"]);
const pixQrCode = pix({
pixKey: props.pixTarget ?? "",
value: props.tokenValue,
});
pixQrCode.base64QrCode().then((code: string) => {
qrCode.value = code;
});
qrCodePayload.value = pixQrCode.payload();
const handleInputEvent = async (event: any): Promise<void> => {
const { value } = event.target;
e2eId.value = value;
await validatePix();
};
const validatePix = async (): Promise<void> => {
if (e2eId.value == "") {
isPixValid.value = false;
isCodeInputEmpty.value = true;
return;
}
const sellerPixKey = props.pixTarget;
const transactionValue = props.tokenValue;
if (sellerPixKey && transactionValue) {
const body_req = {
e2e_id: e2eId.value,
pix_key: sellerPixKey,
pix_value: transactionValue,
};
isCodeInputEmpty.value = false;
try {
await api.post("validate_pix", body_req);
isPixValid.value = true;
} catch (error) {
isPixValid.value = false;
}
} else {
isCodeInputEmpty.value = false;
isPixValid.value = false;
}
};
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 gerar a assinatura 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">
{{ qrCodePayload }}
</span>
</div>
<img
alt="Copy PIX code"
src="@/assets/copyPix.svg?url"
width="16"
height="16"
class="pt-2 lg:mb-5 cursor-pointer"
/>
<span class="text-xs text-start hidden sm:inline-block">
<strong>ATENÇÃO!</strong> A transação será processada após inserir
o código de autenticação. Caso contrário não conseguiremos comprovar o
seu depósito e não será possível transferir os tokens para sua
carteira. Confira aqui como encontrar o código no comprovante.
</span>
</div>
<CustomButton
:is-disabled="isPixValid == false"
:text="'Enviar para a rede'"
@button-clicked="emit('pixValidated', e2eId)"
/>
</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

@@ -0,0 +1,336 @@
<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 the bank list
import bankList from '@/utils/files/isbpList.json';
import type { Participant } from '@/utils/bbPay';
// Define Bank interface
interface Bank {
ISPB: string;
longName: string;
}
// html references
const tokenDropdownRef = ref<any>(null);
const formRef = ref<HTMLFormElement | null>(null);
// Reactive state
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 selectTokenToggle = ref<boolean>(false);
const savingsVariation = ref<string>('');
const errors = ref<{ [key: string]: string }>({});
// Bank selection
const bankSearchQuery = ref<string>('');
const showBankList = ref<boolean>(false);
const selectedBank = ref<Bank | null>(null);
const filteredBanks = computed(() => {
if (!bankSearchQuery.value) return [];
return bankList
.filter((bank) =>
bank.longName.toLowerCase().includes(bankSearchQuery.value.toLowerCase()),
)
.slice(0, 5);
});
const handleBankSelect = (bank: Bank) => {
selectedBank.value = bank;
bankSearchQuery.value = bank.longName;
showBankList.value = false;
};
// Emits
const emit = defineEmits(['approveTokens']);
// Methods
const connectAccount = async (): Promise<void> => {
const { connectWallet } = useOnboard();
await connectWallet();
};
const handleSubmit = (e: Event): void => {
e.preventDefault();
const processedIdentification = postProcessKey(identification.value);
const data: Participant = {
offer: offer.value,
chainID: user.network.value.id,
identification: processedIdentification,
bankIspb: selectedBank.value?.ISPB,
accountType: accountType.value,
account: account.value,
branch: branch.value,
savingsVariation: savingsVariation.value || '',
};
emit('approveTokens', data);
};
// Token selection
const openTokenSelection = (): void => {
selectTokenToggle.value = true;
};
const handleSelectedToken = (token: TokenEnum): void => {
user.setSelectedToken(token);
selectTokenToggle.value = false;
};
</script>
<template>
<div class="page w-full">
<div class="text-container">
<span
class="text font-extrabold sm:text-5xl text-3xl sm:max-w-[29rem] max-w-[20rem]"
>
Venda cripto e receba em Pix
</span>
<span
class="text font-medium sm:text-base text-xs sm:max-w-[28rem] max-w-[30rem] sm:tracking-normal tracking-wide"
>
Digite sua oferta, informe a chave Pix, selecione a rede, aprove o envio
da transação e confirme sua oferta.
</span>
</div>
<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"
>
<input
type="number"
v-model="offer"
class="border-none outline-none text-gray-900 sm:w-fit w-3/4 flex-grow"
:class="{
'!font-medium': offer !== undefined && offer !== '',
'text-xl': offer !== undefined && offer !== '',
}"
min="0.01"
max="999999999.99"
pattern="\d+(\.\d{0,2})?"
placeholder="Digite sua oferta (mínimo R$0,01)"
step=".01"
required
/>
<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"
@click="openTokenSelection()"
>
<img
alt="Token image"
class="sm:w-fit w-4"
:src="getTokenImage(selectedToken)"
/>
<span
class="text-gray-900 sm:text-lg text-md font-medium"
id="token"
>
{{ selectedToken }}
</span>
<ChevronDown
class="text-gray-900 pr-4 sm:pr-0 transition-all duration-500 ease-in-out"
:class="{ 'scale-y-[-1]': selectTokenToggle }"
alt="Chevron Down"
/>
</button>
<transition name="dropdown">
<div
v-if="selectTokenToggle"
class="mt-2 text-gray-900 absolute right-0 z-50 w-full min-w-max"
>
<div
class="bg-white rounded-xl z-10 border border-gray-300 drop-shadow-md shadow-md overflow-clip"
>
<div
v-for="token in TokenEnum"
:key="token"
class="flex menu-button gap-2 px-4 cursor-pointer hover:bg-gray-300 transition-colors"
@click="handleSelectedToken(token)"
>
<img
:alt="token + ' logo'"
width="20"
height="20"
:src="getTokenImage(token)"
/>
<span
class="text-gray-900 py-4 text-end font-semibold text-sm"
>
{{ token }}
</span>
</div>
</div>
</div>
</transition>
</div>
</div>
<!-- CPF or CNPJ input -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<input
type="text"
v-model="identification"
maxlength="14"
:pattern="'^\\d{11}$|^\\d{14}$'"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
:class="{ 'text-xl font-medium': identification }"
placeholder="Digite seu CPF ou CNPJ (somente números)"
required
/>
</div>
<!-- Bank selection -->
<div class="flex flex-col w-full bg-white sm:px-10 px-6 py-4 rounded-lg">
<div class="relative">
<input
type="text"
v-model="bankSearchQuery"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
:class="{ 'text-xl font-medium': bankSearchQuery }"
placeholder="Buscar banco"
@focus="showBankList = true"
required
/>
<div
v-if="showBankList && filteredBanks.length > 0"
class="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto"
>
<div
v-for="bank in filteredBanks"
:key="bank.ISPB"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer transition-colors"
@click="handleBankSelect(bank)"
>
<div class="text-sm font-medium text-gray-900">
{{ bank.longName }}
</div>
<div class="text-xs text-gray-500">ISPB: {{ bank.ISPB }}</div>
</div>
</div>
</div>
<span v-if="errors.bank" class="text-red-500 text-sm mt-2">{{
errors.bank
}}</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 gap-4">
<div class="flex-1">
<input
type="text"
v-model="account"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
:class="{ 'text-xl font-medium': account }"
placeholder="Número da conta"
required
/>
</div>
<div class="flex-1">
<input
type="text"
v-model="branch"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
:class="{ 'text-xl font-medium': branch }"
placeholder="Agência"
required
/>
</div>
</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 gap-4">
<div class="flex-1">
<select
v-model="accountType"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-full"
required
>
<option value="" disabled selected>Tipo de conta</option>
<option value="1">Conta Corrente</option>
<option value="2">Conta Poupança</option>
<option value="3">Conta Salário</option>
<option value="4">Conta Pré-Paga</option>
</select>
</div>
</div>
</div>
<!-- Savings Account Variation -->
<Transition name="resize">
<input
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="{ 'text-xl font-medium': savingsVariation }"
placeholder="Variação da poupança"
required
/>
</Transition>
<!-- Action buttons -->
<CustomButton v-if="walletAddress" type="submit" text="Aprovar tokens" />
<CustomButton
v-else
text="Conectar carteira"
@buttonClicked="connectAccount()"
/>
</form>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.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'] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
}
input {
@apply sm:text-lg text-sm;
}
</style>

View File

@@ -1,164 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import CustomButton from "@/components/CustomButton/CustomButton.vue";
import { debounce } from "@/utils/debounce";
import { decimalCount } from "@/utils/decimalCount";
import { useEtherStore } from "@/store/ether";
import { getTokenImage } from "@/utils/imagesPath";
import { storeToRefs } from "pinia";
import { useOnboard } from "@web3-onboard/vue";
// Store
const etherStore = useEtherStore();
const { walletAddress } = storeToRefs(etherStore);
// 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(etherStore.selectedToken)"
/>
<span class="text-gray-900 text-lg w-fit" id="token">{{
etherStore.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"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import CustomButton from "@/components/CustomButton/CustomButton.vue"; import CustomButton from '@/components/ui/CustomButton.vue';
// Emits // Emits
const emit = defineEmits(["sendNetwork"]); const emit = defineEmits(['sendNetwork']);
// props and store references // props and store references
const props = defineProps({ const props = defineProps({
pixKey: String, sellerId: String,
offer: Number, offer: Number,
selectedToken: String, selectedToken: String,
}); });
@@ -26,10 +26,8 @@ const props = defineProps({
os tokens de volta.</span os tokens de volta.</span
> >
</div> </div>
<div class="main-container sm:w-1/3"> <div class="main-container">
<div <div class="flex flex-col w-full bg-white px-10 py-5 rounded-lg">
class="flex flex-col w-full bg-white px-10 py-5 rounded-lg border-y-10"
>
<div> <div>
<p>Tokens ofertados</p> <p>Tokens ofertados</p>
<p class="text-2xl text-gray-900"> <p class="text-2xl text-gray-900">
@@ -39,7 +37,7 @@ const props = defineProps({
<div class="my-3"> <div class="my-3">
<p>Chave Pix</p> <p>Chave Pix</p>
<p class="text-xl text-gray-900 break-words"> <p class="text-xl text-gray-900 break-words">
{{ props.pixKey }} {{ props.sellerId }}
</p> </p>
</div> </div>
<div class="mb-5"> <div class="mb-5">
@@ -59,6 +57,7 @@ const props = defineProps({
</template> </template>
<style scoped> <style scoped>
@reference "tailwindcss";
.page { .page {
@apply flex flex-col items-center justify-center w-full mt-16; @apply flex flex-col items-center justify-center w-full mt-16;
} }
@@ -79,12 +78,8 @@ p {
@apply font-medium text-base; @apply font-medium text-base;
} }
input[type="number"] { input[type='number']::-webkit-inner-spin-button,
-moz-appearance: textfield; input[type='number']::-webkit-outer-spin-button {
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
</style> </style>

View File

@@ -1,263 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import CustomButton from "../CustomButton/CustomButton.vue";
import { debounce } from "@/utils/debounce";
import { decimalCount } from "@/utils/decimalCount";
import { pixFormatValidation, postProcessKey } from "@/utils/pixKeyFormat";
import { useEtherStore } from "@/store/ether";
import { storeToRefs } from "pinia";
import { connectProvider } from "@/blockchain/provider";
import { TokenEnum } from "@/model/NetworkEnum";
import { getTokenImage } from "@/utils/imagesPath";
import { onClickOutside } from "@vueuse/core";
import { useOnboard } from "@web3-onboard/vue";
import ChevronDown from "@/assets/chevron.svg";
// html references
const tokenDropdownRef = ref<any>(null);
// Reactive state
const etherStore = useEtherStore();
const { walletAddress, selectedToken } = storeToRefs(etherStore);
const offer = ref<string>("");
const pixKey = ref<string>("");
const selectTokenToggle = ref<boolean>(false);
const enableSelectButton = ref<boolean>(false);
const hasLiquidity = ref<boolean>(true);
const validDecimals = ref<boolean>(true);
const validPixFormat = ref<boolean>(true);
// Emits
const emit = defineEmits(["approveTokens"]);
// Blockchain methods
const connectAccount = async (): Promise<void> => {
const { connectWallet } = useOnboard();
await connectWallet();
};
// Debounce methods
const handleInputEvent = (event: any): void => {
const { value } = event.target;
offer.value = value;
if (decimalCount(offer.value) > 2) {
validDecimals.value = false;
enableSelectButton.value = false;
return;
}
validDecimals.value = true;
};
const handlePixKeyInputEvent = (event: any): void => {
const { value } = event.target;
pixKey.value = value;
if (pixFormatValidation(pixKey.value)) {
validPixFormat.value = true;
enableSelectButton.value = true;
return;
}
enableSelectButton.value = false;
validPixFormat.value = false;
};
const openTokenSelection = (): void => {
selectTokenToggle.value = true;
};
onClickOutside(tokenDropdownRef, () => {
selectTokenToggle.value = false;
});
const handleSelectedToken = (token: TokenEnum): void => {
etherStore.setSelectedToken(token);
selectTokenToggle.value = false;
};
const handleSellClick = async (
offer: string,
pixKey: string
): Promise<void> => {
const postProcessedPixKey = postProcessKey(pixKey);
emit("approveTokens", { offer, postProcessedPixKey });
};
</script>
<template>
<div class="page w-full">
<div class="text-container">
<span
class="text font-extrabold sm:text-5xl text-3xl sm:max-w-[29rem] max-w-[20rem]"
>Venda cripto e receba em Pix</span
>
<span
class="text font-medium sm:text-base text-xs sm:max-w-[28rem] max-w-[30rem] sm:tracking-normal tracking-wide"
>Digite sua oferta, informe a chave Pix, selecione a rede, aprove o
envio da transação e confirme sua oferta.</span
>
</div>
<div class="main-container">
<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 border-y-10"
>
<div class="flex justify-between items-center">
<input
type="number"
v-model="offer"
class="border-none outline-none text-gray-900 sm:w-fit w-3/4"
:class="{
'!font-medium': offer !== undefined && offer !== '',
'text-xl': offer !== undefined && offer !== '',
}"
@input="debounce(handleInputEvent, 500)($event)"
placeholder="Digite sua oferta"
step=".01"
/>
<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"
@click="openTokenSelection()"
>
<img
alt="Token image"
class="sm:w-fit w-4"
:src="getTokenImage(selectedToken)"
/>
<span
class="text-gray-900 sm:text-lg text-md font-medium"
id="token"
>
{{ selectedToken }}
</span>
<ChevronDown
class="text-gray-900 pr-4 sm:pr-0 transition-all duration-500 ease-in-out"
:class="{ 'scale-y-[-1]': selectTokenToggle }"
alt="Chevron Down"
/>
</button>
<transition name="dropdown">
<div
v-if="selectTokenToggle"
class="mt-2 text-gray-900 absolute right-0 z-50 w-full min-w-max"
>
<div
class="bg-white rounded-xl z-10 border border-gray-300 drop-shadow-md shadow-md overflow-clip"
>
<div
v-for="token in TokenEnum"
:key="token"
class="flex menu-button gap-2 px-4 cursor-pointer hover:bg-gray-300 transition-colors"
@click="handleSelectedToken(token)"
>
<img
:alt="token + ' logo'"
width="20"
height="20"
:src="getTokenImage(token)"
/>
<span
class="text-gray-900 py-4 text-end font-semibold text-sm"
>
{{ token }}
</span>
</div>
</div>
</div>
</transition>
</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>
<div
class="flex flex-col w-full bg-white sm:px-10 px-6 py-8 rounded-lg border-y-10"
>
<div class="flex justify-between w-full items-center">
<input
@input="debounce(handlePixKeyInputEvent, 500)($event)"
type="text"
v-model="pixKey"
class="border-none outline-none sm:text-lg text-sm text-gray-900 w-fit"
:class="{
'!font-medium': pixKey !== undefined && pixKey !== '',
'text-xl': pixKey !== undefined && pixKey !== '',
}"
placeholder="Digite a chave Pix"
/>
</div>
<div class="flex pt-2 justify-center" v-if="!validPixFormat">
<span class="text-red-500 font-normal text-sm"
>Por favor utilize telefone, CPF ou CNPJ</span
>
</div>
</div>
<CustomButton
v-if="walletAddress"
:text="'Aprovar tokens'"
:isDisabled="!validDecimals || !validPixFormat"
@buttonClicked="handleSellClick(offer, pixKey)"
/>
<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"] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
input {
@apply sm:text-lg text-sm;
}
</style>

View File

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

View File

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

View File

@@ -1,36 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from 'vue';
const props = defineProps<{ const props = defineProps<{
type: string; type: string;
}>(); }>();
const alertText = ref<string>(""); const alertText = ref<string>('');
const alertPaddingLeft = ref<string>("18rem"); const alertPaddingLeft = ref<string>('18rem');
if (props.type === "sell") { if (props.type === 'sell') {
alertPaddingLeft.value = "30%"; alertPaddingLeft.value = '30%';
} else if (props.type === "buy") { } else if (props.type === 'buy') {
alertPaddingLeft.value = "30%"; alertPaddingLeft.value = '30%';
} else if (props.type === "withdraw") { } else if (props.type === 'withdraw') {
alertPaddingLeft.value = "40%"; alertPaddingLeft.value = '40%';
} else if (props.type === "redirect") { } else if (props.type === 'redirect') {
alertPaddingLeft.value = "35%"; alertPaddingLeft.value = '35%';
} }
switch (props.type) { switch (props.type) {
case "buy": case 'buy':
alertText.value = 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; break;
case "sell": case 'sell':
alertText.value = 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; break;
case "redirect": case 'redirect':
alertText.value = "Existe uma compra em aberto. Continuar?"; alertText.value = 'Existe uma compra em aberto. Continuar?';
break; break;
case "withdraw": case 'withdraw':
alertText.value = "Tudo certo! Saque realizado com sucesso!"; alertText.value = 'Tudo certo! Saque realizado com sucesso!';
break; break;
} }
</script> </script>

View File

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

View File

@@ -0,0 +1,55 @@
<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

@@ -0,0 +1,147 @@
<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

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

View File

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

View File

@@ -0,0 +1,50 @@
<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

@@ -0,0 +1,85 @@
<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';
const { connectedWallet } = useOnboard();
const user = useUser();
const { network } = user;
const isWrongNetwork = ref(false);
const targetNetworkName = computed(() => network.value.name);
const checkNetwork = () => {
if (connectedWallet.value) {
const chainId = connectedWallet.value.chains[0].id;
isWrongNetwork.value = Number(chainId) !== network.value.id;
} else {
isWrongNetwork.value = false; // No wallet connected yet
}
};
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',
params: [
{
chainId: `0x${chainId}`,
},
],
});
}
} catch (error) {
console.error('Failed to switch network:', error);
}
};
onMounted(checkNetwork);
watch(connectedWallet, checkNetwork);
watch(network, checkNetwork, { immediate: true });
</script>
<template>
<transition name="slide-up" appear>
<div
v-if="isWrongNetwork"
class="fixed bottom-0 left-0 right-0 bg-red-500 text-white p-4 flex justify-between items-center z-50"
>
<div>
<span class="font-bold">Wrong network!</span>
<span> Please switch to {{ targetNetworkName }}.</span>
</div>
<button
@click="switchNetwork"
class="bg-white text-red-500 px-4 py-2 rounded font-bold hover:bg-gray-100 transition-colors"
>
Switch Network
</button>
</div>
</transition>
</template>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
.slide-up-enter-to,
.slide-up-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>

View File

@@ -0,0 +1,14 @@
<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

@@ -0,0 +1,468 @@
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,
};
}

125
src/composables/useUser.ts Normal file
View File

@@ -0,0 +1,125 @@
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';
const walletAddress = ref<Address | null>(null);
const balance = ref('');
const network = ref(DEFAULT_NETWORK);
const selectedToken = ref<TokenEnum>(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('');
export function useUser() {
// Actions become regular functions
const setWalletAddress = (address: Address | null) => {
walletAddress.value = address;
};
const setBalance = (newBalance: string) => {
balance.value = newBalance;
};
const setSelectedToken = (token: TokenEnum) => {
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 setLoadingLock = (isLoading: boolean) => {
loadingLock.value = isLoading;
};
const setSellerView = (view: boolean) => {
sellerView.value = view;
};
const setDepositsValidList = (deposits: ValidDeposit[]) => {
depositsValidList.value = deposits;
};
const setLoadingWalletTransactions = (isLoading: boolean) => {
loadingWalletTransactions.value = isLoading;
};
const setLoadingNetworkLiquidity = (isLoading: boolean) => {
loadingNetworkLiquidity.value = isLoading;
};
const setSeller = (newSeller: Participant) => {
seller.value = newSeller;
};
const setSellerId = (id: string) => {
sellerId.value = id;
};
// Getters become computed or regular functions
const getValidDepositByWalletAddress = (address: string) => {
return depositsValidList.value
.filter((deposit) => deposit.seller == address)
.sort((a, b) => b.blockNumber - a.blockNumber);
};
return {
// State
walletAddress,
balance,
network,
selectedToken,
loadingLock,
sellerView,
depositsValidList,
loadingWalletTransactions,
loadingNetworkLiquidity,
seller,
sellerId,
// Actions
setWalletAddress,
setBalance,
setSelectedToken,
setNetwork,
setNetworkById,
setLoadingLock,
setSellerView,
setDepositsValidList,
setLoadingWalletTransactions,
setLoadingNetworkLiquidity,
setSeller,
setSellerId,
// Getters
getValidDepositByWalletAddress,
};
}

60
src/config/networks.ts Normal file
View File

@@ -0,0 +1,60 @@
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,14 +1,12 @@
import { createApp } from "vue"; import { createApp } from 'vue';
import App from "./App.vue"; import App from './App.vue';
import router from "./router"; import router from './router';
import { createPinia } from "pinia";
import "./assets/main.css"; import './assets/main.css';
import "./assets/transitions.css"; import './assets/transitions.css';
const app = createApp(App); const app = createApp(App);
app.use(router); app.use(router);
app.use(createPinia());
app.mount("#app"); app.mount('#app');

6
src/model/AppVersion.ts Normal file
View File

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

View File

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

9
src/model/LockStatus.ts Normal file
View File

@@ -0,0 +1,9 @@
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,9 +1,10 @@
export enum NetworkEnum { import type { Chain, ChainContract } from 'viem';
sepolia = 11155111,
rootstock = 31,
}
export enum TokenEnum { export enum TokenEnum {
BRZ = "BRZ", BRZ = 'BRZ',
// BRX = 'BRX' // BRX = 'BRX'
} }
export type NetworkConfig = Chain & {
tokens: Record<TokenEnum, ChainContract>;
subgraphUrls: string[];
};

View File

@@ -1,22 +0,0 @@
import { NetworkEnum } from "@/model/NetworkEnum";
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/"],
},
};

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
import type { LockStatus } from '@/model/LockStatus';
import type { Address } from 'viem';
export type WalletTransaction = { export type WalletTransaction = {
token: string; token?: Address;
blockNumber: number; blockNumber: number;
blockTimestamp?: number;
amount: number; amount: number;
seller: string; seller: string;
buyer: string; buyer: string;
event: string; event: string;
lockStatus: number; lockStatus?: LockStatus;
transactionHash: string; transactionHash: string;
transactionID?: string; transactionID?: string;
}; };

View File

@@ -1,121 +0,0 @@
import type { Event } from "ethers";
import { vi } from "vitest";
export const MockEvents: Event[] = [
{
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

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import axios from "axios";
const defaultConfig = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
};
const api = axios.create({
...defaultConfig,
baseURL: import.meta.env.VITE_API_URL,
});
export default api;

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

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

View File

@@ -1,56 +0,0 @@
import { NetworkEnum, TokenEnum } from "../model/NetworkEnum";
import type { ValidDeposit } from "@/model/ValidDeposit";
import { defineStore } from "pinia";
export const useEtherStore = defineStore("ether", {
state: () => ({
walletAddress: "",
balance: "",
networkName: NetworkEnum.sepolia,
selectedToken: TokenEnum.BRZ,
loadingLock: false,
sellerView: false,
depositsValidList: [] as ValidDeposit[],
loadingWalletTransactions: false,
loadingNetworkLiquidity: false,
}),
actions: {
setWalletAddress(walletAddress: string) {
this.walletAddress = walletAddress;
},
setBalance(balance: string) {
this.balance = balance;
},
setSelectedToken(token: TokenEnum) {
this.selectedToken = token;
},
setNetworkId(networkName: NetworkEnum) {
this.networkName = Number(networkName);
},
setLoadingLock(isLoadingLock: boolean) {
this.loadingLock = isLoadingLock;
},
setSellerView(sellerView: boolean) {
this.sellerView = sellerView;
},
setDepositsValidList(depositsValidList: ValidDeposit[]) {
this.depositsValidList = depositsValidList;
},
setLoadingWalletTransactions(isLoadingWalletTransactions: boolean) {
this.loadingWalletTransactions = isLoadingWalletTransactions;
},
setLoadingNetworkLiquidity(isLoadingNetworkLiquidity: boolean) {
this.loadingNetworkLiquidity = isLoadingNetworkLiquidity;
},
},
getters: {
getValidDepositByWalletAddress: (state) => {
return (walletAddress: string) =>
state.depositsValidList
.filter((deposit) => deposit.seller == walletAddress)
.sort((a, b) => {
return b.blockNumber - a.blockNumber;
});
},
},
});

View File

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

View File

@@ -1,76 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,25 +0,0 @@
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);
});
});

77
src/utils/bbPay.ts Normal file
View File

@@ -0,0 +1,77 @@
export interface Participant {
offer: string;
chainID: number;
identification: string;
bankIspb?: string;
accountType: string;
account: string;
branch: string;
savingsVariation?: string;
}
interface ParticipantWithID extends Participant {
id: string;
}
export interface Offer {
amount: number;
sellerId: string;
}
// Specs for BB Pay Sandbox
// https://apoio.developers.bb.com.br/sandbox/spec/665797498bb48200130fc32c
export const createParticipant = async (participant: Participant) => {
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chainID: participant.chainID,
tipoDocumento: 1,
numeroDocumento: participant.identification,
numeroConta: participant.account,
numeroAgencia: participant.branch,
tipoConta: participant.accountType,
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;
};
export const createSolicitation = async (offer: Offer) => {
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: offer.amount,
pixTarget: offer.sellerId.split('-').pop(),
}),
});
return response.json();
};
export const getSolicitation = async (
id: bigint,
): Promise<{ pixTimestamp: `0x${string}`; signature: `0x${string}` }> => {
const response = await fetch(
`${import.meta.env.VITE_APP_API_URL}/release/${id}`,
);
const obj = await response.json();
return {
pixTimestamp: obj.pixTimestamp,
signature: obj.signature,
};
};

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,16 +1,4 @@
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 => { export const postProcessKey = (pixKey: string): string => {
pixKey = pixKey.replace(/[-.()/]/g, ""); pixKey = pixKey.replace(/[-.()/]/g, '');
return pixKey; return pixKey;
}; };

File diff suppressed because one or more lines are too long

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