From 5dff4d6ebdecd7af605986bcac96ac0f3727e934 Mon Sep 17 00:00:00 2001 From: Muhammad Auwal Date: Tue, 23 Jun 2026 12:35:03 +0100 Subject: [PATCH 1/2] feat(frontend): integrate live websocket status updates on escrow detail page --- apps/frontend/hooks/useEscrowWebSocket.ts | 0 apps/frontend/package-lock.json | 86 +++++++++++++++++++++-- apps/frontend/package.json | 13 ++-- 3 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 apps/frontend/hooks/useEscrowWebSocket.ts diff --git a/apps/frontend/hooks/useEscrowWebSocket.ts b/apps/frontend/hooks/useEscrowWebSocket.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 29280d79..9e9b4ef6 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -21,6 +21,8 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.71.1", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", "stellar-sdk": "^13.3.0", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" @@ -4676,6 +4678,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -7508,7 +7516,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7833,6 +7840,28 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -12853,7 +12882,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -13852,6 +13880,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -15578,6 +15607,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -15588,6 +15645,16 @@ "require-addon": "^1.1.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17253,10 +17320,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17308,6 +17374,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 76a8e73a..311fb868 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,12 +7,11 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint .", + "lint": "eslint", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:e2e": "playwright test", - "lint": "eslint" + "test:e2e": "playwright test" }, "dependencies": { "@albedo-link/intent": "^0.12.0", @@ -28,6 +27,8 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.71.1", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", "stellar-sdk": "^13.3.0", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" @@ -46,17 +47,17 @@ "@types/react-dom": "^19", "eslint": "^8.57.1", "eslint-config-next": "^15.5.9", + "eslint-plugin-prettier": "^5.5.5", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "msw": "^2.12.14", - "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.1", "shadcn": "^3.8.5", "tailwindcss": "^4", "ts-node": "^10.9.2", "tw-animate-css": "^1.4.0", "typescript": "^5", - "whatwg-fetch": "^3.6.20", - "typescript-eslint": "^8.57.2" + "typescript-eslint": "^8.57.2", + "whatwg-fetch": "^3.6.20" } } From 939559a636b1ca4b77c5093bee47566f79a10499 Mon Sep 17 00:00:00 2001 From: Muhammad Auwal Date: Tue, 23 Jun 2026 12:47:40 +0100 Subject: [PATCH 2/2] feat(frontend): integrate notification preference ui with channel and webhook orchestration --- apps/frontend/app/escrow/[id]/page.tsx | 106 +++--- .../settings/NotificationPreferences.tsx | 319 ++++++++++++++++++ apps/frontend/components/settings/page.tsx | 30 ++ apps/frontend/hooks/useEscrow.ts | 79 ++++- apps/frontend/hooks/useEscrowWebSocket.ts | 103 ++++++ apps/frontend/types/notifications.ts | 23 ++ 6 files changed, 590 insertions(+), 70 deletions(-) create mode 100644 apps/frontend/components/settings/NotificationPreferences.tsx create mode 100644 apps/frontend/components/settings/page.tsx create mode 100644 apps/frontend/types/notifications.ts diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx index 9f8082e8..c6c31a7d 100644 --- a/apps/frontend/app/escrow/[id]/page.tsx +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { useEscrow } from "@/hooks/useEscrow"; @@ -19,11 +19,10 @@ import { EscrowDetailSkeleton } from "@/components/ui/EscrowDetailSkeleton"; const EscrowDetailPage = () => { const { id } = useParams(); - const { escrow, error, loading, refetch } = useEscrow(id as string); + const { escrow, error, loading, refetch, isLive } = useEscrow(id as string); const { connected, publicKey, connect } = useWallet(); - const [userRole, setUserRole] = useState< - "creator" | "counterparty" | "arbitrator" | null - >(null); + + const [userRole, setUserRole] = useState<"creator" | "counterparty" | "arbitrator" | null>(null); const [currentParty, setCurrentParty] = useState(null); const [disputeOpen, setDisputeOpen] = useState(false); const [resolutionOpen, setResolutionOpen] = useState(false); @@ -49,37 +48,34 @@ const EscrowDetailPage = () => { } }, [escrow, publicKey]); - // Fetch dispute data when escrow is in DISPUTED status - useEffect(() => { - const fetchDispute = async () => { - if (escrow?.status !== "DISPUTED") { - setDispute(null); - return; - } - - try { - const response = await fetch(`/api/escrows/${escrow.id}/dispute`); - if (response.ok) { - const disputeData = await response.json(); - setDispute(disputeData); - } - } catch (error) { - console.error("Error fetching dispute:", error); + const fetchDisputeData = useCallback(async () => { + if (escrow?.status !== "DISPUTED") { + setDispute(null); + return; + } + try { + const response = await fetch(`/api/escrows/${escrow.id}/dispute`); + if (response.ok) { + const disputeData = await response.json(); + setDispute(disputeData); } - }; - - fetchDispute(); + } catch (error) { + console.error("Error fetching dispute details:", error); + } }, [escrow?.id, escrow?.status]); + // Hook up dispute monitoring states + useEffect(() => { + void fetchDisputeData(); + }, [fetchDisputeData]); + if (loading) return ; if (error) { return (
-

- Error Loading Escrow -

+

Error Loading Escrow

{error}

+
+ +
+ + + + + + + + + + {(Object.keys(EVENT_LABELS) as NotificationEventType[]).map((event) => ( + + + + + + ))} + +
Trigger Core Topic NameEmailWebhook
{EVENT_LABELS[event]} + handleToggleChannel(event, "email")} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" + /> + + handleToggleChannel(event, "webhook")} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" + /> +
+
+ +
+ +
+
+ + {/* SECTION 2: Webhook Endpoint Registries */} +
+

Developer Webhook Streams

+

Stream JSON transaction lifecycle envelopes to custom listener routes.

+ + {/* Existing Hook Iteration Lists */} + {webhooks.length > 0 ? ( +
+ {webhooks.map((hook) => ( +
+
+
{hook.url}
+
+ {hook.eventTypes.map((e) => ( + + {EVENT_LABELS[e]} + + ))} +
+
+
+ + +
+
+ ))} +
+ ) : ( +
+ No live active developer hook targets bound to this account context. +
+ )} + + {/* Form Pipeline append updates */} +
+

Register Developer Listener Endpoints

+ +
+ + setNewUrl(e.target.value)} + className="w-full h-11 px-3 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ +
+ +
+ {(Object.keys(EVENT_LABELS) as NotificationEventType[]).map((event) => ( + + ))} +
+
+ +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/frontend/components/settings/page.tsx b/apps/frontend/components/settings/page.tsx new file mode 100644 index 00000000..ee212ca7 --- /dev/null +++ b/apps/frontend/components/settings/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import NotificationPreferences from "@/components/settings/NotificationPreferences"; + +export default function SettingsPage() { + return ( +
+
+
+

Account Parameters

+

+ Maintain your system identifiers, programmatic hook endpoints, and communication matrices. +

+
+ +
+ +
+
+

Notification Channels Configuration

+

+ Determine how real-time network states update your local node and remote monitoring environments. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/hooks/useEscrow.ts b/apps/frontend/hooks/useEscrow.ts index 24f7d381..d9290ec6 100644 --- a/apps/frontend/hooks/useEscrow.ts +++ b/apps/frontend/hooks/useEscrow.ts @@ -1,11 +1,17 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { fetchEscrow } from '@/lib/escrow-api'; import { IEscrowExtended, IUseEscrowReturn } from '@/types/escrow'; +import { io, Socket } from 'socket.io-client'; +import { toast } from 'sonner'; -export const useEscrow = (id: string): IUseEscrowReturn => { +export const useEscrow = (id: string): IUseEscrowReturn & { isLive: boolean } => { const [escrow, setEscrow] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [isLive, setIsLive] = useState(false); + + const socketRef = useRef(null); + const fallbackIntervalRef = useRef(null); const refetch = useCallback(async () => { if (!id) { @@ -15,7 +21,6 @@ export const useEscrow = (id: string): IUseEscrowReturn => { } try { - setLoading(true); const data = await fetchEscrow(id); setEscrow(data); setError(null); @@ -32,9 +37,73 @@ export const useEscrow = (id: string): IUseEscrowReturn => { } }, [id]); + // Handle active background data sync loops if WebSockets drop out + const startPollingFallback = useCallback(() => { + if (fallbackIntervalRef.current) clearInterval(fallbackIntervalRef.current); + fallbackIntervalRef.current = setInterval(() => { + void refetch(); + }, 5000); // 5-second health loop sync + }, [refetch]); + + const stopPollingFallback = useCallback(() => { + if (fallbackIntervalRef.current) { + clearInterval(fallbackIntervalRef.current); + fallbackIntervalRef.current = null; + } + }, []); + useEffect(() => { void refetch(); }, [refetch]); - return { escrow, loading, error, refetch }; -}; + // ── WebSocket Lifecycle Room Subscriptions ── + useEffect(() => { + if (!id) return; + + const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:3001'; + + const socket = io(WS_URL, { + transports: ['websocket'], + autoConnect: true, + reconnectionAttempts: 5, + }); + + socketRef.current = socket; + + socket.on('connect', () => { + setIsLive(true); + stopPollingFallback(); + socket.emit('escrow:join', { id }); + }); + + socket.on('disconnect', () => { + setIsLive(false); + startPollingFallback(); + }); + + socket.on('connect_error', () => { + setIsLive(false); + startPollingFallback(); + }); + + // Real-time pipe events + const handleLiveUpdate = (event: { message: string }) => { + toast.info(event.message || 'Escrow ledger balance or status changed.'); + void refetch(); + }; + + socket.on('escrow:status_changed', handleLiveUpdate); + socket.on('escrow:funded', handleLiveUpdate); + socket.on('escrow:completed', handleLiveUpdate); + socket.on('escrow:dispute_filed', handleLiveUpdate); + socket.on('escrow:dispute_resolved', handleLiveUpdate); + + return () => { + socket.emit('escrow:leave', { id }); + socket.disconnect(); + stopPollingFallback(); + }; + }, [id, refetch, startPollingFallback, stopPollingFallback]); + + return { escrow, loading, error, refetch, isLive }; +}; \ No newline at end of file diff --git a/apps/frontend/hooks/useEscrowWebSocket.ts b/apps/frontend/hooks/useEscrowWebSocket.ts index e69de29b..ae2a466b 100644 --- a/apps/frontend/hooks/useEscrowWebSocket.ts +++ b/apps/frontend/hooks/useEscrowWebSocket.ts @@ -0,0 +1,103 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { io, Socket } from "socket.io-client"; +import { toast } from "sonner"; // Or your preferred toast provider + +interface UseEscrowWebSocketProps { + escrowId: string; + isSocketConnected: boolean; + setSocketConnected: (connected: boolean) => void; +} + +// Global or shared socket instance connection configuration +let socket: Socket | null = null; + +export function useEscrowWebSocket({ + escrowId, + isSocketConnected, + setSocketConnected, +}: UseEscrowWebSocketProps) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!escrowId) return; + + // Fallback environment targets for standard infrastructure orchestration + const WEBSOCKET_URL = process.env.NEXT_PUBLIC_WS_URL || "http://localhost:3001"; + + if (!socket) { + socket = io(WEBSOCKET_URL, { + transports: ["websocket"], + autoConnect: true, + reconnectionAttempts: 5, + reconnectionDelay: 2000, + }); + } + + // Connect lifecycle configurations + socket.on("connect", () => { + setSocketConnected(true); + // Join the designated escrow isolation workspace room + socket?.emit("escrow:join", { id: escrowId }); + }); + + socket.on("disconnect", () => { + setSocketConnected(false); + }); + + socket.on("connect_error", () => { + setSocketConnected(false); + }); + + // ── Live Event Subscriptions & Cache Mutation Orchestrations ── + + const handleEscrowUpdate = (event: { type: string; message: string; payload?: any }) => { + // Subtle push alert feedback notifications + toast.info(event.message || "Escrow pipeline state update received."); + + // Invalidate and background refetch standard cache line to guarantee complete state integrity + queryClient.invalidateQueries({ queryKey: ["escrow", escrowId] }); + + // Optimistic layout mutation mapping depending on payload structure + if (event.payload) { + queryClient.setQueryData(["escrow", escrowId], (oldData: any) => { + if (!oldData) return oldData; + return { + ...oldData, + ...event.payload, + // Deep array updates are merged cleanly via the incoming refetch cascade + }; + }); + } + }; + + socket.on("escrow:status_changed", handleEscrowUpdate); + socket.on("escrow:funded", handleEscrowUpdate); + socket.on("escrow:completed", handleEscrowUpdate); + socket.on("escrow:dispute_filed", handleEscrowUpdate); + socket.on("escrow:dispute_resolved", handleEscrowUpdate); + + // Explicitly connect if initialized in a dormant state + if (socket.disconnected) { + socket.connect(); + } else if (socket.connected) { + setSocketConnected(true); + socket.emit("escrow:join", { id: escrowId }); + } + + // Cleanup teardown lifecycle pipeline: leave isolation workspace room on component unmount + return () => { + if (socket) { + socket.emit("escrow:leave", { id: escrowId }); + socket.off("connect"); + socket.off("disconnect"); + socket.off("connect_error"); + socket.off("escrow:status_changed", handleEscrowUpdate); + socket.off("escrow:funded", handleEscrowUpdate); + socket.off("escrow:completed", handleEscrowUpdate); + socket.off("escrow:dispute_filed", handleEscrowUpdate); + socket.off("escrow:dispute_resolved", handleEscrowUpdate); + } + }; + }, [escrowId, queryClient, setSocketConnected]); +} \ No newline at end of file diff --git a/apps/frontend/types/notifications.ts b/apps/frontend/types/notifications.ts new file mode 100644 index 00000000..b954ea97 --- /dev/null +++ b/apps/frontend/types/notifications.ts @@ -0,0 +1,23 @@ +export enum NotificationEventType { + ESCROW_CREATED = "ESCROW_CREATED", + ESCROW_FUNDED = "ESCROW_FUNDED", + MILESTONE_RELEASED = "MILESTONE_RELEASED", + DISPUTE_FILED = "DISPUTE_FILED", + DISPUTE_RESOLVED = "DISPUTE_RESOLVED", +} + +export interface IChannelConfig { + email: boolean; + webhook: boolean; +} + +export interface INotificationPreferences { + preferences: Record; +} + +export interface IWebhook { + id: string; + url: string; + eventTypes: NotificationEventType[]; + createdAt: string; +} \ No newline at end of file