diff --git a/web-app/src/components/NotificationBell.vue b/web-app/src/components/NotificationBell.vue
new file mode 100644
index 0000000..816b587
--- /dev/null
+++ b/web-app/src/components/NotificationBell.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
No snatch events found.
+
+
⚡
+
+
Position snatched
+
+ {{ formatKrk(event.tokenAmount) }} $KRK
+ {{ relativeTime(event.timestamp) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web-app/src/components/collapse/CollapseHistory.vue b/web-app/src/components/collapse/CollapseHistory.vue
index 043e9e1..ba5a20e 100644
--- a/web-app/src/components/collapse/CollapseHistory.vue
+++ b/web-app/src/components/collapse/CollapseHistory.vue
@@ -2,13 +2,19 @@
@@ -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 @@
+
@@ -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..85ce1dd
--- /dev/null
+++ b/web-app/src/composables/useSnatchNotifications.ts
@@ -0,0 +1,164 @@
+import { ref, computed, onMounted, onUnmounted, watch } 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 fetchUnseenSnatches(chainId: number, address: string): Promise {
+ const since = getLastSeen(address);
+ let endpoint: string;
+ try {
+ endpoint = resolveGraphqlEndpoint(chainId);
+ } catch {
+ unseenSnatches.value = [];
+ allRecentSnatches.value = [];
+ return;
+ }
+
+ try {
+ const res = await axios.post(
+ endpoint,
+ {
+ query: `query UnseenSnatches($holder: String!, $since: BigInt!) {
+ transactionss(
+ where: { holder: $holder, type: "snatch_out", timestamp_gt: $since }
+ orderBy: "timestamp"
+ orderDirection: "desc"
+ limit: 20
+ ) {
+ items { id timestamp tokenAmount ethAmount txHash }
+ }
+ }`,
+ variables: { holder: address.toLowerCase(), since },
+ },
+ { timeout: GRAPHQL_TIMEOUT_MS }
+ );
+
+ const errors = res.data?.errors;
+ if (Array.isArray(errors) && errors.length > 0) {
+ logger.info('useSnatchNotifications GraphQL errors', errors);
+ return;
+ }
+
+ const items: SnatchEvent[] = res.data?.data?.transactionss?.items ?? [];
+ unseenSnatches.value = items;
+ allRecentSnatches.value = items;
+ } catch (err) {
+ logger.info('useSnatchNotifications fetch failed', err);
+ }
+}
+
+async function refresh(chainId: number): Promise {
+ const account = getAccount(config as Config);
+ if (!account.address) {
+ unseenSnatches.value = [];
+ allRecentSnatches.value = [];
+ return;
+ }
+ await fetchUnseenSnatches(chainId, account.address);
+}
+
+function markSeen(): void {
+ const account = getAccount(config as Config);
+ if (!account.address || allRecentSnatches.value.length === 0) return;
+
+ // Use the most recent timestamp from current results
+ const latestTs = allRecentSnatches.value[0]?.timestamp;
+ if (latestTs) {
+ setLastSeen(account.address, latestTs);
+ }
+ 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;
+ }
+ });
+
+ // Re-fetch when bell is closed to clear unseen after markSeen
+ watch(isOpen, async newVal => {
+ if (!newVal) return;
+ });
+
+ return {
+ unseenCount,
+ unseenSnatches,
+ allRecentSnatches,
+ isOpen,
+ open,
+ close,
+ toggle,
+ refresh: () => refresh(chainId),
+ };
+}
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
+
+
+
+