feat: 071125/send_transaction_form_and_confirm

This commit is contained in:
NguyenAnhQuan 2025-11-07 18:27:37 +07:00
parent e48669d972
commit b9940b66a9
52 changed files with 3530 additions and 1636 deletions

48
components.d.ts vendored Normal file
View File

@ -0,0 +1,48 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
ButtonCommon: typeof import('./src/components/common/ButtonCommon.vue')['default']
CardBase: typeof import('./src/components/common/CardBase.vue')['default']
CardBaseScrollable: typeof import('./src/components/common/CardBaseScrollable.vue')['default']
FormCommon: typeof import('./src/components/common/FormCommon.vue')['default']
IconCommon: typeof import('./src/components/icon/IconCommon.vue')['default']
LayoutVue: typeof import('./src/components/common/LayoutVue.vue')['default']
ModalCommon: typeof import('./src/components/common/ModalCommon.vue')['default']
PasswordForm: typeof import('./src/components/common/PasswordForm.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SpinnerCommon: typeof import('./src/components/common/SpinnerCommon.vue')['default']
TabPaneCommon: typeof import('./src/components/common/TabPaneCommon.vue')['default']
TabsCommon: typeof import('./src/components/common/TabsCommon.vue')['default']
}
}
// For TSX support
declare global {
const AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
const ButtonCommon: typeof import('./src/components/common/ButtonCommon.vue')['default']
const CardBase: typeof import('./src/components/common/CardBase.vue')['default']
const CardBaseScrollable: typeof import('./src/components/common/CardBaseScrollable.vue')['default']
const FormCommon: typeof import('./src/components/common/FormCommon.vue')['default']
const IconCommon: typeof import('./src/components/icon/IconCommon.vue')['default']
const LayoutVue: typeof import('./src/components/common/LayoutVue.vue')['default']
const ModalCommon: typeof import('./src/components/common/ModalCommon.vue')['default']
const PasswordForm: typeof import('./src/components/common/PasswordForm.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SpinnerCommon: typeof import('./src/components/common/SpinnerCommon.vue')['default']
const TabPaneCommon: typeof import('./src/components/common/TabPaneCommon.vue')['default']
const TabsCommon: typeof import('./src/components/common/TabsCommon.vue')['default']
}

View File

@ -3,6 +3,8 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { encrypt, fromEncryptedJson } from './utils/keystore' import { encrypt, fromEncryptedJson } from './utils/keystore'
const neptuneNative = require('@neptune/native')
// Create keystore into default wallets directory // Create keystore into default wallets directory
ipcMain.handle('wallet:createKeystore', async (_event, seed, password) => { ipcMain.handle('wallet:createKeystore', async (_event, seed, password) => {
try { try {
@ -67,13 +69,50 @@ ipcMain.handle('wallet:checkKeystore', async () => {
const walletDir = path.join(process.cwd(), 'wallets') const walletDir = path.join(process.cwd(), 'wallets')
if (!fs.existsSync(walletDir)) return { exists: false, filePath: null } if (!fs.existsSync(walletDir)) return { exists: false, filePath: null }
const file = fs.readdirSync(walletDir).find((f) => f.endsWith('.json')) const newestFile = fs
if (!file) return { exists: false, filePath: null } .readdirSync(walletDir)
.filter((f) => f.endsWith('.json'))
.sort(
(a, b) =>
fs.statSync(path.join(walletDir, b)).mtime.getTime() -
fs.statSync(path.join(walletDir, a)).mtime.getTime()
)[0]
const filePath = path.join(walletDir, file) if (!newestFile) return { exists: false, filePath: null }
return { exists: true, filePath}
return { exists: true, filePath: path.join(walletDir, newestFile) }
} catch (error) { } catch (error) {
console.error('Error checking keystore:', error) console.error('Error checking keystore ipc:', error)
return { exists: false, filePath: null, error: String(error) } return { exists: false, filePath: null, error: String(error) }
} }
}) })
ipcMain.handle('wallet:generateKeysFromSeed', async (_event, seedPhrase: string[]) => {
try {
const wallet = new neptuneNative.WalletManager()
return wallet.generateKeysFromSeed(seedPhrase)
} catch (error) {
console.error('Error generating keys from seed ipc:', error)
throw error
}
})
ipcMain.handle('wallet:buildTransactionWithPrimitiveProof', async (_event, args) => {
const { spendingKeyHex, inputAdditionRecords, outputAddresses, outputAmounts, fee } = args
try {
const builder = new neptuneNative.SimpleTransactionBuilder()
const result = await builder.buildTransactionWithPrimitiveProof(
import.meta.env.VITE_APP_API,
spendingKeyHex,
inputAdditionRecords,
outputAddresses,
outputAmounts,
fee
)
return JSON.parse(result)
} catch (error) {
console.error('Error building transaction with primitive proof ipc:', error)
throw error
}
})

View File

@ -11,6 +11,9 @@ const createWindow = () => {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 800, width: 800,
height: 800, height: 800,
minWidth: 500,
minHeight: 650,
icon: path.resolve(process.cwd(), 'public/favicon.png'),
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,

View File

@ -10,4 +10,8 @@ contextBridge.exposeInMainWorld('walletApi', {
decryptKeystore: (filePath: string, password: string) => decryptKeystore: (filePath: string, password: string) =>
ipcRenderer.invoke('wallet:decryptKeystore', filePath, password), ipcRenderer.invoke('wallet:decryptKeystore', filePath, password),
checkKeystore: () => ipcRenderer.invoke('wallet:checkKeystore'), checkKeystore: () => ipcRenderer.invoke('wallet:checkKeystore'),
generateKeysFromSeed: (seedPhrase: string[]) =>
ipcRenderer.invoke('wallet:generateKeysFromSeed', seedPhrase),
buildTransactionWithPrimitiveProof: (args: any) =>
ipcRenderer.invoke('wallet:buildTransactionWithPrimitiveProof', args),
}) })

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neptune Web Wallet</title> <title>Neptune Web Wallet</title>
</head> </head>

1984
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@neptune/native": "file:./packages/neptune-native",
"@neptune/wasm": "file:./packages/neptune-wasm", "@neptune/wasm": "file:./packages/neptune-wasm",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"axios": "^1.6.8", "axios": "^1.6.8",
@ -57,6 +58,8 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.75.0", "sass": "^1.75.0",
"typescript": "~5.4.0", "typescript": "~5.4.0",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^5.2.8", "vite": "^5.2.8",
"vite-plugin-vue-devtools": "^7.0.25", "vite-plugin-vue-devtools": "^7.0.25",
"vue-tsc": "^2.0.11" "vue-tsc": "^2.0.11"

Binary file not shown.

82
packages/neptune-native/index.d.ts vendored Normal file
View File

@ -0,0 +1,82 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
/** Module initialization */
export declare function initNativeModule(): string
/** Quick VM test (can call immediately) */
export declare function quickVmTest(): string
export declare function getVersion(): string
/** Wallet manager for key generation and transaction signing */
export declare class WalletManager {
constructor()
/**
* Generate spending key from BIP39 seed phrase
*
* # Arguments
* * `seed_phrase` - Array of words (12, 15, 18, 21, or 24 words)
*
* # Returns
* JSON with spending_key_hex, view_key_hex, receiver_identifier
*/
generateKeysFromSeed(seedPhrase: Array<string>): string
/**
* Generate lock_script_and_witness (transaction signature)
*
* # Arguments
* * `spending_key_hex` - Hex-encoded spending key from generate_keys_from_seed
*
* # Returns
* JSON with lock_script_and_witness in hex format
*/
createLockScriptAndWitness(spendingKeyHex: string): string
/** Derive ViewKey hex (0x-prefixed bincode) from a Generation spending key hex */
spendingKeyToViewKeyHex(spendingKeyHex: string): string
/** Call wallet_getAdditionRecordsFromViewKey and return JSON as string */
getAdditionRecordsFromViewKeyCall(rpcUrl: string, viewKeyHex: string, startBlock: number, endBlock: number | undefined | null, maxSearchDepth: number): Promise<string>
/**
* Build RPC request to get UTXOs from view key
*
* # Arguments
* * `view_key_hex` - Hex-encoded view key from generate_keys_from_seed
* * `start_block` - Starting block height (0 for genesis)
* * `end_block` - Ending block height (current tip)
* * `max_search_depth` - Maximum blocks to search (default: 1000)
*
* # Returns
* JSON-RPC request ready to send
*
* # Note
* Method name format: namespace_method (e.g. wallet_getUtxosFromViewKey)
*/
buildGetUtxosRequest(viewKeyHex: string, startBlock: number, endBlock: number, maxSearchDepth?: number | undefined | null): string
/** Build RPC request to test chain height (for connectivity testing) */
buildTestRpcRequest(): string
/** Build JSON-RPC request to fetch current chain height */
buildChainHeightRequest(): string
/** Build JSON-RPC request to fetch current chain header (tip) */
buildChainHeaderRequest(): string
/** Get network information */
getNetworkInfo(): string
getChainHeightCall(rpcUrl: string): Promise<string>
/** Call node_getState to get server state information */
getStateCall(rpcUrl: string): Promise<string>
/** Call mempool_submitTransaction to broadcast a pre-built transaction */
submitTransactionCall(rpcUrl: string, transactionHex: string): Promise<string>
getUtxosFromViewKeyCall(rpcUrl: string, viewKeyHex: string, startBlock: number, endBlock: number, maxSearchDepth?: number | undefined | null): Promise<string>
getArchivalMutatorSet(rpcUrl: string): Promise<string>
/**
* Build JSON-RPC request to find the canonical block that created a UTXO (by addition_record)
* Method: archival_getUtxoCreationBlock
*/
buildGetUtxoCreationBlockRequest(additionRecordHex: string, maxSearchDepth?: number | undefined | null): string
/** Perform JSON-RPC call to find the canonical block that created a UTXO (by addition_record) */
getUtxoCreationBlockCall(rpcUrl: string, additionRecordHex: string, maxSearchDepth?: number | undefined | null): Promise<string>
/** Call wallet_sendWithSpendingKey to build and broadcast transaction */
generateUtxoWithProofCall(rpcUrl: string, utxoHex: string, additionRecordHex: string, senderRandomnessHex: string, receiverPreimageHex: string, maxSearchDepth: string): Promise<string>
}
export declare class SimpleTransactionBuilder {
constructor()
buildTransactionWithPrimitiveProof(rpcUrl: string, spendingKeyHex: string, inputAdditionRecords: Array<string>, outputAddresses: Array<string>, outputAmounts: Array<string>, fee: string): Promise<string>
}

View File

@ -0,0 +1,319 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'neptune-native.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.android-arm64.node')
} else {
nativeBinding = require('neptune-native-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'neptune-native.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.android-arm-eabi.node')
} else {
nativeBinding = require('neptune-native-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'neptune-native.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.win32-x64-msvc.node')
} else {
nativeBinding = require('neptune-native-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'neptune-native.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.win32-ia32-msvc.node')
} else {
nativeBinding = require('neptune-native-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'neptune-native.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.win32-arm64-msvc.node')
} else {
nativeBinding = require('neptune-native-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'neptune-native.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.darwin-universal.node')
} else {
nativeBinding = require('neptune-native-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'neptune-native.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.darwin-x64.node')
} else {
nativeBinding = require('neptune-native-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'neptune-native.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.darwin-arm64.node')
} else {
nativeBinding = require('neptune-native-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'neptune-native.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.freebsd-x64.node')
} else {
nativeBinding = require('neptune-native-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-x64-musl.node')
} else {
nativeBinding = require('neptune-native-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-x64-gnu.node')
} else {
nativeBinding = require('neptune-native-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-arm64-musl.node')
} else {
nativeBinding = require('neptune-native-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-arm64-gnu.node')
} else {
nativeBinding = require('neptune-native-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-arm-musleabihf.node')
} else {
nativeBinding = require('neptune-native-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('neptune-native-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-riscv64-musl.node')
} else {
nativeBinding = require('neptune-native-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-riscv64-gnu.node')
} else {
nativeBinding = require('neptune-native-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'neptune-native.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./neptune-native.linux-s390x-gnu.node')
} else {
nativeBinding = require('neptune-native-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { initNativeModule, quickVmTest, getVersion, WalletManager, SimpleTransactionBuilder } = nativeBinding
module.exports.initNativeModule = initNativeModule
module.exports.quickVmTest = quickVmTest
module.exports.getVersion = getVersion
module.exports.WalletManager = WalletManager
module.exports.SimpleTransactionBuilder = SimpleTransactionBuilder

View File

@ -0,0 +1,45 @@
{
"name": "neptune-native",
"version": "0.1.0",
"description": "Native Node.js addon for Neptune transaction building",
"main": "index.js",
"files": [
"index.js",
"index.d.ts",
"*.node"
],
"scripts": {
"build": "npx napi build --platform --release",
"build:debug": "npx napi build --platform",
"prepublishOnly": "napi prepublish -t npm",
"test": "cargo test",
"universal": "napi universal"
},
"napi": {
"name": "neptune-native",
"triples": {
"defaults": true,
"additional": [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-apple-darwin",
"aarch64-unknown-linux-musl"
]
}
},
"devDependencies": {
"@napi-rs/cli": "^2.18.4"
},
"keywords": [
"neptune",
"blockchain",
"native",
"napi",
"vm",
"proof"
],
"license": "Apache-2.0",
"engines": {
"node": ">= 16"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -3,7 +3,7 @@ import { LayoutVue } from '@/components'
const config = { const config = {
token: { token: {
colorPrimary: '#007FCF', colorPrimary: '#42A5F5',
borderRadius: 4, borderRadius: 4,
}, },
} }

View File

@ -12,34 +12,35 @@ export const getUtxosFromViewKey = async (
endBlock, endBlock,
maxSearchDepth, maxSearchDepth,
} }
return await callJsonRpc('wallet_getUtxosFromViewKey', params) return await callJsonRpc('wallet_getUtxoInfo', params)
} }
export const getBalance = async (): Promise<any> => { export const getBalance = async (
return await callJsonRpc('wallet_balance', []) viewKey: string,
startBlock: number = 0,
endBlock: number | null = null,
maxSearchDepth: number = 1000
): Promise<any> => {
const params = {
viewKey,
startBlock,
endBlock,
maxSearchDepth,
}
return await callJsonRpc('wallet_getBalanceFromViewKey', params)
} }
export const getBlockHeight = async (): Promise<any> => { export const getBlockHeight = async (): Promise<any> => {
return await callJsonRpc('chain_height', []) return await callJsonRpc('chain_height')
} }
export const getNetworkInfo = async (): Promise<any> => { export const getNetworkInfo = async (): Promise<any> => {
return await callJsonRpc('node_network', []) return await callJsonRpc('node_network')
} }
export const getWalletAddress = async (): Promise<any> => { export const broadcastSignedTransaction = async (transactionHex: any): Promise<any> => {
return await callJsonRpc('wallet_address', []) const params = {
transactionHex,
} }
return await callJsonRpc('mempool_submitTransaction', params)
export const sendTransaction = async (
toAddress: string,
amount: string,
fee: string
): Promise<any> => {
const params = [toAddress, amount, fee]
return await callJsonRpc('wallet_send', params)
}
export const broadcastSignedTransaction = async (signedTxData: any): Promise<any> => {
return await callJsonRpc('wallet_broadcastSignedTransaction', signedTxData)
} }

BIN
src/assets/imgs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -144,14 +144,12 @@ $fw: 100;
padding: var(--card-padding); padding: var(--card-padding);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
transition: var(--transition-all); transition: var(--transition-all);
animation: fadeIn 0.6s ease-out;
@media (max-width: 768px) { @media (max-width: 768px) {
padding: var(--card-padding-mobile); padding: var(--card-padding-mobile);
} }
&:hover { &:hover {
transform: translateY(-4px);
box-shadow: var(--card-shadow-hover); box-shadow: var(--card-shadow-hover);
} }
} }

View File

@ -2,38 +2,34 @@
// ==================== COLORS ==================== // ==================== COLORS ====================
// Primary Colors // Primary Colors
--primary-color: #007fcf; --primary-color: #42A5F5;
--primary-hover: #0066a6; --primary-hover: #1E88E5;
--primary-light: #e8f4fc; --primary-light: #E3F2FD;
--primary-bg: #f5fbff; --primary-bg: #F5FBFF;
// Secondary Colors
--secondary-color: #ff9500;
--secondary-hover: #e68600;
// Text Colors // Text Colors
--text-primary: #2c3e50; --text-primary: #232323;
--text-secondary: #5a6c7d; --text-secondary: #5A5A5A;
--text-muted: #8b95a5; --text-muted: #8B8B8B;
--text-light: #ffffff; --text-light: #FFFFFF;
// Background Colors // Background Colors
--bg-gradient-start: #f0f8ff; --bg-gradient-start: #f0f8ff;
--bg-gradient-end: #e6f2ff; --bg-gradient-end: #e3f2fd;
--bg-white: #ffffff; --bg-white: #ffffff;
--bg-light: #f8fcff; --bg-light: #f8fcff;
--bg-hover: #e8f4fc; --bg-hover: #e3f2fd;
// Border Colors // Border Colors
--border-light: #e6f2ff; --border-light: #e3f2fd;
--border-color: #ebf5ff; --border-color: #e8f4fc;
--border-primary: #007fcf; --border-primary: #42A5F5;
// Status Colors // Status Colors
--success-color: #10b981; --success-color: #10b981;
--warning-color: #f59e0b; --warning-color: #f59e0b;
--error-color: #ef4444; --error-color: #ef4444;
--info-color: #007fcf; --info-color: #42A5F5;
// ==================== SPACING ==================== // ==================== SPACING ====================
@ -48,7 +44,7 @@
// ==================== BORDER RADIUS ==================== // ==================== BORDER RADIUS ====================
--radius-sm: 8px; --radius-sm: 4px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 12px; --radius-lg: 12px;
--radius-xl: 16px; --radius-xl: 16px;
@ -61,7 +57,7 @@
--shadow-md: 0 4px 20px rgba(0, 0, 0, 0.08); --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 12px 40px rgba(0, 0, 0, 0.15); --shadow-xl: 0 12px 40px rgba(0, 0, 0, 0.15);
--shadow-primary: 0 4px 12px rgba(0, 127, 207, 0.25); --shadow-primary: 0 4px 12px rgba(66, 165, 245, 0.25);
--shadow-secondary: 0 4px 12px rgba(255, 149, 0, 0.25); --shadow-secondary: 0 4px 12px rgba(255, 149, 0, 0.25);
// ==================== TRANSITIONS ==================== // ==================== TRANSITIONS ====================
@ -117,7 +113,7 @@
// Button // Button
--btn-padding-y: 0.75rem; --btn-padding-y: 0.75rem;
--btn-padding-x: 1rem; --btn-padding-x: 1rem;
--btn-radius: var(--radius-md); --btn-radius: var(--radius-sm);
// Tabs // Tabs
--tabs-height: 3px; --tabs-height: 3px;

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from 'ant-design-vue'
import type { ButtonProps } from '@/interface' import type { ButtonProps } from '@/interface'
import { computed } from 'vue'
import { SpinnerCommon } from '@/components'
const props = withDefaults(defineProps<ButtonProps>(), { const props = withDefaults(defineProps<ButtonProps>(), {
type: 'default', type: 'default',
@ -18,50 +19,154 @@ const handleClick = () => {
emit('click') emit('click')
} }
} }
const buttonClasses = computed(() => {
return [
'btn-common',
`btn-${props.type}`,
`btn-${props.size}`,
{
'btn-block': props.block,
'btn-disabled': props.disabled || props.loading,
'btn-loading': props.loading,
},
]
})
</script> </script>
<template> <template>
<Button <button
:type="props.type" :type="props.htmlType"
:size="props.size" :class="buttonClasses"
:block="props.block" :disabled="props.disabled || props.loading"
:disabled="props.disabled"
:loading="props.loading"
:html-type="props.htmlType"
@click="handleClick" @click="handleClick"
class="btn-common"
> >
<span v-if="props.loading" class="btn-spinner">
<SpinnerCommon size="small" />
</span>
<slot /> <slot />
</Button> </button>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.btn-common { .btn-common {
:deep(.ant-btn) { position: relative;
background: var(--primary-color); display: inline-flex;
border-color: var(--primary-color); align-items: center;
justify-content: center;
gap: var(--spacing-xs);
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
height: auto;
padding: var(--btn-padding-y) var(--btn-padding-x);
transition: var(--transition-all);
border-radius: var(--btn-radius); border-radius: var(--btn-radius);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
transition: all 2s ease-in-out; transition: var(--transition-all);
&:hover { cursor: pointer;
border: 2px solid transparent;
outline: none;
white-space: nowrap;
user-select: none;
// Sizes
&.btn-small {
padding: 6px 12px;
font-size: var(--font-sm);
}
&.btn-medium {
padding: 10px 16px;
font-size: var(--font-base);
}
&.btn-large {
padding: var(--btn-padding-y) var(--btn-padding-x);
font-size: var(--font-base);
}
// Types
&.btn-primary {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-light);
&:hover:not(.btn-disabled) {
background: var(--primary-hover); background: var(--primary-hover);
border-color: var(--primary-hover); border-color: var(--primary-hover);
} }
&:active, &:active:not(.btn-disabled),
&:focus { &:focus:not(.btn-disabled) {
background: var(--primary-hover); background: var(--primary-hover);
border-color: var(--primary-hover); border-color: var(--primary-hover);
} }
}
&:disabled { &.btn-default {
background: var(--bg-white);
border-color: var(--border-color);
color: var(--text-primary);
&:hover:not(.btn-disabled) {
background: var(--bg-hover);
border-color: var(--primary-color);
color: var(--primary-color);
}
&:active:not(.btn-disabled),
&:focus:not(.btn-disabled) {
background: var(--bg-hover);
border-color: var(--primary-color);
}
}
&.btn-dashed {
background: transparent;
border-style: dashed;
border-color: var(--border-color);
color: var(--text-primary);
&:hover:not(.btn-disabled) {
border-color: var(--primary-color);
color: var(--primary-color);
}
}
&.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary-color);
&:hover:not(.btn-disabled) {
color: var(--primary-hover);
}
}
&.btn-text {
background: transparent;
border-color: transparent;
color: var(--text-primary);
&:hover:not(.btn-disabled) {
background: var(--bg-hover);
}
}
// States
&.btn-block {
display: flex;
width: 100%;
}
&.btn-disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
&.btn-loading {
cursor: wait;
}
.btn-spinner {
display: inline-flex;
align-items: center;
} }
} }
</style> </style>

View File

@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
interface Props {
open: boolean
title?: string
width?: string | number
maskClosable?: boolean
keyboard?: boolean
footer?: boolean
disabled?: boolean
loading?: boolean
getContainer?: () => HTMLElement
}
const props = withDefaults(defineProps<Props>(), {
title: '',
width: '520px',
maskClosable: true,
keyboard: true,
footer: true,
disabled: false,
loading: false,
})
const emit = defineEmits<{
'update:open': [value: boolean]
cancel: []
ok: []
}>()
const handleClose = () => {
if (!props.maskClosable) return
emit('update:open', false)
emit('cancel')
}
const handleCancel = () => {
emit('update:open', false)
emit('cancel')
}
const handleOk = () => {
emit('ok')
}
const handleMaskClick = () => {
if (props.maskClosable) {
handleClose()
}
}
const handleContentClick = (e: Event) => {
e.stopPropagation()
}
const handleKeydown = (e: KeyboardEvent) => {
if (props.keyboard && e.key === 'Escape' && props.open) {
handleClose()
}
}
const modalWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`
}
return props.width
})
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
)
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = ''
})
</script>
<template>
<Teleport :to="getContainer ? getContainer() : 'body'">
<Transition name="modal-fade">
<div v-if="open" class="modal-mask" @click="handleMaskClick">
<Transition name="modal-slide">
<div
v-if="open"
class="modal-wrapper"
:style="{ width: modalWidth }"
@click="handleContentClick"
>
<div class="modal-header" v-if="title">
<h3 class="modal-title">{{ title }}</h3>
<button
class="modal-close-btn"
@click="handleCancel"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<slot />
</div>
<div v-if="footer" class="modal-footer">
<slot name="footer">
<button class="modal-btn modal-btn-default" @click="handleCancel">
Cancel
</button>
<button
class="modal-btn modal-btn-primary"
@click="handleOk"
:disabled="disabled"
>
<SpinnerCommon v-if="loading" size="small" />
<span v-else>OK</span>
</button>
</slot>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-lg);
}
.modal-wrapper {
background: var(--bg-white);
border-radius: var(--radius-lg);
box-shadow: 0 3px 20px rgba(0, 0, 0, 0.2);
max-width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-lg) var(--spacing-xl);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
.modal-title {
margin: 0;
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
}
.modal-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: var(--transition-all);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.modal-body {
padding: var(--spacing-xl);
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg) var(--spacing-xl);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
.modal-btn {
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: var(--font-base);
font-weight: var(--font-medium);
cursor: pointer;
transition: var(--transition-all);
border: 1px solid transparent;
&.modal-btn-default {
background: var(--bg-white);
border-color: var(--border-color);
color: var(--text-primary);
&:hover {
background: var(--bg-hover);
border-color: var(--primary-color);
color: var(--primary-color);
}
}
&.modal-btn-primary {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-light);
&:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
}
}
}
// Transitions
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-slide-enter-active,
.modal-slide-leave-active {
transition: all 0.3s ease;
}
.modal-slide-enter-from {
opacity: 0;
transform: translateY(-50px) scale(0.95);
}
.modal-slide-leave-to {
opacity: 0;
transform: translateY(50px) scale(0.95);
}
@media (max-width: 767px) {
.modal-mask {
padding: var(--spacing-sm);
}
.modal-wrapper {
width: 90% !important;
max-width: 90% !important;
}
.modal-header,
.modal-body,
.modal-footer {
padding: var(--spacing-md);
}
}
</style>

View File

@ -13,6 +13,7 @@ interface Props {
loading?: boolean loading?: boolean
error?: boolean error?: boolean
errorMessage?: string errorMessage?: string
validateFormat?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -26,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
error: false, error: false,
errorMessage: 'Invalid password', errorMessage: 'Invalid password',
validateFormat: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -36,14 +38,42 @@ const emit = defineEmits<{
const password = ref('') const password = ref('')
const passwordError = ref('') const passwordError = ref('')
const passwordStrength = computed(() => {
if (!password.value || !props.validateFormat) return { level: 0, text: '', color: '' }
let strength = 0
const checks = {
length: password.value.length >= 8,
uppercase: /[A-Z]/.test(password.value),
lowercase: /[a-z]/.test(password.value),
number: /[0-9]/.test(password.value),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password.value),
}
strength = Object.values(checks).filter(Boolean).length
if (strength <= 2) return { level: 1, text: 'Weak', color: 'var(--error-color)' }
if (strength <= 3) return { level: 2, text: 'Medium', color: 'var(--warning-color)' }
if (strength <= 4) return { level: 3, text: 'Good', color: 'var(--info-color)' }
return { level: 4, text: 'Strong', color: 'var(--success-color)' }
})
const canProceed = computed(() => { const canProceed = computed(() => {
return password.value.length > 0 && !passwordError.value if (!password.value || passwordError.value) return false
if (props.validateFormat) {
return password.value.length >= 8 && passwordStrength.value.level >= 2
}
return password.value.length > 0
}) })
const handleSubmit = () => { const handleSubmit = () => {
if (!canProceed.value) { if (!canProceed.value) {
if (!password.value) { if (!password.value) {
passwordError.value = 'Please enter your password' passwordError.value = 'Please enter your password'
} else if (props.validateFormat && password.value.length < 8) {
passwordError.value = 'Password must be at least 8 characters'
} else if (props.validateFormat && passwordStrength.value.level < 2) {
passwordError.value = 'Password is too weak'
} }
return return
} }
@ -73,6 +103,28 @@ const handleBack = () => {
@input="passwordError = ''" @input="passwordError = ''"
@keyup.enter="handleSubmit" @keyup.enter="handleSubmit"
/> />
<!-- Password Strength Indicator -->
<div v-if="props.validateFormat && password" class="password-strength">
<div class="strength-bar">
<div
class="strength-fill"
:style="{
width: `${(passwordStrength.level / 4) * 100}%`,
backgroundColor: passwordStrength.color,
}"
></div>
</div>
<span class="strength-text" :style="{ color: passwordStrength.color }">
{{ passwordStrength.text }}
</span>
</div>
<!-- Helper Text -->
<p v-if="props.validateFormat" class="helper-text">
Password must be at least 8 characters with uppercase, lowercase, and numbers.
</p>
<span v-if="error" class="error-message">{{ errorMessage }}</span> <span v-if="error" class="error-message">{{ errorMessage }}</span>
</div> </div>
@ -132,4 +184,38 @@ const handleBack = () => {
font-size: var(--font-sm); font-size: var(--font-sm);
text-align: center; text-align: center;
} }
.password-strength {
margin-top: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-md);
.strength-bar {
flex: 1;
height: 4px;
background: var(--border-light);
border-radius: var(--radius-full);
overflow: hidden;
.strength-fill {
height: 100%;
transition: all 0.3s ease;
}
}
.strength-text {
font-size: var(--font-xs);
font-weight: var(--font-medium);
min-width: 50px;
text-align: right;
}
}
.helper-text {
font-size: var(--font-xs);
color: var(--text-muted);
margin: var(--spacing-xs) 0 0;
line-height: var(--leading-normal);
}
</style> </style>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { inject, computed, type ComputedRef } from 'vue'
interface Props {
tabKey: string
}
const props = defineProps<Props>()
const activeKey = inject<ComputedRef<string>>('activeTabKey')
const isActive = computed(() => {
return activeKey?.value === props.tabKey
})
</script>
<template>
<div v-show="isActive" class="tab-pane">
<slot />
</div>
</template>
<style lang="scss" scoped>
.tab-pane {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
import { provide, computed } from 'vue'
interface TabItem {
key: string
label: string
disabled?: boolean
}
interface Props {
modelValue: string
items: TabItem[]
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const activeKey = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value),
})
provide('activeTabKey', activeKey)
const handleTabClick = (key: string, disabled?: boolean) => {
if (disabled) return
activeKey.value = key
}
const tabClasses = computed(() => {
return ['tabs-common', `tabs-${props.size}`]
})
</script>
<template>
<div :class="tabClasses">
<div class="tabs-nav">
<div
v-for="item in items"
:key="item.key"
class="tab-item"
:class="{
'tab-active': activeKey === item.key,
'tab-disabled': item.disabled,
}"
@click="handleTabClick(item.key, item.disabled)"
>
{{ item.label }}
</div>
<div class="tabs-ink-bar" :style="{ left: `${items.findIndex(item => item.key === activeKey) * (100 / items.length)}%`, width: `${100 / items.length}%` }" />
</div>
<div class="tabs-content">
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.tabs-common {
height: 100%;
.tabs-nav {
display: flex;
position: relative;
border-bottom: 2px solid var(--border-color);
margin-bottom: var(--spacing-md);
flex-shrink: 0;
.tab-item {
flex: 1;
text-align: center;
cursor: pointer;
transition: var(--transition-all);
color: var(--text-secondary);
font-weight: var(--font-semibold);
letter-spacing: var(--tracking-wide);
user-select: none;
&:hover:not(.tab-disabled) {
color: var(--primary-color);
}
&.tab-active {
color: var(--primary-color);
}
&.tab-disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.tabs-ink-bar {
position: absolute;
bottom: -2px;
height: var(--tabs-height);
background: var(--primary-color);
transition: all 0.3s ease;
}
}
// Sizes
&.tabs-small .tab-item {
padding: 8px 12px;
font-size: var(--font-sm);
}
&.tabs-medium .tab-item {
padding: 10px 16px;
font-size: var(--font-base);
}
&.tabs-large .tab-item {
padding: 12px 20px;
font-size: var(--font-lg);
}
.tabs-content {
flex: 1;
display: flex;
flex-direction: column;
}
@include screen(mobile) {
&.tabs-large .tab-item {
padding: 8px 12px;
font-size: var(--font-sm);
}
}
}
</style>

View File

@ -5,6 +5,21 @@ import PasswordForm from './common/PasswordForm.vue'
import SpinnerCommon from './common/SpinnerCommon.vue' import SpinnerCommon from './common/SpinnerCommon.vue'
import CardBase from './common/CardBase.vue' import CardBase from './common/CardBase.vue'
import CardBaseScrollable from './common/CardBaseScrollable.vue' import CardBaseScrollable from './common/CardBaseScrollable.vue'
import TabsCommon from './common/TabsCommon.vue'
import TabPaneCommon from './common/TabPaneCommon.vue'
import ModalCommon from './common/ModalCommon.vue'
import { IconCommon } from './icon' import { IconCommon } from './icon'
export { LayoutVue, ButtonCommon, FormCommon, PasswordForm, SpinnerCommon, CardBase, CardBaseScrollable, IconCommon } export {
LayoutVue,
ButtonCommon,
FormCommon,
PasswordForm,
SpinnerCommon,
CardBase,
CardBaseScrollable,
TabsCommon,
TabPaneCommon,
ModalCommon,
IconCommon,
}

View File

@ -1,12 +1,7 @@
import { useNeptuneStore } from '@/stores/neptuneStore' import { useNeptuneStore } from '@/stores/neptuneStore'
import * as API from '@/api/neptuneApi' import * as API from '@/api/neptuneApi'
import type { GenerateSeedResult, ViewKeyResult } from '@/interface' import type { GenerateSeedResult, PayloadBuildTransaction, ViewKeyResult, WalletState } from '@/interface'
import initWasm, { import initWasm, { generate_seed, address_from_seed, validate_seed_phrase } from '@neptune/wasm'
generate_seed,
get_viewkey,
address_from_seed,
validate_seed_phrase,
} from '@neptune/wasm'
let wasmInitialized = false let wasmInitialized = false
let initPromise: Promise<void> | null = null let initPromise: Promise<void> | null = null
@ -51,8 +46,11 @@ export function useNeptuneWallet() {
store.setReceiverId(result.receiver_identifier) store.setReceiverId(result.receiver_identifier)
const viewKeyResult = await getViewKeyFromSeed(result.seed_phrase) const viewKeyResult = await getViewKeyFromSeed(result.seed_phrase)
store.setViewKey(viewKeyResult.view_key) store.setViewKey(viewKeyResult.view_key_hex)
store.setAddress(viewKeyResult.address) store.setSpendingKey(viewKeyResult.spending_key_hex)
const addressResult = await getAddressFromSeed(result.seed_phrase)
store.setAddress(addressResult)
return result return result
} catch (err) { } catch (err) {
@ -62,18 +60,11 @@ export function useNeptuneWallet() {
} }
const getViewKeyFromSeed = async (seedPhrase: string[]): Promise<ViewKeyResult> => { const getViewKeyFromSeed = async (seedPhrase: string[]): Promise<ViewKeyResult> => {
try { const result = await (window as any).walletApi.generateKeysFromSeed([...seedPhrase])
await ensureWasmInitialized() return JSON.parse(result)
const seedPhraseJson = JSON.stringify(seedPhrase)
const resultJson = get_viewkey(seedPhraseJson, store.getNetwork)
return JSON.parse(resultJson)
} catch (err) {
console.error('Error getting view key from seed:', err)
throw err
}
} }
const recoverWalletFromSeed = async (seedPhrase: string[]): Promise<ViewKeyResult> => { const recoverWalletFromSeed = async (seedPhrase: string[]): Promise<WalletState> => {
try { try {
const isValid = validate_seed_phrase(JSON.stringify(seedPhrase)) const isValid = validate_seed_phrase(JSON.stringify(seedPhrase))
if (!isValid) throw new Error('Invalid seed phrase') if (!isValid) throw new Error('Invalid seed phrase')
@ -82,10 +73,20 @@ export function useNeptuneWallet() {
store.setSeedPhrase(seedPhrase) store.setSeedPhrase(seedPhrase)
store.setReceiverId(result.receiver_identifier) store.setReceiverId(result.receiver_identifier)
store.setViewKey(result.view_key) store.setViewKey(result.view_key_hex)
store.setAddress(result.address) store.setSpendingKey(result.spending_key_hex)
return result const addressResult = await getAddressFromSeed(seedPhrase)
store.setAddress(addressResult)
return {
seedPhrase: seedPhrase,
network: store.getNetwork,
receiverId: result.receiver_identifier,
viewKey: result.view_key_hex,
spendingKey: result.spending_key_hex,
address: addressResult,
}
} catch (err) { } catch (err) {
console.error('Error recovering wallet from seed:', err) console.error('Error recovering wallet from seed:', err)
throw err throw err
@ -93,14 +94,9 @@ export function useNeptuneWallet() {
} }
const getAddressFromSeed = async (seedPhrase: string[]): Promise<string> => { const getAddressFromSeed = async (seedPhrase: string[]): Promise<string> => {
try {
await ensureWasmInitialized() await ensureWasmInitialized()
const seedPhraseJson = JSON.stringify(seedPhrase) const seedPhraseJson = JSON.stringify(seedPhrase)
return address_from_seed(seedPhraseJson, store.getNetwork) return address_from_seed(seedPhraseJson, store.getNetwork)
} catch (err) {
console.error('Error getting address from seed:', err)
throw err
}
} }
const decryptKeystore = async (password: string): Promise<void> => { const decryptKeystore = async (password: string): Promise<void> => {
@ -118,9 +114,12 @@ export function useNeptuneWallet() {
store.setPassword(password) store.setPassword(password)
store.setSeedPhrase(seedPhrase) store.setSeedPhrase(seedPhrase)
store.setAddress(viewKeyResult.address) store.setViewKey(viewKeyResult.view_key_hex)
store.setViewKey(viewKeyResult.view_key)
store.setReceiverId(viewKeyResult.receiver_identifier) store.setReceiverId(viewKeyResult.receiver_identifier)
store.setSpendingKey(viewKeyResult.spending_key_hex)
const addressResult = await getAddressFromSeed(seedPhrase)
store.setAddress(addressResult)
} catch (err) { } catch (err) {
if ( if (
err instanceof Error && err instanceof Error &&
@ -137,6 +136,7 @@ export function useNeptuneWallet() {
const createKeystore = async (seed: string, password: string): Promise<string> => { const createKeystore = async (seed: string, password: string): Promise<string> => {
try { try {
const result = await (window as any).walletApi.createKeystore(seed, password) const result = await (window as any).walletApi.createKeystore(seed, password)
store.setKeystorePath(result.filePath)
return result.filePath return result.filePath
} catch (err) { } catch (err) {
console.error('Error creating keystore:', err) console.error('Error creating keystore:', err)
@ -170,22 +170,13 @@ export function useNeptuneWallet() {
// ===== API METHODS ===== // ===== API METHODS =====
const getUtxos = async ( const getUtxos = async (): Promise<any> => {
startBlock: number = 0,
endBlock: number | null = null,
maxSearchDepth: number = 1000
): Promise<any> => {
try { try {
if (!store.getViewKey) { if (!store.getViewKey) {
throw new Error('No view key available. Please import or generate a wallet first.') throw new Error('No view key available. Please import or generate a wallet first.')
} }
const response = await API.getUtxosFromViewKey( const response = await API.getUtxosFromViewKey(store.getViewKey || '')
store.getViewKey,
startBlock,
endBlock,
maxSearchDepth
)
const result = response?.result || response const result = response?.result || response
store.setUtxos(result.utxos || result || []) store.setUtxos(result.utxos || result || [])
@ -198,11 +189,14 @@ export function useNeptuneWallet() {
const getBalance = async (): Promise<any> => { const getBalance = async (): Promise<any> => {
try { try {
const response = await API.getBalance() const response = await API.getBalance(store.getViewKey || '')
const result = response?.result || response const result = response?.result || response
store.setBalance(result.balance || result) store.setBalance(result?.balance || result)
store.setPendingBalance(result?.pendingBalance || result)
return result return {
balance: result?.balance || result,
pendingBalance: result?.pendingBalance || result,
}
} catch (err) { } catch (err) {
console.error('Error getting balance:', err) console.error('Error getting balance:', err)
throw err throw err
@ -233,13 +227,20 @@ export function useNeptuneWallet() {
} }
} }
const sendTransaction = async ( const buildTransactionWithPrimitiveProof = async (args: PayloadBuildTransaction): Promise<any> => {
toAddress: string, const payload = {
amount: string, spendingKeyHex: store.getSpendingKey,
fee: string inputAdditionRecords: args.inputAdditionRecords,
): Promise<any> => { outputAddresses: args.outputAddresses,
outputAmounts: args.outputAmounts,
fee: args.fee,
}
return await (window as any).walletApi.buildTransactionWithPrimitiveProof(payload)
}
const broadcastSignedTransaction = async (transactionHex: string): Promise<any> => {
try { try {
const response = await API.sendTransaction(toAddress, amount, fee) const response = await API.broadcastSignedTransaction(transactionHex)
const result = response?.result || response const result = response?.result || response
return result return result
} catch (err) { } catch (err) {
@ -254,8 +255,11 @@ export function useNeptuneWallet() {
if (store.getSeedPhrase) { if (store.getSeedPhrase) {
const viewKeyResult = await getViewKeyFromSeed(store.getSeedPhrase) const viewKeyResult = await getViewKeyFromSeed(store.getSeedPhrase)
store.setAddress(viewKeyResult.address) store.setViewKey(viewKeyResult.view_key_hex)
store.setViewKey(viewKeyResult.view_key) store.setSpendingKey(viewKeyResult.spending_key_hex)
const addressResult = await getAddressFromSeed(store.getSeedPhrase)
store.setAddress(addressResult)
} }
} catch (err) { } catch (err) {
console.error('Error setting network:', err) console.error('Error setting network:', err)
@ -279,7 +283,8 @@ export function useNeptuneWallet() {
getBalance, getBalance,
getBlockHeight, getBlockHeight,
getNetworkInfo, getNetworkInfo,
sendTransaction, buildTransactionWithPrimitiveProof,
broadcastSignedTransaction,
decryptKeystore, decryptKeystore,
createKeystore, createKeystore,
saveKeystoreAs, saveKeystoreAs,

View File

@ -1,6 +1,7 @@
import type { ButtonType, ButtonSize } from 'ant-design-vue/es/button'
// Button Component Props // Button Component Props
export type ButtonType = 'default' | 'primary' | 'dashed' | 'link' | 'text'
export type ButtonSize = 'small' | 'medium' | 'large'
export interface ButtonProps { export interface ButtonProps {
type?: ButtonType type?: ButtonType
size?: ButtonSize size?: ButtonSize

View File

@ -1,12 +1,14 @@
export interface WalletState { export interface WalletState {
seedPhrase: string[] | null seedPhrase: string[] | null
password: string | null password?: string | null
receiverId: string | null receiverId: string | null
viewKey: string | null viewKey: string | null
spendingKey?: string | null
address: string | null address: string | null
network: 'mainnet' | 'testnet' network: 'mainnet' | 'testnet'
balance: string | null balance?: string | null
utxos: any[] pendingBalance?: string | null
utxos?: Utxo[]
} }
export interface GenerateSeedResult { export interface GenerateSeedResult {
@ -16,6 +18,26 @@ export interface GenerateSeedResult {
export interface ViewKeyResult { export interface ViewKeyResult {
receiver_identifier: string receiver_identifier: string
view_key: string spending_key_hex: string
address: string view_key_hex: string
success?: boolean
}
export interface PayloadBuildTransaction {
spendingKeyHex?: string
inputAdditionRecords?: string[]
outputAddresses?: string[]
outputAmounts?: string[]
fee?: string
}
export interface PayloadBroadcastSignedTransaction {
transactionHex: string
}
export interface Utxo {
additionRecord: string
amount: string
blockHeight: number
utxoHash: string
} }

View File

@ -2,7 +2,6 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import i18n from '@/lang' import i18n from '@/lang'
import App from './App.vue' import App from './App.vue'
import Antd from 'ant-design-vue'
import router from './router' import router from './router'
import 'ant-design-vue/dist/reset.css' import 'ant-design-vue/dist/reset.css'
import './assets/scss/main.scss' import './assets/scss/main.scss'
@ -12,12 +11,11 @@ const app = createApp(App)
app.use(i18n) app.use(i18n)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(Antd)
// Hide 'DOMNodeInserted' // Hide 'DOMNodeInserted'
const originalAddEventListener = Element.prototype.addEventListener const originalAddEventListener = Element.prototype.addEventListener
Element.prototype.addEventListener = function (type: any, listener: any, options: any) { Element.prototype.addEventListener = function (type: any, listener: any, options: any) {
if (type === 'DOMNodeInserted') return // Ignore this event type if (type === 'DOMNodeInserted') return
return originalAddEventListener.call(this, type, listener, options) return originalAddEventListener.call(this, type, listener, options)
} }

View File

@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
import type { WalletState } from '@/interface' import type { WalletState } from '@/interface'
export const useNeptuneStore = defineStore('neptune', () => { export const useNeptuneStore = defineStore('neptune', () => {
const defaultNetwork = (import.meta.env.VITE_NODE_NETWORK || 'testnet') as 'mainnet' | 'testnet' const defaultNetwork = (import.meta.env.VITE_NODE_NETWORK || 'mainnet') as 'mainnet' | 'testnet'
// ===== STATE ===== // ===== STATE =====
const wallet = ref<WalletState>({ const wallet = ref<WalletState>({
@ -11,9 +11,11 @@ export const useNeptuneStore = defineStore('neptune', () => {
password: null, password: null,
receiverId: null, receiverId: null,
viewKey: null, viewKey: null,
spendingKey: null,
address: null, address: null,
network: defaultNetwork, network: defaultNetwork,
balance: null, balance: null,
pendingBalance: null,
utxos: [], utxos: [],
}) })
@ -37,6 +39,10 @@ export const useNeptuneStore = defineStore('neptune', () => {
wallet.value.viewKey = viewKey wallet.value.viewKey = viewKey
} }
const setSpendingKey = (spendingKey: string | null) => {
wallet.value.spendingKey = spendingKey
}
const setAddress = (address: string | null) => { const setAddress = (address: string | null) => {
wallet.value.address = address wallet.value.address = address
} }
@ -49,6 +55,10 @@ export const useNeptuneStore = defineStore('neptune', () => {
wallet.value.balance = balance wallet.value.balance = balance
} }
const setPendingBalance = (pendingBalance: string | null) => {
wallet.value.pendingBalance = pendingBalance
}
const setUtxos = (utxos: any[]) => { const setUtxos = (utxos: any[]) => {
wallet.value.utxos = utxos wallet.value.utxos = utxos
} }
@ -67,9 +77,11 @@ export const useNeptuneStore = defineStore('neptune', () => {
password: null, password: null,
receiverId: null, receiverId: null,
viewKey: null, viewKey: null,
spendingKey: null,
address: null, address: null,
network: defaultNetwork, network: defaultNetwork,
balance: null, balance: null,
pendingBalance: null,
utxos: [], utxos: [],
} }
} }
@ -81,9 +93,11 @@ export const useNeptuneStore = defineStore('neptune', () => {
const getPassword = computed(() => wallet.value.password) const getPassword = computed(() => wallet.value.password)
const getReceiverId = computed(() => wallet.value.receiverId) const getReceiverId = computed(() => wallet.value.receiverId)
const getViewKey = computed(() => wallet.value.viewKey) const getViewKey = computed(() => wallet.value.viewKey)
const getSpendingKey = computed(() => wallet.value.spendingKey)
const getAddress = computed(() => wallet.value.address) const getAddress = computed(() => wallet.value.address)
const getNetwork = computed(() => wallet.value.network) const getNetwork = computed(() => wallet.value.network)
const getBalance = computed(() => wallet.value.balance) const getBalance = computed(() => wallet.value.balance)
const getPendingBalance = computed(() => wallet.value.pendingBalance)
const getUtxos = computed(() => wallet.value.utxos) const getUtxos = computed(() => wallet.value.utxos)
const hasWallet = computed(() => wallet.value.address !== null) const hasWallet = computed(() => wallet.value.address !== null)
const getKeystorePath = computed(() => keystorePath.value) const getKeystorePath = computed(() => keystorePath.value)
@ -94,9 +108,11 @@ export const useNeptuneStore = defineStore('neptune', () => {
getPassword, getPassword,
getReceiverId, getReceiverId,
getViewKey, getViewKey,
getSpendingKey,
getAddress, getAddress,
getNetwork, getNetwork,
getBalance, getBalance,
getPendingBalance,
getUtxos, getUtxos,
hasWallet, hasWallet,
getKeystorePath, getKeystorePath,
@ -104,9 +120,11 @@ export const useNeptuneStore = defineStore('neptune', () => {
setPassword, setPassword,
setReceiverId, setReceiverId,
setViewKey, setViewKey,
setSpendingKey,
setAddress, setAddress,
setNetwork, setNetwork,
setBalance, setBalance,
setPendingBalance,
setUtxos, setUtxos,
setWallet, setWallet,
setKeystorePath, setKeystorePath,

View File

@ -1,2 +1,3 @@
export const PAGE_FIRST = 1 export const PAGE_FIRST = 1
export const PER_PAGE = 40 export const PER_PAGE = 20
export const POLLING_INTERVAL = 1000 * 60 // 1 minute

View File

@ -33,7 +33,6 @@ const handleGoToRecover = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.auth-container { .auth-container {
min-height: 100vh; min-height: 100vh;
background: var(--bg-light);
} }
.complete-state { .complete-state {

View File

@ -4,7 +4,6 @@ import { PasswordForm } from '@/components'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { message } from 'ant-design-vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@ -38,74 +37,7 @@ const handleNewWallet = () => {
<div class="auth-card-header"> <div class="auth-card-header">
<div class="logo-container"> <div class="logo-container">
<div class="logo-circle"> <div class="logo-circle">
<svg <img src="@/assets/imgs/logo.png" alt="Neptune Logo" class="neptune-logo" />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
class="neptune-logo"
>
<defs>
<linearGradient
id="neptuneGradientLogin"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
style="stop-color: #007fcf; stop-opacity: 1"
/>
<stop
offset="100%"
style="stop-color: #0066a6; stop-opacity: 1"
/>
</linearGradient>
<linearGradient
id="ringGradientLogin"
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<stop
offset="0%"
style="stop-color: #007fcf; stop-opacity: 0.3"
/>
<stop
offset="50%"
style="stop-color: #007fcf; stop-opacity: 0.6"
/>
<stop
offset="100%"
style="stop-color: #007fcf; stop-opacity: 0.3"
/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="28" fill="url(#neptuneGradientLogin)" />
<ellipse
cx="50"
cy="45"
rx="22"
ry="6"
fill="rgba(255, 255, 255, 0.1)"
/>
<ellipse cx="50" cy="55" rx="20" ry="5" fill="rgba(0, 0, 0, 0.1)" />
<ellipse
cx="50"
cy="50"
rx="42"
ry="12"
fill="none"
stroke="url(#ringGradientLogin)"
stroke-width="4"
opacity="0.8"
/>
<circle cx="42" cy="42" r="6" fill="rgba(255, 255, 255, 0.4)" />
</svg>
</div> </div>
<div class="logo-text"> <div class="logo-text">
<span class="coin-name">Neptune</span> <span class="coin-name">Neptune</span>
@ -172,6 +104,8 @@ const handleNewWallet = () => {
.neptune-logo { .neptune-logo {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain;
border-radius: 50%;
} }
} }

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineEmits, computed } from 'vue' import { computed } from 'vue'
import { ButtonCommon } from '@/components' import { ButtonCommon } from '@/components'
import { useNeptuneStore } from '@/stores/neptuneStore' import { useNeptuneStore } from '@/stores/neptuneStore'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineEmits, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { ButtonCommon, CardBase } from '@/components' import { ButtonCommon } from '@/components'
import { useNeptuneStore } from '@/stores/neptuneStore' import { useNeptuneStore } from '@/stores/neptuneStore'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'

View File

@ -71,50 +71,7 @@ const handleIHaveWallet = () => {
<div class="auth-card-header"> <div class="auth-card-header">
<div class="logo-container"> <div class="logo-container">
<div class="logo-circle"> <div class="logo-circle">
<svg <img src="@/assets/imgs/logo.png" alt="Neptune Logo" class="neptune-logo" />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
class="neptune-logo"
>
<defs>
<linearGradient
id="neptuneGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" style="stop-color: #007fcf; stop-opacity: 1" />
<stop offset="100%" style="stop-color: #0066a6; stop-opacity: 1" />
</linearGradient>
<linearGradient id="ringGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color: #007fcf; stop-opacity: 0.3" />
<stop offset="50%" style="stop-color: #007fcf; stop-opacity: 0.6" />
<stop
offset="100%"
style="stop-color: #007fcf; stop-opacity: 0.3"
/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="28" fill="url(#neptuneGradient)" />
<ellipse cx="50" cy="45" rx="22" ry="6" fill="rgba(255, 255, 255, 0.1)" />
<ellipse cx="50" cy="55" rx="20" ry="5" fill="rgba(0, 0, 0, 0.1)" />
<ellipse
cx="50"
cy="50"
rx="42"
ry="12"
fill="none"
stroke="url(#ringGradient)"
stroke-width="4"
opacity="0.8"
/>
<circle cx="42" cy="42" r="6" fill="rgba(255, 255, 255, 0.4)" />
</svg>
</div> </div>
<div class="logo-text"> <div class="logo-text">
<span class="coin-name">Neptune</span> <span class="coin-name">Neptune</span>
@ -237,6 +194,8 @@ const handleIHaveWallet = () => {
.neptune-logo { .neptune-logo {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain;
border-radius: 50%;
} }
} }

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineEmits, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { SeedPhraseDisplayComponent, ConfirmSeedComponent } from '..' import { SeedPhraseDisplayComponent, ConfirmSeedComponent } from '..'
import { CreatePasswordStep, WalletCreatedStep } from '.' import { CreatePasswordStep, WalletCreatedStep } from '.'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'

View File

@ -42,30 +42,7 @@ const handleCreateAnother = () => {
keystore file should only be used in an offline setting. keystore file should only be used in an offline setting.
</p> </p>
<div class="center-svg" style="margin: 14px auto 12px auto"> <div class="center-svg" style="margin: 14px auto 12px auto">
<svg width="180" height="95" viewBox="0 0 175 92" fill="none"> <img src="@/assets/imgs/logo.png" alt="Neptune Logo" style="max-width: 180px; height: auto;" />
<rect x="111" y="37" width="64" height="33" rx="7" fill="#23B1EC" />
<rect
x="30.5"
y="37.5"
width="80"
height="46"
rx="7.5"
fill="#D6F9FE"
stroke="#AEEBF8"
stroke-width="5"
/>
<rect x="56" y="67" width="32" height="10" rx="3" fill="#B0F3A6" />
<rect x="46" y="49" width="52" height="12" rx="3" fill="#a2d2f5" />
<circle cx="155" cy="52" r="8" fill="#fff" />
<rect x="121" y="43" width="27" height="7" rx="1.5" fill="#5AE9D2" />
<rect x="128" y="59" width="17" height="4" rx="1.5" fill="#FCEBBA" />
<circle cx="40" cy="27" r="7" fill="#A2D2F5" />
<g>
<circle cx="128" cy="21" r="3" fill="#FF8585" />
<circle cx="57.5" cy="20.5" r="1.5" fill="#67DEFF" />
<rect x="95" y="18" width="7" height="5" rx="2" fill="#A2D2F5" />
</g>
</svg>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<ButtonCommon <ButtonCommon

View File

@ -37,6 +37,7 @@ const handlePasswordSubmit = async (password: string) => {
const result = await recoverWalletFromSeed(seedPhrase.value) const result = await recoverWalletFromSeed(seedPhrase.value)
if (result.address) { if (result.address) {
await createKeystore(seedPhrase.value.join(' '), password) await createKeystore(seedPhrase.value.join(' '), password)
message.success('Loading wallet...')
emit('accessWallet') emit('accessWallet')
} }
} catch (err) { } catch (err) {
@ -99,6 +100,7 @@ const handleCancel = () => {
placeholder="Enter password to encrypt seed phrase" placeholder="Enter password to encrypt seed phrase"
label="Password" label="Password"
:loading="isLoading" :loading="isLoading"
:validate-format="true"
@submit="handlePasswordSubmit" @submit="handlePasswordSubmit"
@back="handlePasswordBack" @back="handlePasswordBack"
/> />

View File

@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { Tabs } from 'ant-design-vue' import { TabsCommon, TabPaneCommon } from '@/components'
import { WalletTab, NetworkTab, UTXOTab } from './components' import { WalletTab, NetworkTab, UTXOTab } from './components'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
const neptuneWallet = useNeptuneWallet() const neptuneWallet = useNeptuneWallet()
const activeTab = ref('UTXOs') const activeTab = ref('WALLET')
const network = ref('neptune-mainnet') const network = ref('neptune-mainnet')
const tabItems = [
{ key: 'WALLET', label: 'WALLET' },
{ key: 'UTXOs', label: 'UTXOs' },
{ key: 'NETWORK', label: 'NETWORK' },
]
onMounted(async () => { onMounted(async () => {
await neptuneWallet.getNetworkInfo() await neptuneWallet.getNetworkInfo()
}) })
@ -16,22 +22,22 @@ onMounted(async () => {
<template> <template>
<div class="home-container"> <div class="home-container">
<Tabs v-model:activeKey="activeTab" size="large" class="main-tabs"> <TabsCommon v-model="activeTab" :items="tabItems" size="large" class="main-tabs">
<!-- DEBUG TAB -->
<Tabs.TabPane key="UTXOs" tab="UTXOs">
<UTXOTab />
</Tabs.TabPane>
<!-- WALLET TAB --> <!-- WALLET TAB -->
<Tabs.TabPane key="WALLET" tab="WALLET"> <TabPaneCommon tab-key="WALLET">
<WalletTab :network="network" /> <WalletTab :network="network" />
</Tabs.TabPane> </TabPaneCommon>
<!-- UTXO TAB -->
<TabPaneCommon tab-key="UTXOs">
<UTXOTab />
</TabPaneCommon>
<!-- NETWORK TAB --> <!-- NETWORK TAB -->
<Tabs.TabPane key="NETWORK" tab="NETWORK"> <TabPaneCommon tab-key="NETWORK">
<NetworkTab /> <NetworkTab />
</Tabs.TabPane> </TabPaneCommon>
</Tabs> </TabsCommon>
</div> </div>
</template> </template>
@ -51,47 +57,7 @@ onMounted(async () => {
} }
} }
:deep(.main-tabs) { .main-tabs {
height: 100%; height: 100%;
display: flex;
flex-direction: column;
.ant-tabs-nav {
margin-bottom: var(--spacing-md);
flex-shrink: 0;
}
.ant-tabs-tab {
font-size: 14px;
font-weight: var(--font-semibold);
letter-spacing: var(--tracking-wide);
padding: 10px 16px;
@include screen(mobile) {
font-size: 12px;
padding: 8px 12px;
}
}
.ant-tabs-ink-bar {
background: var(--primary-color);
height: var(--tabs-height);
}
.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--primary-color);
}
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
height: 100%;
display: flex;
flex-direction: column;
}
}
} }
</style> </style>

View File

@ -5,6 +5,7 @@ import { CardBase, SpinnerCommon } from '@/components'
import { useNeptuneStore } from '@/stores/neptuneStore' import { useNeptuneStore } from '@/stores/neptuneStore'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { POLLING_INTERVAL } from '@/utils'
const neptuneStore = useNeptuneStore() const neptuneStore = useNeptuneStore()
const { getBlockHeight } = useNeptuneWallet() const { getBlockHeight } = useNeptuneWallet()
@ -44,7 +45,7 @@ const retryConnection = async () => {
const startPolling = () => { const startPolling = () => {
pollingInterval = window.setInterval(async () => { pollingInterval = window.setInterval(async () => {
if (!loading.value) await loadNetworkData() if (!loading.value) await loadNetworkData()
}, 10000) }, POLLING_INTERVAL)
} }
const stopPolling = () => { const stopPolling = () => {
@ -89,7 +90,7 @@ onUnmounted(() => {
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">DAA Score</span> <span class="status-label">Block Height</span>
<span class="status-value">{{ formatNumberToLocaleString(blockHeight) }}</span> <span class="status-value">{{ formatNumberToLocaleString(blockHeight) }}</span>
</div> </div>

View File

@ -1,29 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, onMounted } from 'vue'
import { EditOutlined } from '@ant-design/icons-vue' import { Table, message } from 'ant-design-vue'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
import { CardBaseScrollable } from '@/components' import { useNeptuneStore } from '@/stores/neptuneStore'
import { CardBaseScrollable, SpinnerCommon } from '@/components'
import { PER_PAGE } from '@/utils'
import { columns } from '../utils'
const { getUtxos } = useNeptuneWallet() const { getUtxos } = useNeptuneWallet()
const neptuneStore = useNeptuneStore()
const inUseUtxosCount = ref(0) const loading = ref(false)
const inUseUtxosAmount = ref(0)
const utxosList = computed(() => [...(neptuneStore.getUtxos || []), ...Array.from({ length: 18 }, (_, i) => ({
additionRecord: `additionRecord${i}`,
amount: `${i}.00000000`,
blockHeight: `blockHeight${i}`,
utxoHash: `utxoHash${i}`,
}))])
const inUseUtxosCount = computed(() => (utxosList.value?.length ? utxosList.value.length : 0))
const inUseUtxosAmount = computed(() => {
if (!utxosList.value?.length) return '0.00000000'
const total = utxosList.value.reduce((total: number, utxo: any) => {
const amount = parseFloat(utxo.amount || utxo.value || 0)
return total + amount
}, 0)
return total.toFixed(8)
})
const loadUtxos = async () => {
try {
loading.value = true
const result = await getUtxos()
if (result.error) {
console.error(result.error.message)
message.error('Failed to load UTXOs')
loading.value = false
return
}
loading.value = false
} catch (err) {
message.error('Failed to load UTXOs')
console.error('Error loading UTXOs:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
loadUtxos()
})
</script> </script>
<template> <template>
<CardBaseScrollable class="content-card debug-card"> <CardBaseScrollable class="content-card debug-card">
<div class="debug-header"> <div class="debug-header">
<h3 class="debug-title"> <h3 class="debug-title">IN USE UTXOS</h3>
IN USE UTXOS
<EditOutlined style="margin-left: 8px; font-size: 16px" />
</h3>
<div class="debug-info"> <div class="debug-info">
<p><strong>COUNT</strong> {{ inUseUtxosCount }}</p> <p><span>COUNT</span> {{ inUseUtxosCount }}</p>
<p><strong>AMOUNT</strong> {{ inUseUtxosAmount }} NPT</p> <p><span>AMOUNT</span> {{ inUseUtxosAmount }} <strong>NPT</strong></p>
</div> </div>
</div> </div>
<div class="list-pagination"></div> <div v-if="loading" class="loading-container">
<SpinnerCommon />
</div>
<div v-else class="list-pagination">
<Table
:columns="columns"
:data-source="utxosList"
:scroll="{ x: 'max-content', y: '200px' }"
/>
</div>
</CardBaseScrollable> </CardBaseScrollable>
</template> </template>
@ -32,14 +84,14 @@ const inUseUtxosAmount = ref(0)
.debug-header { .debug-header {
text-align: center; text-align: center;
margin-bottom: var(--spacing-2xl); margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl); padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
.debug-title { .debug-title {
font-size: var(--font-2xl); font-size: var(--font-2xl);
font-weight: var(--font-bold); font-weight: var(--font-bold);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--spacing-lg); margin-bottom: var(--spacing-xl);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
display: flex; display: flex;
align-items: center; align-items: center;
@ -48,20 +100,54 @@ const inUseUtxosAmount = ref(0)
.debug-info { .debug-info {
display: flex; display: flex;
flex-direction: column; justify-content: flex-start;
gap: var(--spacing-sm); gap: var(--spacing-4xl);
p { p {
margin: 0; margin: 0;
font-size: var(--font-lg); font-size: var(--font-lg);
color: var(--text-secondary); color: var(--text-secondary);
strong { span {
font-weight: var(--font-semibold); color: var(--text-primary);
font-weight: var(--font-bold);
margin-right: var(--spacing-sm); margin-right: var(--spacing-sm);
} }
} }
} }
} }
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.list-pagination {
:deep(.ant-table) {
background: transparent;
.ant-table-thead > tr > th {
background: var(--bg-light);
font-weight: var(--font-semibold);
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
}
.ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light);
}
.ant-table-tbody > tr:hover > td {
background: var(--bg-hover);
}
}
:deep(.ant-pagination) {
margin-top: var(--spacing-lg);
text-align: center;
}
}
} }
</style> </style>

View File

@ -1,3 +1,3 @@
export { default as NetworkTab } from './NetworkTab.vue' export { default as NetworkTab } from './NetworkTab.vue'
export { default as UTXOTab } from './UTXOTab.vue' export { default as UTXOTab } from './UTXOTab.vue'
export { WalletInfo, WalletBalanceAndAddress, WalletTab } from './wallet-tab' export { WalletInfo, WalletBalance, WalletAddress, WalletTab } from './wallet-tab'

View File

@ -0,0 +1,466 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ButtonCommon, ModalCommon } from '@/components'
interface Props {
isLoading?: boolean
availableBalance?: string
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
availableBalance: '0.00000000',
})
const emit = defineEmits(['cancel', 'send'])
const outputAddresses = ref('')
const outputAmounts = ref('')
const fee = ref('')
const showConfirmModal = ref(false)
const isAddressExpanded = ref(false)
// Validation
const isAddressValid = computed(() => outputAddresses.value.trim().length > 0)
const isAmountValid = computed(() => {
if (!outputAmounts.value) return false
const num = parseFloat(outputAmounts.value)
if (isNaN(num) || num <= 0) return false
// Check if amount exceeds available balance
const balance = parseFloat(props.availableBalance)
if (!isNaN(balance) && num > balance) return false
return true
})
const isFeeValid = computed(() => {
if (!fee.value) return false
const num = parseFloat(fee.value)
return !isNaN(num) && num >= 0
})
const amountErrorMessage = computed(() => {
if (!outputAmounts.value) return ''
const num = parseFloat(outputAmounts.value)
if (isNaN(num) || num <= 0) return 'Invalid amount'
const balance = parseFloat(props.availableBalance)
if (!isNaN(balance) && num > balance) {
return `Insufficient balance. Available: ${props.availableBalance} NPT`
}
return ''
})
const isFormValid = computed(
() => isAddressValid.value && isAmountValid.value && isFeeValid.value && !props.isLoading
)
// Format decimal for amount and fee
const formatDecimal = (value: string) => {
if (!value) return ''
// Remove any non-numeric characters except dot
let cleaned = value.replace(/[^\d.]/g, '')
// Ensure only one dot
const parts = cleaned.split('.')
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('')
}
// If it's a valid number and doesn't have a dot, add decimal point and zeros
if (cleaned && !cleaned.includes('.') && /^\d+$/.test(cleaned)) {
cleaned = cleaned + '.0'
}
// Limit to 8 decimal places
if (cleaned.includes('.')) {
const [integer, decimal] = cleaned.split('.')
cleaned = integer + '.' + decimal.slice(0, 8)
}
return cleaned
}
const handleAmountBlur = () => {
if (outputAmounts.value) {
outputAmounts.value = formatDecimal(outputAmounts.value)
}
}
const handleFeeBlur = () => {
if (fee.value) {
fee.value = formatDecimal(fee.value)
}
}
const handleCancel = () => {
emit('cancel')
}
const handleSend = () => {
if (!isFormValid.value) return
showConfirmModal.value = true
}
const handleConfirm = () => {
showConfirmModal.value = false
isAddressExpanded.value = false
emit('send', {
outputAddresses: outputAddresses.value.trim(),
outputAmounts: outputAmounts.value,
fee: fee.value,
})
}
const handleCancelConfirm = () => {
showConfirmModal.value = false
isAddressExpanded.value = false
}
const toggleAddressExpand = () => {
isAddressExpanded.value = !isAddressExpanded.value
}
const displayAddress = computed(() => {
if (!outputAddresses.value) return ''
if (isAddressExpanded.value) return outputAddresses.value
const address = outputAddresses.value
if (address.length <= 40) return address
return address.slice(0, 20) + '...' + address.slice(-20)
})
</script>
<template>
<div class="send-transaction-container">
<div class="send-transaction-form">
<!-- Recipient Address -->
<div class="form-group">
<label class="form-label">
Recipient Address <span class="required">*</span>
</label>
<textarea
v-model="outputAddresses"
class="form-textarea"
:class="{ invalid: outputAddresses && !isAddressValid }"
placeholder="Type the recipient address"
rows="3"
:disabled="isLoading"
/>
<span v-if="outputAddresses && !isAddressValid" class="error-message">
Address is required
</span>
</div>
<!-- Amount and Priority Fee Row -->
<div class="amount-row">
<div class="amount-field">
<div class="form-group">
<label class="form-label">
Amount <span class="required">*</span>
<span class="balance-info">Available: {{ availableBalance }} NPT</span>
</label>
<input
v-model="outputAmounts"
type="text"
class="form-input"
:class="{ invalid: outputAmounts && !isAmountValid }"
placeholder="0.0"
:disabled="isLoading"
@blur="handleAmountBlur"
/>
<span v-if="outputAmounts && amountErrorMessage" class="error-message">
{{ amountErrorMessage }}
</span>
</div>
</div>
<div class="priority-fee-field">
<div class="form-group">
<label class="form-label">
Priority Fee <span class="required">*</span>
</label>
<input
v-model="fee"
type="text"
class="form-input"
:class="{ invalid: fee && !isFeeValid }"
placeholder="0.0"
:disabled="isLoading"
@blur="handleFeeBlur"
/>
<span v-if="fee && !isFeeValid" class="error-message"> Invalid fee </span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<ButtonCommon
type="default"
size="large"
:disabled="isLoading"
@click="handleCancel"
>
Cancel
</ButtonCommon>
<ButtonCommon
type="primary"
size="large"
:disabled="!isFormValid"
@click="handleSend"
>
SEND
</ButtonCommon>
</div>
</div>
</div>
<!-- Confirm Modal -->
<ModalCommon
v-model:open="showConfirmModal"
title="Confirm Transaction"
:footer="false"
width="500px"
:mask-closable="false"
:keyboard="false"
@cancel="handleCancelConfirm"
>
<div class="confirm-modal-content">
<div class="confirm-section">
<h4 class="confirm-label">Recipient Address:</h4>
<div class="confirm-value address-value" :class="{ expanded: isAddressExpanded }">
<span class="address-text">{{ displayAddress }}</span>
<button
v-if="outputAddresses.length > 40"
class="toggle-btn"
@click="toggleAddressExpand"
>
{{ isAddressExpanded ? 'Show Less' : 'Show More' }}
</button>
</div>
</div>
<div class="confirm-row">
<div class="confirm-section">
<h4 class="confirm-label">Amount:</h4>
<div class="confirm-value">{{ outputAmounts }} NPT</div>
</div>
<div class="confirm-section">
<h4 class="confirm-label">Priority Fee:</h4>
<div class="confirm-value">{{ fee }} NPT</div>
</div>
</div>
<div class="confirm-actions">
<ButtonCommon type="default" size="large" @click="handleCancelConfirm">
Cancel
</ButtonCommon>
<ButtonCommon type="primary" size="large" @click="handleConfirm">
Confirm & Send
</ButtonCommon>
</div>
</div>
</ModalCommon>
</template>
<style lang="scss" scoped>
.send-transaction-container {
padding: var(--spacing-xl);
height: 100%;
overflow-y: auto;
}
.send-transaction-form {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-label {
font-size: var(--font-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
display: flex;
justify-content: space-between;
align-items: center;
.required {
color: var(--error-color);
margin-left: 2px;
}
.balance-info {
font-size: var(--font-xs);
font-weight: var(--font-regular);
color: var(--text-secondary);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
}
.form-input,
.form-textarea {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: var(--font-base);
color: var(--text-primary);
background: var(--bg-primary);
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(66, 165, 245, 0.1);
}
&:disabled {
background: var(--bg-disabled);
cursor: not-allowed;
opacity: 0.6;
}
&.invalid {
border-color: var(--error-color);
}
}
.form-textarea {
resize: vertical;
min-height: 80px;
font-family: 'Courier New', monospace;
line-height: 1.5;
&::placeholder {
font-family: var(--font-primary);
}
}
.error-message {
font-size: var(--font-xs);
color: var(--error-color);
margin-top: -4px;
}
.amount-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.amount-field,
.priority-fee-field {
flex: 1;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
@include screen(mobile) {
.amount-row {
grid-template-columns: 1fr;
}
}
.confirm-modal-content {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.confirm-section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.confirm-label {
font-size: var(--font-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin: 0;
}
.confirm-value {
font-size: var(--font-base);
font-weight: var(--font-semibold);
color: var(--text-primary);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
word-break: break-all;
}
.address-value {
font-family: 'Courier New', monospace;
font-size: var(--font-sm);
line-height: 1.6;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
position: relative;
.address-text {
word-break: break-all;
transition: all 0.3s ease;
}
.toggle-btn {
align-self: flex-end;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--font-medium);
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font-primary);
&:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
.confirm-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
@include screen(mobile) {
.confirm-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { message } from 'ant-design-vue'
import { useNeptuneStore } from '@/stores/neptuneStore'
const neptuneStore = useNeptuneStore()
const receiveAddress = computed(() => neptuneStore.getWallet?.address || '')
const copyAddress = async () => {
if (!receiveAddress.value) {
message.error('No address available')
return
}
try {
await navigator.clipboard.writeText(receiveAddress.value)
message.success('Address copied to clipboard!')
} catch (err) {
message.error('Failed to copy address')
}
}
</script>
<template>
<div v-if="!receiveAddress" class="empty-state">
<p>No address available</p>
</div>
<div v-else class="receive-section">
<div class="address-label">Receive Address:</div>
<div class="address-value" @click="copyAddress">
<span class="address-text">
{{ receiveAddress }}
</span>
<svg
class="copy-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
</div>
</template>
<style lang="scss" scoped>
.empty-state {
text-align: center;
padding: var(--spacing-lg);
color: var(--text-secondary);
p {
margin: 0;
font-size: var(--font-base);
}
}
.receive-section {
margin-bottom: var(--spacing-lg);
flex-shrink: 0;
.address-label {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
font-weight: var(--font-semibold);
}
.address-value {
background: var(--bg-light);
padding: var(--spacing-lg);
border-radius: var(--radius-md);
word-break: break-all;
font-family: var(--font-mono);
font-size: var(--font-sm);
color: var(--primary-color);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-sm);
border: 2px solid transparent;
line-height: 1.5;
position: relative;
height: 120px;
overflow: hidden;
.address-text {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
max-height: 100%;
padding-right: var(--spacing-xs);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
&:hover {
background: var(--text-muted);
}
}
}
&:hover {
background: var(--bg-hover);
border-color: var(--border-primary);
}
.copy-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--primary-color);
margin-top: 2px;
align-self: flex-start;
}
}
}
</style>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
interface Props {
isLoadingData: boolean
availableBalance: string
pendingBalance: string
}
const props = defineProps<Props>()
</script>
<template>
<div class="balance-section">
<div class="balance-label">Available</div>
<div class="balance-amount">
<span v-if="props.isLoadingData"><SpinnerCommon size="medium" /></span>
<span v-else>{{ props.availableBalance }} NPT</span>
</div>
<div class="pending-section">
<span class="pending-label">Pending</span>
<span class="pending-amount">
{{ props.isLoadingData ? '...' : props.pendingBalance }}
NPT
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.balance-section {
text-align: center;
margin-bottom: var(--spacing-xl);
padding-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--border-color);
flex-shrink: 0;
.balance-label {
color: var(--text-muted);
font-size: var(--font-base);
margin-bottom: var(--spacing-sm);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
.balance-amount {
font-size: var(--font-4xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
letter-spacing: var(--tracking-tight);
}
.pending-section {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
color: var(--text-secondary);
font-size: var(--font-md);
.pending-label {
font-weight: var(--font-medium);
}
.pending-amount {
font-weight: var(--font-semibold);
}
}
}
</style>

View File

@ -1,239 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { formatNumberToLocaleString } from '@/utils'
import { useNeptuneStore } from '@/stores/neptuneStore'
import { SpinnerCommon } from '@/components'
interface Props {
isLoadingData: boolean
availableBalance: number
pendingBalance: number
}
const props = defineProps<Props>()
const neptuneStore = useNeptuneStore()
const receiveAddress = computed(() => neptuneStore.getWallet?.address || '')
const isAddressExpanded = ref(false)
const toggleAddressExpanded = () => {
isAddressExpanded.value = !isAddressExpanded.value
}
const copyAddress = async () => {
if (!receiveAddress.value) {
message.error('No address available')
return
}
try {
await navigator.clipboard.writeText(receiveAddress.value)
message.success('Address copied to clipboard!')
} catch (err) {
message.error('Failed to copy address')
}
}
</script>
<template>
<div v-if="props.isLoadingData && !receiveAddress" class="loading-state">
<SpinnerCommon size="medium" />
<p>Loading wallet data...</p>
</div>
<div v-else-if="!receiveAddress" class="empty-state">
<p>No wallet found. Please create or import a wallet.</p>
</div>
<div v-else>
<!-- Balance Section -->
<div class="balance-section">
<div class="balance-label">Available</div>
<div class="balance-amount">
<span v-if="props.isLoadingData">Loading...</span>
<span v-else>{{ formatNumberToLocaleString(props.availableBalance) }} NPT</span>
</div>
<div class="pending-section">
<span class="pending-label">Pending</span>
<span class="pending-amount">
{{ props.isLoadingData ? '...' : formatNumberToLocaleString(props.pendingBalance) }}
NPT
</span>
</div>
</div>
<!-- Receive Address Section -->
<div class="receive-section">
<div class="address-label">Receive Address:</div>
<div
class="address-value"
:class="{ expanded: isAddressExpanded, collapsed: !isAddressExpanded }"
@click="copyAddress"
>
<span class="address-text">
{{ receiveAddress || 'No address available' }}
</span>
<svg
class="copy-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
<button
v-if="receiveAddress && receiveAddress.length > 80"
class="toggle-address-btn"
@click.stop="toggleAddressExpanded"
>
{{ isAddressExpanded ? 'Show less' : 'Show more' }}
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.loading-state,
.empty-state {
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-secondary);
p {
margin: var(--spacing-lg) 0 0;
font-size: var(--font-base);
}
}
.balance-section {
text-align: center;
margin-bottom: var(--spacing-xl);
padding-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--border-color);
flex-shrink: 0;
.balance-label {
color: var(--text-muted);
font-size: var(--font-base);
margin-bottom: var(--spacing-sm);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
.balance-amount {
font-size: var(--font-4xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
letter-spacing: var(--tracking-tight);
}
.pending-section {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
color: var(--text-secondary);
font-size: var(--font-md);
.pending-label {
font-weight: var(--font-medium);
}
.pending-amount {
font-weight: var(--font-semibold);
}
}
}
.receive-section {
margin-bottom: var(--spacing-lg);
flex-shrink: 0;
.address-label {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
font-weight: var(--font-semibold);
}
.address-value {
background: var(--bg-light);
padding: var(--spacing-lg);
border-radius: var(--radius-md);
word-break: break-all;
font-family: var(--font-mono);
font-size: var(--font-sm);
color: var(--primary-color);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-sm);
border: 2px solid transparent;
line-height: 1.5;
position: relative;
.address-text {
flex: 1;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
&.collapsed .address-text {
-webkit-line-clamp: 2;
line-clamp: 2;
}
&.expanded .address-text {
display: block;
overflow: visible;
text-overflow: initial;
}
&:hover {
background: var(--bg-hover);
border-color: var(--border-primary);
}
.copy-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--primary-color);
margin-top: 2px;
align-self: flex-start;
}
}
.toggle-address-btn {
margin-top: var(--spacing-md);
padding: var(--spacing-xs) var(--spacing-md);
background: none;
border: none;
color: var(--primary-color);
font-size: var(--font-sm);
font-weight: var(--font-medium);
cursor: pointer;
text-decoration: underline;
transition: color 0.2s ease;
&:hover {
color: var(--primary-hover);
}
&:active {
opacity: 0.8;
}
}
}
</style>

View File

@ -1,26 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Modal } from 'ant-design-vue'
import { useNeptuneStore } from '@/stores/neptuneStore' import { useNeptuneStore } from '@/stores/neptuneStore'
import { useNeptuneWallet } from '@/composables/useNeptuneWallet' import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { ButtonCommon, CardBaseScrollable } from '@/components' import { ButtonCommon, CardBaseScrollable, ModalCommon, SpinnerCommon, PasswordForm } from '@/components'
import SeedPhraseDisplayComponent from '@/views/Auth/components/SeedPhraseDisplayComponent.vue' import SeedPhraseDisplayComponent from '@/views/Auth/components/SeedPhraseDisplayComponent.vue'
import WalletBalanceAndAddress from './WalletBalanceAndAddress.vue' import SendTransactionComponent from './SendTransactionComponent.vue'
import { WalletAddress, WalletBalance } from '.'
import type { Utxo } from '@/interface'
const neptuneStore = useNeptuneStore() const neptuneStore = useNeptuneStore()
const { getBalance, saveKeystoreAs } = useNeptuneWallet() const {
getBalance,
saveKeystoreAs,
buildTransactionWithPrimitiveProof,
broadcastSignedTransaction,
decryptKeystore,
} = useNeptuneWallet()
const availableBalance = ref(0) const availableBalance = ref<string>('0.00000000')
const pendingBalance = ref(0) const pendingBalance = ref<string>('0.00000000')
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const showSeedModal = ref(false) const showSeedModal = ref(false)
const isSendingMode = ref(false)
const getModalContainer = (): HTMLElement => { const isVerifyingPassword = ref(false)
const homeContainer = document.querySelector('.home-container') as HTMLElement const passwordError = ref(false)
return homeContainer || document.body const isVerifying = ref(false)
}
const receiveAddress = computed(() => neptuneStore.getWallet?.address || '') const receiveAddress = computed(() => neptuneStore.getWallet?.address || '')
@ -44,7 +50,47 @@ const handleResize = () => {
} }
const handleClickSendButton = () => { const handleClickSendButton = () => {
// TODO: Implement send transaction functionality isSendingMode.value = true
}
const handleCancelSend = () => {
isSendingMode.value = false
}
const handleSendTransaction = async (data: {
outputAddresses: string
outputAmounts: string
fee: string
}) => {
try {
loading.value = true
const payload = {
spendingKeyHex: neptuneStore.getSpendingKey || '',
inputAdditionRecords: neptuneStore.getUtxos?.map((utxo: Utxo) => utxo.additionRecord),
outputAddresses: [data.outputAddresses],
outputAmounts: [data.outputAmounts],
fee: data.fee,
}
const result = await buildTransactionWithPrimitiveProof(payload)
if (!result.success) {
message.error('Failed to build transaction')
return
}
const broadcastResult = await broadcastSignedTransaction(result.transaction)
if (!broadcastResult.success) {
message.error('Failed to broadcast transaction')
return
}
message.success('Transaction sent successfully!')
handleCancelSend()
await loadWalletData()
} catch (error) {
message.error('Failed to send transaction')
} finally {
loading.value = false
}
} }
const handleBackupFile = async () => { const handleBackupFile = async () => {
@ -66,17 +112,34 @@ const handleBackupFile = async () => {
} }
const handleBackupSeed = () => { const handleBackupSeed = () => {
isVerifyingPassword.value = true
}
const handleCancelVerify = () => {
isVerifyingPassword.value = false
passwordError.value = false
}
const handlePasswordVerify = async (password: string) => {
try {
isVerifying.value = true
passwordError.value = false
await decryptKeystore(password)
isVerifyingPassword.value = false
showSeedModal.value = true showSeedModal.value = true
} catch (err) {
passwordError.value = true
} finally {
isVerifying.value = false
}
} }
const handleCloseModal = () => { const handleCloseModal = () => {
showSeedModal.value = false showSeedModal.value = false
} }
const handleModalNext = () => {
handleCloseModal()
}
const loadWalletData = async () => { const loadWalletData = async () => {
const receiveAddress = neptuneStore.getWallet?.address || '' const receiveAddress = neptuneStore.getWallet?.address || ''
if (!receiveAddress) return if (!receiveAddress) return
@ -86,8 +149,8 @@ const loadWalletData = async () => {
try { try {
const result = await getBalance() const result = await getBalance()
availableBalance.value = +result.confirmedAvailable || 0 availableBalance.value = result?.balance || result || '0.00000000'
pendingBalance.value = +result.unconfirmedAvailable || 0 pendingBalance.value = result?.pendingBalance || result || '0.00000000'
} catch (error) { } catch (error) {
message.error('Failed to load wallet data') message.error('Failed to load wallet data')
} finally { } finally {
@ -107,15 +170,21 @@ onUnmounted(() => {
<template> <template>
<CardBaseScrollable class="wallet-info-container"> <CardBaseScrollable class="wallet-info-container">
<div class="wallet-content"> <!-- Normal Wallet View -->
<WalletBalanceAndAddress <div v-if="!isSendingMode && !isVerifyingPassword" class="wallet-content">
<div class="balance-wrapper">
<WalletBalance
:is-loading-data="loading" :is-loading-data="loading"
:available-balance="availableBalance" :available-balance="availableBalance"
:pending-balance="pendingBalance" :pending-balance="pendingBalance"
/> />
</div>
<!-- Action Buttons --> <div v-if="receiveAddress" class="address-actions-row">
<div v-if="receiveAddress" class="action-buttons"> <div class="address-wrapper">
<WalletAddress />
</div>
<div class="action-buttons">
<ButtonCommon <ButtonCommon
type="primary" type="primary"
size="large" size="large"
@ -132,33 +201,67 @@ onUnmounted(() => {
Backup Seed Backup Seed
</ButtonCommon> </ButtonCommon>
</div> </div>
</div>
</div>
<!-- Wallet Status --> <!-- Password Verification View -->
<div v-if="receiveAddress" class="wallet-status"> <div v-else-if="isVerifyingPassword" class="password-verify-wrapper">
<div class="password-verify-content">
<div class="verify-header">
<h2 class="verify-title">Verify Password</h2>
<p class="verify-subtitle">Enter your password to view seed phrase</p>
</div>
<PasswordForm
button-text="Verify"
placeholder="Enter your password"
back-button-text="Cancel"
label="Password"
:loading="isVerifying"
:error="passwordError"
error-message="Invalid password"
@submit="handlePasswordVerify"
@back="handleCancelVerify"
/>
</div>
</div>
<!-- Send Transaction View -->
<div v-else-if="isSendingMode" class="send-transaction-wrapper">
<SendTransactionComponent
:is-loading="loading"
:available-balance="availableBalance"
@cancel="handleCancelSend"
@send="handleSendTransaction"
/>
<!-- Loading Overlay -->
<div v-if="loading" class="sending-overlay">
<div class="sending-content">
<SpinnerCommon size="large" />
<p class="sending-text">Sending...</p>
</div>
</div>
</div>
<div v-if="receiveAddress && !isSendingMode && !isVerifyingPassword" class="wallet-status">
<span <span
>Wallet Status: <strong>{{ walletStatus }}</strong></span >Wallet Status: <strong>{{ walletStatus }}</strong></span
> >
</div> </div>
</div>
</CardBaseScrollable> </CardBaseScrollable>
<Modal <ModalCommon
v-model:open="showSeedModal" v-model:open="showSeedModal"
title="Backup Seed Phrase" title="Backup Seed Phrase"
:footer="null" :footer="false"
:width="modalWidth" :width="modalWidth"
:mask-closable="false" :mask-closable="false"
:keyboard="false" :keyboard="false"
:get-container="getModalContainer"
@cancel="handleCloseModal" @cancel="handleCloseModal"
> >
<div class="seed-modal-content"> <div class="seed-modal-content">
<SeedPhraseDisplayComponent <SeedPhraseDisplayComponent :back-button="false" :next-button="false" />
:back-button="false"
:next-button-text="'DONE'"
@next="handleModalNext"
/>
</div> </div>
</Modal> </ModalCommon>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -167,15 +270,36 @@ onUnmounted(() => {
} }
.wallet-content { .wallet-content {
background: inherit; display: flex;
height: 100%; flex-direction: column;
gap: var(--spacing-xl);
flex: 1;
min-height: 0;
}
.balance-wrapper {
flex-shrink: 0;
}
.address-actions-row {
display: flex;
gap: var(--spacing-xl);
align-items: center;
flex: 1;
min-height: 0;
.address-wrapper {
flex: 1;
min-width: 0;
}
} }
.action-buttons { .action-buttons {
@include center_flex; display: flex;
flex-direction: column;
gap: var(--spacing-md); gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-shrink: 0; flex-shrink: 0;
min-width: 200px;
:deep(.btn-send) { :deep(.btn-send) {
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
@ -214,16 +338,74 @@ onUnmounted(() => {
} }
} }
// Modal responsive width .password-verify-wrapper {
:deep(.ant-modal) { display: flex;
@media (max-width: 767px) { align-items: center;
width: 90% !important; justify-content: center;
max-width: 90% !important; flex: 1;
min-height: 0;
padding: var(--spacing-xl);
} }
@media (min-width: 768px) { .password-verify-content {
width: 60% !important; width: 100%;
max-width: 60% !important; max-width: 480px;
}
.verify-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
.verify-title {
font-size: var(--font-2xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 var(--spacing-xs) 0;
}
.verify-subtitle {
font-size: var(--font-sm);
color: var(--text-secondary);
margin: 0;
}
}
.send-transaction-wrapper {
position: relative;
flex: 1;
min-height: 0;
}
.sending-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: var(--radius-md);
.sending-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-2xl);
background: rgba(255, 255, 255, 0.95);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
.sending-text {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
} }
} }
</style> </style>

View File

@ -1,3 +1,4 @@
export { default as WalletInfo } from './WalletInfo.vue' export { default as WalletInfo } from './WalletInfo.vue'
export { default as WalletBalanceAndAddress } from './WalletBalanceAndAddress.vue' export { default as WalletBalance } from './WalletBalance.vue'
export { default as WalletAddress } from './WalletAddress.vue'
export { default as WalletTab } from './WalletTab.vue' export { default as WalletTab } from './WalletTab.vue'

View File

@ -0,0 +1,31 @@
export const columns = [
{
title: 'UTXO Hash',
dataIndex: 'utxoHash',
key: 'utxoHash',
width: '60%',
ellipsis: true,
customRender: ({ text }: any) => {
if (!text) return 'N/A'
const str = typeof text === 'string' ? text : JSON.stringify(text)
return str.length > 20 ? `${str.slice(0, 5)}...${str.slice(-5)}` : str
},
},
{
title: 'Amount (NPT)',
dataIndex: 'amount',
key: 'amount',
width: '20%',
customRender: ({ record }: any) => {
const amount = record.amount || record.value || 0
return parseFloat(amount).toFixed(8)
},
},
{
title: 'Block Height',
dataIndex: 'blockHeight',
key: 'blockHeight',
width: '20%',
customRender: ({ text }: any) => text || 'N/A',
},
]

View File

@ -0,0 +1 @@
export * from './columns'

View File

@ -3,19 +3,29 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import VueDevTools from 'vite-plugin-vue-devtools' import VueDevTools from 'vite-plugin-vue-devtools'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({ export default defineConfig({
server: { server: {
port: 3008, port: 3008,
}, },
base: './', base: './',
plugins: [vue(), vueJsx(), VueDevTools()], plugins: [vue(), vueJsx(), VueDevTools(), Components({
resolvers: [AntDesignVueResolver({importStyle: false})],
})],
optimizeDeps: { optimizeDeps: {
exclude: ['@neptune/wasm'], exclude: ['@neptune/wasm', '@neptune/native'],
},
build: {
rollupOptions: {
external: ['@neptune/wasm', '@neptune/native'],
},
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
silenceDeprecations: ['import'],
additionalData: ` additionalData: `
@import "@/assets/scss/__variables.scss"; @import "@/assets/scss/__variables.scss";
@import "@/assets/scss/__mixin.scss"; @import "@/assets/scss/__mixin.scss";