diff --git a/web-app/src/components/NotificationBell.vue b/web-app/src/components/NotificationBell.vue new file mode 100644 index 0000000..0c39726 --- /dev/null +++ b/web-app/src/components/NotificationBell.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/web-app/src/components/collapse/CollapseHistory.vue b/web-app/src/components/collapse/CollapseHistory.vue index 043e9e1..4d72395 100644 --- a/web-app/src/components/collapse/CollapseHistory.vue +++ b/web-app/src/components/collapse/CollapseHistory.vue @@ -2,17 +2,23 @@ -
+
Tax paid{{ props.taxPaid }} $KRK @@ -21,6 +27,15 @@ Profit{{ profit }} $KRK
+
@@ -28,9 +43,12 @@ diff --git a/web-app/src/components/layouts/NavbarHeader.vue b/web-app/src/components/layouts/NavbarHeader.vue index fe945af..230517f 100644 --- a/web-app/src/components/layouts/NavbarHeader.vue +++ b/web-app/src/components/layouts/NavbarHeader.vue @@ -24,6 +24,7 @@
+ avatar @@ -40,6 +41,7 @@ import IconLogin from '@/components/icons/IconLogin.vue'; // import ThemeToggle from "@/components/layouts/ThemeToggle.vue"; import NetworkChanger from '@/components/layouts/NetworkChanger.vue'; import ConnectButton from '@/components/layouts/ConnectButton.vue'; +import NotificationBell from '@/components/NotificationBell.vue'; import { inject, ref } from 'vue'; import { useAccount } from '@wagmi/vue'; import { useDark } from '@/composables/useDark'; diff --git a/web-app/src/composables/usePositions.ts b/web-app/src/composables/usePositions.ts index 1d5315f..9855c84 100644 --- a/web-app/src/composables/usePositions.ts +++ b/web-app/src/composables/usePositions.ts @@ -81,6 +81,8 @@ export interface Position { harbDeposit: bigint; iAmOwner: boolean; snatched: number; + closedAt?: string; + payout?: string; } const myClosedPositions: ComputedRef = computed(() => { @@ -208,6 +210,7 @@ export async function loadMyClosedPositions(chainId: number, endpointOverride: s totalSupplyEnd totalSupplyInit creationTime + closedAt } } }`, diff --git a/web-app/src/composables/useSnatchNotifications.ts b/web-app/src/composables/useSnatchNotifications.ts new file mode 100644 index 0000000..62afee4 --- /dev/null +++ b/web-app/src/composables/useSnatchNotifications.ts @@ -0,0 +1,167 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue'; +import axios from 'axios'; +import { getAccount, watchAccount } from '@wagmi/core'; +import type { WatchAccountReturnType } from '@wagmi/core'; +import { config } from '@/wagmi'; +import type { Config } from '@wagmi/core'; +import { DEFAULT_CHAIN_ID } from '@/config'; +import { resolveGraphqlEndpoint } from '@/utils/graphqlRetry'; +import logger from '@/utils/logger'; + +export interface SnatchEvent { + id: string; + timestamp: string; + tokenAmount: string; + ethAmount: string; + txHash: string; +} + +const GRAPHQL_TIMEOUT_MS = 15_000; +const STORAGE_KEY_PREFIX = 'snatch-seen-'; + +const unseenSnatches = ref([]); +const allRecentSnatches = ref([]); +const isOpen = ref(false); + +let unwatchAccount: WatchAccountReturnType | null = null; +let consumerCount = 0; + +function storageKey(address: string): string { + return `${STORAGE_KEY_PREFIX}${address.toLowerCase()}`; +} + +function getLastSeen(address: string): string { + return localStorage.getItem(storageKey(address)) ?? '0'; +} + +function setLastSeen(address: string, timestamp: string): void { + localStorage.setItem(storageKey(address), timestamp); +} + +async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise { + const holder = address.toLowerCase(); + const whereClause = + since !== null + ? `{ holder: "${holder}", type: "snatch_out", timestamp_gt: "${since}" }` + : `{ holder: "${holder}", type: "snatch_out" }`; + const res = await axios.post( + endpoint, + { + query: `query SnatchEvents { + transactionss( + where: ${whereClause} + orderBy: "timestamp" + orderDirection: "desc" + limit: 20 + ) { + items { id timestamp tokenAmount ethAmount txHash } + } + }`, + }, + { timeout: GRAPHQL_TIMEOUT_MS } + ); + + const errors = res.data?.errors; + if (Array.isArray(errors) && errors.length > 0) { + logger.info('useSnatchNotifications GraphQL errors', errors); + return []; + } + return (res.data?.data?.transactionss?.items ?? []) as SnatchEvent[]; +} + +async function refresh(chainId: number): Promise { + const account = getAccount(config as Config); + if (!account.address) { + unseenSnatches.value = []; + allRecentSnatches.value = []; + return; + } + + let endpoint: string; + try { + endpoint = resolveGraphqlEndpoint(chainId); + } catch { + unseenSnatches.value = []; + allRecentSnatches.value = []; + return; + } + + try { + const since = getLastSeen(account.address); + const [unseen, all] = await Promise.all([ + fetchSnatchEvents(endpoint, account.address, since), + fetchSnatchEvents(endpoint, account.address, null), + ]); + unseenSnatches.value = unseen; + allRecentSnatches.value = all; + } catch (err) { + logger.info('useSnatchNotifications fetch failed', err); + } +} + +function markSeen(): void { + const account = getAccount(config as Config); + if (!account.address) return; + + // Advance last-seen to the most recent event timestamp + const latestTs = allRecentSnatches.value[0]?.timestamp ?? unseenSnatches.value[0]?.timestamp; + if (latestTs) { + setLastSeen(account.address, latestTs); + } + // Clear badge; allRecentSnatches stays populated so the panel still shows history + unseenSnatches.value = []; +} + +export function useSnatchNotifications(chainId: number = DEFAULT_CHAIN_ID) { + const unseenCount = computed(() => unseenSnatches.value.length); + + function open(): void { + isOpen.value = true; + markSeen(); + } + + function close(): void { + isOpen.value = false; + } + + function toggle(): void { + if (isOpen.value) { + close(); + } else { + open(); + } + } + + onMounted(async () => { + consumerCount += 1; + + if (consumerCount === 1) { + unwatchAccount = watchAccount(config as Config, { + async onChange() { + await refresh(chainId); + }, + }); + } + + await refresh(chainId); + }); + + onUnmounted(() => { + consumerCount = Math.max(0, consumerCount - 1); + if (consumerCount === 0 && unwatchAccount) { + unwatchAccount(); + unwatchAccount = null; + } + }); + + return { + unseenCount, + unseenSnatches, + allRecentSnatches, + isOpen, + open, + close, + toggle, + refresh: () => refresh(chainId), + }; +} diff --git a/web-app/src/utils/helper.ts b/web-app/src/utils/helper.ts index 7fde6c4..50cc08a 100644 --- a/web-app/src/utils/helper.ts +++ b/web-app/src/utils/helper.ts @@ -46,6 +46,28 @@ export function InsertCommaNumber(number: number) { return formattedWithOptions; } +export function relativeTime(timestamp: string | number): string { + try { + const sec = typeof timestamp === 'string' ? Number(timestamp) : timestamp; + const nowSec = Math.floor(Date.now() / 1000); + const diff = nowSec - sec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; + } catch { + return ''; + } +} + +export function formatTokenAmount(raw: string, decimals: number = 18, digits: number = 4): string { + try { + return Number(formatUnits(BigInt(raw), decimals)).toFixed(digits); + } catch { + return '?'; + } +} + export function formatBigIntDivision(nominator: bigint, denominator: bigint, _digits: number = 2) { if (!nominator) { return 0; diff --git a/web-app/src/views/StakeView.vue b/web-app/src/views/StakeView.vue index 7e7e930..1401ff4 100644 --- a/web-app/src/views/StakeView.vue +++ b/web-app/src/views/StakeView.vue @@ -70,6 +70,9 @@

Active Positions

+
+ No active positions — your position history is below. +
+
+

Position History

+
+ +
+