diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..ff3e288b323884fdf22dfbb073b29cd767254099 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,42 @@ +# Copilot Instructions for This Nuxt Repository + +## Code Structure +- Prefer imports from absolute paths using `@`: `@/components/...`, over relative paths. +- Break pages and components into small, reusable units whenever possible. + - **Max page size:** 400 LOC. + - Follow **Single Responsibility Principle** for all components. +- Avoid trailing spaces and maintain consistent formatting (use project ESLint/Prettier rules). + +## Error Handling & Logging +- **Never** use `console.*` for logging, debugging, or error reporting. +- Always use `toastError(message, error)` to surface errors to the user. + +## Loading States +- When components or pages depend on async data, include **Skeleton components** whenever possible. + - Skeletons should be lightweight, reusable, and reflect the expected layout. + +## Architecture & Data Flow +- **HTM/ctokens** is a separate domain and **not** directly tied to the wallet-dapp. Keep abstractions clean. +- Always use the **store** (Pinia/Vuex) and **mediating interfaces/services** for API communication. + - APIs may change - components should never call APIs directly. + - Prefer strongly typed interfaces for request/response modeling. + - Prefer defining Pinia stores as `state`, `getters`, and `actions` (avoid using `defineStore` with only setup functions). + +## UI & Styling +- Maintain consistent component naming and directory structure. Add directories as needed. +- Avoid excessive inline styles; prefer Tailwind CSS utility classes or component-scoped styles. +- If required, install Shadcn UI components via `pnpm dlx shadcn-vue@latest add ` and import them properly. + +## Project Documentation +- **Do not** create markdown files for documentation, architecture, reference, summary or instructions. +- Write self-documenting code; add comments only where intent is not obvious. + +## Code formatting +- Prefer arrow functions and concise syntax where applicable. + +## General Guidelines +- Prefer using `NuxtLink` for internal navigation over `` tags. External links should use `` with `target="_blank"` and `rel="noopener noreferrer"`. +- Do not repeat the code. If you notice that it is the same in a multiple files - extract the code into a separate file/component, implementing common functionality +- When using any component creating a link/navigation, ensure it has a class `keychainify-checked` to avoid hydration issues when keychain is installed. +- Prefer fallback-safe, defensive programming for external interactions (APIs, wallets, etc.). +- Keep dependencies minimal and review them regularly. diff --git a/package.json b/package.json index 01b79f7038cfcffa59eda172a98febdd9729ef15..2ba55d9d5c640d9c29622ab58a83dcdbe026c698 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@eslint/compat": "^1.3.1", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.32.0", - "@hiveio/beekeeper": "1.28.4-rc0", + "@hiveio/beekeeper": "1.28.4", "@hiveio/wax": "1.28.4-rc0-stable.251125135529", "@hiveio/wax-signers-keychain": "1.28.4-rc0-stable.251125135529", "@hiveio/wax-signers-metamask": "1.28.4-rc0-stable.251125135529", @@ -89,6 +89,7 @@ }, "dependencies": { "google-auth-library": "^10.4.2", + "html5-qrcode": "^2.3.8", "jsonwebtoken": "^9.0.2", "qrcode": "^1.5.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4969069e0e00fb8de337180cfd9a3c456f202a6e..f21618a150386d3a4de68197ac73ae63afc14d92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: google-auth-library: specifier: ^10.4.2 version: 10.4.2 + html5-qrcode: + specifier: ^2.3.8 + version: 2.3.8 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -32,8 +35,8 @@ importers: specifier: ^9.32.0 version: 9.32.0 '@hiveio/beekeeper': - specifier: 1.28.4-rc0 - version: 1.28.4-rc0 + specifier: 1.28.4 + version: 1.28.4 '@hiveio/wax': specifier: 1.28.4-rc0-stable.251125135529 version: 1.28.4-rc0-stable.251125135529 @@ -761,8 +764,8 @@ packages: '@floating-ui/vue@1.1.6': resolution: {integrity: sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A==} - '@hiveio/beekeeper@1.28.4-rc0': - resolution: {integrity: sha1-qLgaAY/l+gXAdleuIeyaMAo6wxA=, tarball: https://gitlab.syncad.com/api/v4/projects/198/packages/npm/@hiveio/beekeeper/-/@hiveio/beekeeper-1.28.4-rc0.tgz} + '@hiveio/beekeeper@1.28.4': + resolution: {integrity: sha1-X+2VqUKItQPk4Dr+t+/sfQ+84YY=, tarball: https://gitlab.syncad.com/api/v4/projects/198/packages/npm/@hiveio/beekeeper/-/@hiveio/beekeeper-1.28.4.tgz} engines: {node: ^20.11 || >= 21.2} '@hiveio/wax-signers-keychain@1.28.4-rc0-stable.251125135529': @@ -3633,6 +3636,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html5-qrcode@2.3.8: + resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==} + http-assert@1.5.0: resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} engines: {node: '>= 0.8'} @@ -6596,7 +6602,7 @@ snapshots: - '@vue/composition-api' - vue - '@hiveio/beekeeper@1.28.4-rc0': {} + '@hiveio/beekeeper@1.28.4': {} '@hiveio/wax-signers-keychain@1.28.4-rc0-stable.251125135529': dependencies: @@ -9922,6 +9928,8 @@ snapshots: hookable@5.5.3: {} + html5-qrcode@2.3.8: {} + http-assert@1.5.0: dependencies: deep-equal: 1.0.1 diff --git a/src/components/QrScanner.vue b/src/components/QrScanner.vue new file mode 100644 index 0000000000000000000000000000000000000000..394214f44f76740fe8c08f2e5c0daa9b338d94c3 --- /dev/null +++ b/src/components/QrScanner.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/src/components/ReceiveTransferCard.vue b/src/components/ReceiveTransferCard.vue index 934bdf79b10c0d8044015e369596db3e9ab61bb5..f694060276e4c7631280a2b0e01fa4016cbc8fad 100644 --- a/src/components/ReceiveTransferCard.vue +++ b/src/components/ReceiveTransferCard.vue @@ -2,23 +2,34 @@ import type { htm_operation } from '@mtyszczak-cargo/htm'; import { computed, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; -import { toast } from 'vue-sonner'; import CollapsibleMemoInput from '@/components/CollapsibleMemoInput.vue'; import { TokenAmountInput } from '@/components/htm/amount'; +import QrScanner from '@/components/QrScanner.vue'; import ReceiverTokenSummary from '@/components/ReceiverTokenSummary.vue'; import TransferCompletedSummary from '@/components/TransferCompletedSummary.vue'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; import { useTokensStore, type CTokenDisplayBase } from '@/stores/tokens.store'; +import { useWalletStore } from '@/stores/wallet.store'; import { toastError } from '@/utils/parse-error'; import { waitForTransactionStatus } from '@/utils/transaction-status'; import CTokensProvider from '@/utils/wallet/ctokens/signer'; +import { TempCTokensSigner } from '@/utils/wallet/ctokens/temp-signer'; + +interface TransferSummary { + amount: string; + tokenLabel: string; + senderPublicKey: string; + receiver: string; + receiverPublicKey: string; + remainingBalance?: string; + timestamp?: string; +} interface Props { - receiverName?: string; receiverKey?: string; - receiverAvatar?: string; tokenData: CTokenDisplayBase | undefined; queryAmount?: string; queryMemo?: string; @@ -27,15 +38,47 @@ interface Props { const props = defineProps(); +const senderPublicKey = ref(''); + +const receiverName = ref(''); +const receiverAvatar = ref(undefined); + +const fetchUserMetadata = async () => { + try { + if (!props.receiverKey) return; + + const { name, profileImage } = await tokensStore.getUser(props.receiverKey); + receiverName.value = name || ''; + receiverAvatar.value = profileImage; + } catch (error) { + toastError('Failed to load receiver metadata', error); + } +}; + const emit = defineEmits<{ - transferCompleted: [summary: { amount: string; tokenLabel: string; receiver: string; remainingBalance?: string; timestamp?: string }]; + transferCompleted: [summary: TransferSummary]; }>(); -const router = useRouter(); const tokensStore = useTokensStore(); +const walletStore = useWalletStore(); const tokenComputed = computed(() => props.tokenData); -const amountComputed = computed(() => props.queryAmount ? props.tokenData ? (props.queryAmount.slice(0, -props.tokenData.precision) + '.' + props.queryAmount.slice(-props.tokenData.precision)) : '' : ''); +const amountComputed = computed(() => { + if (props.tokenData === undefined || props.queryAmount === undefined) + return ''; + + if (props.tokenData.precision === 0) + return props.queryAmount || ''; + + return ((props.queryAmount.slice(0, -props.tokenData.precision) || '0') + '.' + props.queryAmount.slice(-props.tokenData.precision)); +}); + +// QR code signing state +const showQrScanner = ref(false); +const scannedPrivateKey = ref(null); +const tempSigner = shallowRef(undefined); + +const router = useRouter(); // Form state const form = ref({ @@ -44,14 +87,39 @@ const form = ref({ memo: props.queryMemo || '' }); - const isSending = ref(false); const transferCompleted = ref(false); -const sentSummary = ref<{ amount: string; tokenLabel: string; receiver: string; remainingBalance?: string; timestamp?: string } | null>(null); +const sentSummary = ref(null); // Check if user is logged in const isLoggedIn = computed(() => !!tokensStore.wallet); +// Handle QR code scan +const handleQrScan = async (privateKey: string) => { + try { + scannedPrivateKey.value = privateKey; + + // Create a temporary signer from the scanned private key + tempSigner.value = await TempCTokensSigner.for(privateKey); + + showQrScanner.value = false; + } catch (error) { + toastError('Invalid private key from QR code', error); + scannedPrivateKey.value = null; + tempSigner.value = undefined; + } +}; + +// Clear scanned key +const clearScannedKey = () => { + scannedPrivateKey.value = null; + if (tempSigner.value) { + tempSigner.value.destroy(); + tempSigner.value = undefined; + } + senderPublicKey.value = tokensStore.getUserPublicKey() || ''; +}; + // TODO: Fix validation const isFormValid = computed(() => { @@ -66,11 +134,9 @@ const handleSend = async () => { return; } - if (!isLoggedIn.value || !CTokensProvider.getOperationalPublicKey()) { - toast.error('Please log in to your wallet first'); - router.push({ - path: '/tokens/list' - }); + // For non-logged-in users, require QR code + if (!isLoggedIn.value && !tempSigner.value) { + toastError('Please scan your private key QR code to sign the transaction', new Error('No signer available')); return; } @@ -82,7 +148,14 @@ const handleSend = async () => { try { isSending.value = true; - const sender = CTokensProvider.getOperationalPublicKey()!; + // Determine the sender's public key + const sender = tempSigner.value + ? tempSigner.value.publicKey + : tokensStore.getUserPublicKey(); + + if (!sender) + throw new Error('Could not determine sender public key'); + await waitForTransactionStatus( () => ([{ @@ -97,14 +170,21 @@ const handleSend = async () => { memo: form.value.memo } } satisfies htm_operation]), - 'Transfer' + 'Transfer', + true, + tempSigner.value // Use temp signer if available ); transferCompleted.value = true; - const balanceObj = await tokensStore.getBalanceSingleToken(sender, form.value.token.assetNum); + let remainingBalance = `0 ${form.value.token.symbol}`; + + try { + const balanceObj = await tokensStore.getBalanceSingleToken(sender, form.value.token.assetNum); + remainingBalance = balanceObj.displayBalance; + } catch {} - const receiverLabel = props.receiverName || props.receiverKey || 'Recipient'; + const receiverLabel = receiverName.value || props.receiverKey || 'Recipient'; const tokenLabel = form.value.token.symbol || form.value.token.name || String(form.value.token.assetNum) || ''; const ts = new Date().toISOString(); @@ -112,11 +192,16 @@ const handleSend = async () => { sentSummary.value = { amount: form.value.amount, tokenLabel, + senderPublicKey: senderPublicKey.value, receiver: receiverLabel, - remainingBalance: balanceObj.displayBalance, + receiverPublicKey: props.receiverKey!, + remainingBalance, timestamp: ts }; + // Clear scanned key after successful transfer + clearScannedKey(); + emit('transferCompleted', sentSummary.value); } catch (error) { toastError('Transfer failed', error); @@ -125,6 +210,14 @@ const handleSend = async () => { } }; +const conditionalLogin = async () => { + if (await CTokensProvider.hasWallet()) + walletStore.isProvideWalletPasswordModalOpen = true; + else + router.push({ path: '/tokens/register-account' }); + +}; + // Watch query params changes to update form watch(() => props.queryAmount, (newValue) => { if (newValue) @@ -140,6 +233,17 @@ watch(() => props.queryMemo, (newValue) => { if (newValue) form.value.memo = newValue; }); + +watch(isLoggedIn, (newValue) => { + const key = tokensStore.getUserPublicKey(); + senderPublicKey.value = newValue && key ? key : ''; +}); + +onMounted(() => { + fetchUserMetadata(); + + senderPublicKey.value = tokensStore.getUserPublicKey() || ''; +}); diff --git a/src/components/SendTransferCard.vue b/src/components/SendTransferCard.vue index c4747a231317ecdf009cc43cf75d6fb9ee9cb959..1be3def3dbdad6c6e8aa71e48fa27ce3f85e602a 100644 --- a/src/components/SendTransferCard.vue +++ b/src/components/SendTransferCard.vue @@ -4,9 +4,7 @@ import { computed, ref, watch } from 'vue'; import CollapsibleMemoInput from '@/components/CollapsibleMemoInput.vue'; import { TokenAmountInput } from '@/components/htm/amount'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import type { CTokenDisplayBase } from '@/stores/tokens.store'; -import CTokensProvider from '@/utils/wallet/ctokens/signer'; - +import { useTokensStore, type CTokenDisplayBase } from '@/stores/tokens.store'; interface Props { token?: CTokenDisplayBase; @@ -22,6 +20,8 @@ const emit = defineEmits<{ updateMemo: [value: string]; }>(); +const tokensStore = useTokensStore(); + // Form state const form = ref({ amount: props.initialAmount || '', @@ -35,7 +35,7 @@ const selectedToken = computed({ } }); -const userOperationalKey = computed(() => CTokensProvider.getOperationalPublicKey()); +const userOperationalKey = computed(() => tokensStore.getUserPublicKey()); // Watch form changes to emit updates watch(() => form.value.amount, (newValue) => { diff --git a/src/components/TransferCompletedSummary.vue b/src/components/TransferCompletedSummary.vue index c01d31017d00c9ef1380584e2015ca99d9bc6ca3..33f68f52a96eed6edbd5d416bfc91f9c4f85bb69 100644 --- a/src/components/TransferCompletedSummary.vue +++ b/src/components/TransferCompletedSummary.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import CTokensProvider from '@/utils/wallet/ctokens/signer'; +import { useTokensStore } from '@/stores/tokens.store'; interface Props { amount: string; @@ -23,10 +23,14 @@ const props = defineProps(); const router = useRouter(); +const tokensStore = useTokensStore(); + const generateInvoice = () => { + const pk = tokensStore.getUserPublicKey(); + const params = new URLSearchParams({ - fromPk: props.isReceiveMode ? CTokensProvider.getOperationalPublicKey() || '' : CTokensProvider.getOperationalPublicKey() || '', - toPk: props.isReceiveMode ? props.receiverKey || '' : CTokensProvider.getOperationalPublicKey() || '', + fromPk: pk || '', + toPk: props.isReceiveMode ? props.receiverKey || '' : pk || '', 'asset-num': props.assetNum.toString(), amount: props.amount }); diff --git a/src/components/htm/HTMLoginContent.vue b/src/components/htm/HTMLoginContent.vue index 72fdb0edf7cad121e9a88922c103783b2903ac76..a012044dc840cfe36e9af304a30fa3cd16d0e578 100644 --- a/src/components/htm/HTMLoginContent.vue +++ b/src/components/htm/HTMLoginContent.vue @@ -1,10 +1,12 @@ + + diff --git a/src/components/htm/HTMProvidePasswordContent.vue b/src/components/htm/HTMProvidePasswordContent.vue index 4651609d249516e0a98365fb5480476875f2a643..2a7b55a38fb645497b02264f5152942fa7860b19 100644 --- a/src/components/htm/HTMProvidePasswordContent.vue +++ b/src/components/htm/HTMProvidePasswordContent.vue @@ -1,5 +1,5 @@