diff --git a/src/components/common/PriceAlertDialog.tsx b/src/components/common/PriceAlertDialog.tsx new file mode 100644 index 00000000..207b48b7 --- /dev/null +++ b/src/components/common/PriceAlertDialog.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from 'react'; +import { BellRing } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useCreatePriceAlert } from '@/hooks/usePriceAlerts'; +import type { AlertDirection } from '@/services/alert.service'; +import { cn } from '@/lib/utils'; +import { parsePositivePrice } from '@/utils/priceAlert.utils'; +import showToast from '@/utils/toast.util'; + +interface PriceAlertDialogProps { + userId?: string; + keyId: string; + keyName?: string; + currentPrice?: number | null; +} + +export default function PriceAlertDialog({ + userId, + keyId, + keyName = 'this key', + currentPrice, +}: PriceAlertDialogProps) { + const [open, setOpen] = useState(false); + const [targetText, setTargetText] = useState(''); + const [direction, setDirection] = useState('above'); + const createAlert = useCreatePriceAlert(userId); + const targetPrice = useMemo(() => parsePositivePrice(targetText), [targetText]); + const invalid = targetText.trim().length > 0 && targetPrice === null; + + useEffect(() => { + if (!open) return; + setTargetText(currentPrice != null && currentPrice > 0 ? String(currentPrice) : ''); + setDirection('above'); + createAlert.reset(); + }, [createAlert, currentPrice, open]); + + const handleSave = async () => { + if (targetPrice === null) return; + try { + await createAlert.mutateAsync({ keyId, keyName, targetPrice, direction }); + showToast.success( + `Price alert set for ${keyName}. We’ll notify you when the price moves ${direction} your target.` + ); + setOpen(false); + } catch (error) { + showToast.error( + error instanceof Error ? error.message : 'Couldn’t save your price alert.' + ); + } + }; + + return ( + + + + + + + Set a price alert + + We’ll check {keyName} every 60 seconds and notify you when it crosses your target. + + + +
{ + event.preventDefault(); + void handleSave(); + }} + > +
+ + setTargetText(event.target.value)} + aria-invalid={invalid} + aria-describedby="price-alert-help price-alert-error" + placeholder="Enter a positive price" + className={cn( + 'w-full rounded-xl border border-white/15 bg-white/[0.05] px-4 py-3 text-white outline-none transition focus:border-cyan-300 focus:ring-2 focus:ring-cyan-300/20', + invalid && 'border-rose-400 focus:border-rose-400 focus:ring-rose-400/20' + )} + /> +

+ Use the key’s quoted unit, XLM. +

+ {invalid ? ( + + ) : null} +
+ +
+ Notify me when price is +
+ {(['above', 'below'] as const).map(option => ( + + ))} +
+
+ + {createAlert.isError ? ( +

+ {createAlert.error instanceof Error + ? createAlert.error.message + : 'Couldn’t save your price alert.'} +

+ ) : null} + + + + + +
+
+
+ ); +} diff --git a/src/components/common/PriceAlertsSection.tsx b/src/components/common/PriceAlertsSection.tsx new file mode 100644 index 00000000..419bbcce --- /dev/null +++ b/src/components/common/PriceAlertsSection.tsx @@ -0,0 +1,97 @@ +import { BellRing, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useActivePriceAlerts, useDeletePriceAlert } from '@/hooks/usePriceAlerts'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import showToast from '@/utils/toast.util'; + +interface PriceAlertsSectionProps { + userId: string | undefined; +} + +export default function PriceAlertsSection({ userId }: PriceAlertsSectionProps) { + const alertsQuery = useActivePriceAlerts(userId); + const deleteAlert = useDeletePriceAlert(userId); + const alerts = alertsQuery.data ?? []; + + const handleDelete = async (alertId: string) => { + try { + await deleteAlert.mutateAsync(alertId); + showToast.success('Price alert deleted.'); + } catch (error) { + showToast.error(error instanceof Error ? error.message : 'Couldn’t delete this alert.'); + } + }; + + return ( +
+
+
+ + +
+

+ My Alerts +

+

+ Price thresholds we’re watching for you. +

+
+
+ + {alertsQuery.isLoading ? ( +
+ {Array.from({ length: 2 }, (_, index) => ( +
+ ))} +
+ ) : alertsQuery.isError ? ( +
+ Couldn’t load your alerts. + +
+ ) : alerts.length === 0 ? ( +
+

No active alerts

+

Set one from any key detail page.

+
+ ) : ( +
    + {alerts.map(alert => ( +
  • +
    +

    + {alert.keyName || `Key ${alert.keyId}`} +

    +

    + Alert when price is{' '} + + {alert.direction} + {' '} + {formatDisplayKeyPrice(alert.targetPrice)} +

    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/common/RecentBuyerActivityFeed.tsx b/src/components/common/RecentBuyerActivityFeed.tsx new file mode 100644 index 00000000..89f4b4cb --- /dev/null +++ b/src/components/common/RecentBuyerActivityFeed.tsx @@ -0,0 +1,149 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowDownRight, ArrowUpRight, Radio } from 'lucide-react'; +import useKeyTrades from '@/hooks/useKeyTrades'; +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; +import { formatAbsoluteDateTime, formatRelativeTime } from '@/utils/time.utils'; +import { cn } from '@/lib/utils'; +import type { KeyTrade } from '@/services/keyTrades.service'; + +function truncateWallet(address: string): string { + if (address.length <= 14) return address; + return `${address.slice(0, 6)}...${address.slice(-5)}`; +} + +function quantityLabel(quantity: number): string { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 6, + }).format(quantity); +} + +function TradeRow({ trade, isNew }: { trade: KeyTrade; isNew: boolean }) { + const isBuy = trade.type === 'buy'; + return ( + +
+ +
+

+ {truncateWallet(trade.walletAddress)} +

+

+ {isBuy ? 'Buy' : 'Sell'} · {quantityLabel(trade.quantity)} key + {trade.quantity === 1 ? '' : 's'} +

+
+
+ +
+ ); +} + +export default function RecentBuyerActivityFeed({ keyId }: { keyId: string }) { + const { trades, newTradeIds, isLoading, isFetching, isError, refetch } = + useKeyTrades(keyId); + const prefersReducedMotion = usePrefersReducedMotion(); + + return ( +
+
+
+

+ Recent activity +

+

Last 5 buys and sells

+
+ {isFetching && !isLoading ? ( + + + ) : null} +
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }, (_, index) => ( +
+ ))} +
+ ) : isError ? ( +
+

Couldn’t load recent activity.

+ +
+ ) : trades.length === 0 ? ( +

+ No recent activity +

+ ) : ( + + + {trades.map(trade => ( + + ))} + + + )} +
+ ); +} diff --git a/src/hooks/useKeyTrades.ts b/src/hooks/useKeyTrades.ts new file mode 100644 index 00000000..a71578e8 --- /dev/null +++ b/src/hooks/useKeyTrades.ts @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchKeyTrades, type KeyTrade } from '@/services/keyTrades.service'; + +export const KEY_TRADES_POLL_INTERVAL_MS = 30_000; +export const KEY_TRADES_DISPLAY_LIMIT = 5; +const NEW_TRADE_HIGHLIGHT_MS = 2_200; + +function mergeTrades(previous: KeyTrade[], incoming: KeyTrade[]): KeyTrade[] { + const seen = new Set(); + return [...incoming, ...previous] + .filter(trade => { + if (seen.has(trade.id)) return false; + seen.add(trade.id); + return true; + }) + .slice(0, KEY_TRADES_DISPLAY_LIMIT); +} + +export interface UseKeyTradesResult { + trades: KeyTrade[]; + newTradeIds: ReadonlySet; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + refetch: () => Promise; +} + +/** + * Loads the five most recent key trades immediately and polls every 30s. + * Previously seen rows are retained across a poll so rows animate only once + * when they first appear. + */ +export function useKeyTrades( + keyId: string, + limit = KEY_TRADES_DISPLAY_LIMIT +): UseKeyTradesResult { + const [trades, setTrades] = useState([]); + const [newTradeIds, setNewTradeIds] = useState>(new Set()); + const seenIdsRef = useRef>(new Set()); + const initializedRef = useRef(false); + + const query = useQuery({ + queryKey: ['key-trades', keyId, limit], + queryFn: () => fetchKeyTrades(keyId, limit), + enabled: Boolean(keyId), + refetchInterval: KEY_TRADES_POLL_INTERVAL_MS, + }); + + useEffect(() => { + setTrades([]); + setNewTradeIds(new Set()); + seenIdsRef.current = new Set(); + initializedRef.current = false; + }, [keyId, limit]); + + useEffect(() => { + const incoming = query.data; + if (!incoming) return; + + if (!initializedRef.current) { + initializedRef.current = true; + seenIdsRef.current = new Set(incoming.map(trade => trade.id)); + setTrades(incoming.slice(0, limit)); + return; + } + + const added = incoming.filter(trade => !seenIdsRef.current.has(trade.id)); + for (const trade of incoming) seenIdsRef.current.add(trade.id); + setTrades(previous => mergeTrades(previous, incoming)); + + if (added.length === 0) return; + setNewTradeIds(new Set(added.map(trade => trade.id))); + const timer = window.setTimeout(() => { + setNewTradeIds(new Set()); + }, NEW_TRADE_HIGHLIGHT_MS); + return () => window.clearTimeout(timer); + }, [limit, query.data]); + + return useMemo( + () => ({ + trades, + newTradeIds, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + refetch: query.refetch, + }), + [newTradeIds, query.isError, query.isFetching, query.isLoading, query.refetch, trades] + ); +} + +export default useKeyTrades; diff --git a/src/services/keyTrades.service.ts b/src/services/keyTrades.service.ts new file mode 100644 index 00000000..3eb07933 --- /dev/null +++ b/src/services/keyTrades.service.ts @@ -0,0 +1,105 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +export type KeyTradeSide = 'buy' | 'sell'; + +/** Normalized public trade returned by GET /keys/:keyId/trades. */ +export interface KeyTrade { + id: string; + keyId: string; + walletAddress: string; + type: KeyTradeSide; + quantity: number; + price?: number; + timestamp: number; + transactionHash?: string | null; +} + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord { + return typeof value === 'object' && value !== null + ? (value as UnknownRecord) + : {}; +} + +function asPositiveNumber(value: unknown): number | undefined { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + // Unix seconds are occasionally returned by backend jobs. + return value < 1_000_000_000_000 ? value * 1_000 : value; + } + if (typeof value === 'string') { + const numericValue = Number(value); + if (Number.isFinite(numericValue) && value.trim() !== '') { + return normalizeTimestamp(numericValue); + } + const parsed = new Date(value).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + return Date.now(); +} + +function normalizeSide(value: unknown): KeyTradeSide { + return String(value ?? '').toLowerCase() === 'sell' ? 'sell' : 'buy'; +} + +/** + * Normalize the small set of historical response shapes used by the key + * activity endpoint so the UI never has to know backend field aliases. + */ +export function normalizeKeyTrade(value: unknown, keyId: string, index = 0): KeyTrade { + const raw = asRecord(value); + const timestamp = normalizeTimestamp( + raw.timestamp ?? raw.createdAt ?? raw.tradedAt ?? raw.time + ); + const rawType = raw.tradeType ?? raw.type ?? raw.side; + const transactionHash = raw.transactionHash ?? raw.txHash ?? raw.hash; + + return { + id: String(raw.id ?? raw.tradeId ?? transactionHash ?? `${keyId}-${timestamp}-${index}`), + keyId: String(raw.keyId ?? keyId), + walletAddress: String( + raw.walletAddress ?? raw.traderAddress ?? raw.wallet ?? raw.trader ?? 'Unknown wallet' + ), + type: normalizeSide(rawType), + quantity: + asPositiveNumber(raw.quantity ?? raw.amount ?? raw.shares) ?? 0, + price: asPositiveNumber(raw.price ?? raw.pricePerKey), + timestamp, + transactionHash: + typeof transactionHash === 'string' ? transactionHash : null, + }; +} + +export function normalizeKeyTrades(value: unknown, keyId: string): KeyTrade[] { + const data = asRecord(value).trades ?? asRecord(value).data ?? value; + const trades = Array.isArray(data) ? data : []; + + return trades + .map((trade, index) => normalizeKeyTrade(trade, keyId, index)) + .sort((a, b) => b.timestamp - a.timestamp); +} + +class KeyTradeService extends BaseApiService { + async getKeyTrades(keyId: string, limit = 5): Promise { + try { + const response = await this.api.get>( + `/keys/${encodeURIComponent(keyId)}/trades`, + { params: { limit } } + ); + return normalizeKeyTrades(response.data.data, keyId).slice(0, limit); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keyTradeService = new KeyTradeService(); + +export function fetchKeyTrades(keyId: string, limit = 5): Promise { + return keyTradeService.getKeyTrades(keyId, limit); +} diff --git a/src/services/priceAlert.service.ts b/src/services/priceAlert.service.ts new file mode 100644 index 00000000..de374c7a --- /dev/null +++ b/src/services/priceAlert.service.ts @@ -0,0 +1,95 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +export type PriceAlertDirection = 'above' | 'below'; + +export interface PriceAlert { + id: string; + keyId: string; + keyName?: string; + targetPrice: number; + direction: PriceAlertDirection; + active: boolean; + createdAt?: number; +} + +export interface CreatePriceAlertInput { + keyId: string; + targetPrice: number; + direction: PriceAlertDirection; +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +function normalizeTimestamp(value: unknown): number | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined; + const numeric = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(numeric) && numeric > 0) return numeric; + const parsed = new Date(String(value)).getTime(); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function normalizePriceAlert(value: unknown): PriceAlert { + const raw = asRecord(value); + const key = asRecord(raw.key); + const target = Number(raw.targetPrice ?? raw.price ?? raw.threshold); + const direction = String(raw.direction ?? raw.condition).toLowerCase(); + + return { + id: String(raw.id ?? raw._id ?? raw.alertId), + keyId: String(raw.keyId ?? key.id ?? ''), + keyName: + typeof (raw.keyName ?? raw.title ?? key.title ?? key.name) === 'string' + ? String(raw.keyName ?? raw.title ?? key.title ?? key.name) + : undefined, + targetPrice: Number.isFinite(target) ? target : 0, + direction: direction === 'below' ? 'below' : 'above', + active: raw.active == null ? true : Boolean(raw.active), + createdAt: normalizeTimestamp(raw.createdAt ?? raw.created_at), + }; +} + +function normalizePriceAlerts(value: unknown): PriceAlert[] { + const direct = Array.isArray(value) ? value : asRecord(value).alerts; + return Array.isArray(direct) ? direct.map(normalizePriceAlert) : []; +} + +class PriceAlertService extends BaseApiService { + async getPriceAlerts(): Promise { + try { + const response = await this.api.get>('/alerts'); + return normalizePriceAlerts(response.data.data); + } catch (error) { + throw this.handleError(error); + } + } + + async createPriceAlert(input: CreatePriceAlertInput): Promise { + try { + // Send the issue contract exactly as specified. Amounts remain + // numbers here; the backend owns unit conversion and persistence. + const response = await this.api.post>('/alerts', { + keyId: input.keyId, + targetPrice: input.targetPrice, + direction: input.direction, + }); + const data = response.data.data; + return normalizePriceAlert(asRecord(data).alert ?? data); + } catch (error) { + throw this.handleError(error); + } + } + + async deletePriceAlert(alertId: string): Promise { + try { + await this.api.delete(`/alerts/${encodeURIComponent(alertId)}`); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const priceAlertService = new PriceAlertService(); diff --git a/src/utils/priceAlert.utils.ts b/src/utils/priceAlert.utils.ts new file mode 100644 index 00000000..943d8d6b --- /dev/null +++ b/src/utils/priceAlert.utils.ts @@ -0,0 +1,10 @@ +export function parsePositivePrice(value: string): number | null { + const normalized = value.trim(); + if (!normalized) return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export function isValidPriceAlertPrice(value: string): boolean { + return parsePositivePrice(value) !== null; +}