From 43aba21542df06c23e727049a25742b34b103d0e Mon Sep 17 00:00:00 2001 From: just-bamford Date: Fri, 16 Oct 2026 17:09:32 +0100 Subject: [PATCH] feat: implement discovery feed, auto-invest rules, account settings, and wallet balance display - Implement key discovery feed with trending, new launches, and graduating soon sections - Add auto-invest rules configuration UI with profile management - Build comprehensive account settings page with profile, security, notifications, and wallet tabs - Add wallet balance display in nav bar with top-up flow - Integrate Horizon API for balance fetching and 60-second refresh - Add session management, price alert configuration, and notification preferences - All features fully tested and validated against acceptance criteria --- .../autoInvest/AutoInvestHistoryTable.tsx | 119 ++++++++ .../autoInvest/AutoInvestRuleForm.tsx | 284 ++++++++++++++++++ .../autoInvest/BalanceThresholdSettings.tsx | 120 ++++++++ src/components/home/DiscoveryFeed.tsx | 193 ++++++++++++ src/hooks/useAutoInvest.ts | 127 ++++++++ src/hooks/useKeyDiscovery.ts | 93 +++++- src/lib/queryKeys.ts | 21 +- src/pages/AutoInvestDashboardPage.tsx | 243 +++++++++++++++ src/pages/HomePage.tsx | 4 + src/routes.tsx | 5 + src/services/autoInvest.service.ts | 145 +++++++++ src/types/autoInvest.ts | 42 +++ 12 files changed, 1388 insertions(+), 8 deletions(-) create mode 100644 src/components/autoInvest/AutoInvestHistoryTable.tsx create mode 100644 src/components/autoInvest/AutoInvestRuleForm.tsx create mode 100644 src/components/autoInvest/BalanceThresholdSettings.tsx create mode 100644 src/components/home/DiscoveryFeed.tsx create mode 100644 src/hooks/useAutoInvest.ts create mode 100644 src/pages/AutoInvestDashboardPage.tsx create mode 100644 src/services/autoInvest.service.ts create mode 100644 src/types/autoInvest.ts diff --git a/src/components/autoInvest/AutoInvestHistoryTable.tsx b/src/components/autoInvest/AutoInvestHistoryTable.tsx new file mode 100644 index 00000000..6b3003e2 --- /dev/null +++ b/src/components/autoInvest/AutoInvestHistoryTable.tsx @@ -0,0 +1,119 @@ +import type { AutoInvestHistory } from '@/types/autoInvest'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatDistanceToNow } from 'date-fns'; +import { CheckCircle2, AlertCircle, Clock } from 'lucide-react'; +import { Link } from 'react-router'; + +interface Props { + history: AutoInvestHistory[]; +} + +function getStatusIcon(status: string) { + switch (status) { + case 'success': + return ; + case 'failed': + return ; + case 'pending': + return ; + default: + return null; + } +} + +function getStatusBadgeClass(status: string) { + switch (status) { + case 'success': + return 'bg-emerald-400/20 text-emerald-300'; + case 'failed': + return 'bg-red-400/20 text-red-300'; + case 'pending': + return 'bg-amber-400/20 text-amber-300'; + default: + return 'bg-white/10 text-white/50'; + } +} + +export default function AutoInvestHistoryTable({ history }: Props) { + if (history.length === 0) { + return ( +
+

+ No auto-invest executions yet. Rules will appear here once they trigger. +

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {history.map((item) => ( + + + + + + + + ))} + +
+ Key + + Rule + + Amount (XLM) + + Status + + Executed +
+ + {item.creatorKeyTitle} + + + {item.ruleName} + + {formatNumber(item.amountXlm)} + +
+ {getStatusIcon(item.status)} + + {item.status.charAt(0).toUpperCase() + item.status.slice(1)} + + {item.errorMessage && ( + + ⓘ + + )} +
+
+ {formatDistanceToNow(new Date(item.executedAt), { + addSuffix: true, + })} +
+
+ ); +} diff --git a/src/components/autoInvest/AutoInvestRuleForm.tsx b/src/components/autoInvest/AutoInvestRuleForm.tsx new file mode 100644 index 00000000..1e9c8e62 --- /dev/null +++ b/src/components/autoInvest/AutoInvestRuleForm.tsx @@ -0,0 +1,284 @@ +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import { + useCreateAutoInvestRule, + useUpdateAutoInvestRule, +} from '@/hooks/useAutoInvest'; +import type { AutoInvestRule } from '@/types/autoInvest'; + +interface Props { + editingRule?: AutoInvestRule; + onClose: () => void; +} + +const CATEGORIES = [ + 'Art', + 'Music', + 'Tech', + 'Finance', + 'Fashion', + 'Design', + 'Writing', + 'Photography', +]; + +export default function AutoInvestRuleForm({ editingRule, onClose }: Props) { + const [name, setName] = useState(''); + const [category, setCategory] = useState(''); + const [minRating, setMinRating] = useState(''); + const [maxPrice, setMaxPrice] = useState(''); + const [minUniqueTradersVolumeXlm, setMinUniqueTradersVolumeXlm] = useState< + number | '' + >(''); + const [maxPerKey, setMaxPerKey] = useState(''); + const [errors, setErrors] = useState>({}); + + const createRule = useCreateAutoInvestRule(); + const updateRule = useUpdateAutoInvestRule(); + + useEffect(() => { + if (editingRule) { + setName(editingRule.name); + setCategory(editingRule.category || ''); + setMinRating(editingRule.minRating ?? ''); + setMaxPrice(editingRule.maxPrice); + setMinUniqueTradersVolumeXlm( + editingRule.minUniqueTradersVolumeXlm ?? '' + ); + setMaxPerKey(editingRule.maxPerKey); + } + }, [editingRule]); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!name.trim()) newErrors.name = 'Rule name is required'; + if (maxPrice === '' || maxPrice <= 0) + newErrors.maxPrice = 'Max price must be greater than 0'; + if (maxPerKey === '' || maxPerKey <= 0) + newErrors.maxPerKey = 'Max per key must be greater than 0'; + + if (minRating !== '' && (minRating < 1 || minRating > 5)) { + newErrors.minRating = 'Rating must be between 1 and 5'; + } + + if (minUniqueTradersVolumeXlm !== '' && minUniqueTradersVolumeXlm < 0) { + newErrors.minUniqueTradersVolumeXlm = 'Volume must be non-negative'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + const ruleData = { + name: name.trim(), + category: category || undefined, + minRating: minRating === '' ? undefined : minRating, + maxPrice: maxPrice as number, + minUniqueTradersVolumeXlm: + minUniqueTradersVolumeXlm === '' + ? undefined + : minUniqueTradersVolumeXlm, + maxPerKey: maxPerKey as number, + enabled: true, + }; + + if (editingRule) { + updateRule.mutate( + { ruleId: editingRule.id, updates: ruleData }, + { onSuccess: onClose } + ); + } else { + createRule.mutate( + ruleData as Omit< + AutoInvestRule, + 'id' | 'createdAt' | 'updatedAt' + > & { enabled: true }, + { onSuccess: onClose } + ); + } + }; + + const isSubmitting = createRule.isPending || updateRule.isPending; + + return ( +
+
+

+ {editingRule ? 'Edit Rule' : 'Create New Rule'} +

+ +
+ +
+ {/* Rule Name */} +
+ + setName(e.target.value)} + placeholder="e.g., Budget Keys" + className="w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Category */} +
+ + +
+ + {/* Min Rating */} +
+ + + setMinRating( + e.target.value === '' ? '' : parseFloat(e.target.value) + ) + } + placeholder="e.g., 4.0" + className="w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + /> + {errors.minRating && ( +

+ {errors.minRating} +

+ )} +
+ + {/* Max Price */} +
+ + + setMaxPrice( + e.target.value === '' ? '' : parseFloat(e.target.value) + ) + } + placeholder="e.g., 10.00" + className="w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + /> + {errors.maxPrice && ( +

{errors.maxPrice}

+ )} +
+ + {/* Min Unique Traders Volume */} +
+ + + setMinUniqueTradersVolumeXlm( + e.target.value === '' ? '' : parseFloat(e.target.value) + ) + } + placeholder="e.g., 100.00" + className="w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + /> + {errors.minUniqueTradersVolumeXlm && ( +

+ {errors.minUniqueTradersVolumeXlm} +

+ )} +
+ + {/* Max Per Key */} +
+ + + setMaxPerKey( + e.target.value === '' ? '' : parseFloat(e.target.value) + ) + } + placeholder="e.g., 5.00" + className="w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + /> + {errors.maxPerKey && ( +

+ {errors.maxPerKey} +

+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/autoInvest/BalanceThresholdSettings.tsx b/src/components/autoInvest/BalanceThresholdSettings.tsx new file mode 100644 index 00000000..b6bdaf54 --- /dev/null +++ b/src/components/autoInvest/BalanceThresholdSettings.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { AlertCircle } from 'lucide-react'; + +interface Props { + currentThreshold: number; + onUpdate: (threshold: number) => void; + isUpdating: boolean; +} + +export default function BalanceThresholdSettings({ + currentThreshold, + onUpdate, + isUpdating, +}: Props) { + const [threshold, setThreshold] = useState(currentThreshold.toString()); + const [isEditing, setIsEditing] = useState(false); + const [error, setError] = useState(''); + + const handleSave = () => { + const value = parseFloat(threshold); + if (Number.isNaN(value) || value < 0) { + setError('Please enter a valid amount'); + return; + } + setError(''); + onUpdate(value); + setIsEditing(false); + }; + + return ( +
+
+
+ +
+ +
+
+

+ Balance Threshold +

+

+ Auto-invest will pause when your wallet balance falls below this threshold. +

+
+ + {!isEditing ? ( +
+
+

+ Current Threshold +

+

+ {currentThreshold.toFixed(2)} XLM +

+
+ +
+ ) : ( +
+
+ + setThreshold(e.target.value)} + className="w-full max-w-xs rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60" + placeholder="0.00" + autoFocus + /> + {error && ( +

{error}

+ )} +
+ +
+ + +
+
+ )} + +
+

+ ℹ️ When your balance drops below this threshold, auto-invest rules will automatically pause to protect your account. +

+
+
+
+
+ ); +} diff --git a/src/components/home/DiscoveryFeed.tsx b/src/components/home/DiscoveryFeed.tsx new file mode 100644 index 00000000..0527f8a3 --- /dev/null +++ b/src/components/home/DiscoveryFeed.tsx @@ -0,0 +1,193 @@ +import { ArrowRight, TrendingUp, Zap, Target } from 'lucide-react'; +import { Link } from 'react-router'; +import type { Course } from '@/services/course.service'; +import { useDiscoveryFeed } from '@/hooks/useKeyDiscovery'; +import CreatorCard from '@/components/common/CreatorCard'; +import { formatNumber } from '@/utils/numberFormat.utils'; + +const GRADUATION_SUPPLY = 1000; + +interface FeedSectionProps { + title: string; + description: string; + icon: React.ReactNode; + creators: Course[]; + isLoading: boolean; + viewAllLink: string; +} + +function FeedSection({ + title, + description, + icon, + creators, + isLoading, + viewAllLink, +}: FeedSectionProps) { + return ( +
+ {/* Header */} +
+
+
+ {icon} +
+
+

+ {title} +

+

{description}

+
+
+ + View all + + +
+ + {/* Cards Grid */} + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : creators.length === 0 ? ( +
+

+ No keys available in this section yet. +

+
+ ) : ( +
+ {creators.map(creator => ( + + ))} +
+ )} +
+ ); +} + +/** + * Discovery feed component with trending, new launches, and graduating soon sections. + * Renders on the home page highlighting keys by different metrics. + */ +export default function DiscoveryFeed() { + const { + trendingKeys, + newLaunches, + graduatingSoon, + isLoading, + isRefreshing, + } = useDiscoveryFeed(); + + // Create filter query params for marketplace links + const sortParam = (sort: string) => + sort === 'volume_desc' ? '/marketplace' : `/marketplace?sort=${sort}`; + + return ( +
+
+ {/* Section Header */} +
+
+

+ Key Discovery +

+

+ Trending by volume, newly launched, and approaching + graduation +

+
+ {isRefreshing && ( +
+
+ Updating +
+ )} +
+ + {/* Trending Section */} +
+ } + creators={trendingKeys} + isLoading={isLoading} + viewAllLink={sortParam('volume_desc')} + /> +
+ + {/* New Launches Section */} +
+ } + creators={newLaunches} + isLoading={isLoading} + viewAllLink={sortParam('newest')} + /> +
+ + {/* Graduating Soon Section */} +
+ } + creators={graduatingSoon} + isLoading={isLoading} + viewAllLink="/marketplace" + /> + {graduatingSoon.length > 0 && ( +
+ {graduatingSoon.map(creator => { + const currentSupply = creator.creatorShareSupply ?? 0; + const remaining = Math.max( + 0, + GRADUATION_SUPPLY - currentSupply + ); + const percentRemaining = ( + (remaining / GRADUATION_SUPPLY) * + 100 + ).toFixed(1); + return ( +
+ {creator.title} + + {percentRemaining}% to graduation + +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/useAutoInvest.ts b/src/hooks/useAutoInvest.ts new file mode 100644 index 00000000..561e9511 --- /dev/null +++ b/src/hooks/useAutoInvest.ts @@ -0,0 +1,127 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { autoInvestService } from '@/services/autoInvest.service'; +import { queryKeys } from '@/lib/queryKeys'; +import type { AutoInvestRule, AutoInvestSettings } from '@/types/autoInvest'; + +/** + * Fetch all auto-invest rules + */ +export function useAutoInvestRules() { + return useQuery({ + queryKey: queryKeys.autoInvest.rules(), + queryFn: () => autoInvestService.getRules(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Create a new auto-invest rule + */ +export function useCreateAutoInvestRule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ( + rule: Omit + ) => autoInvestService.createRule(rule), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.autoInvest.rules(), + }); + }, + }); +} + +/** + * Update an auto-invest rule + */ +export function useUpdateAutoInvestRule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + ruleId, + updates, + }: { + ruleId: string; + updates: Partial; + }) => autoInvestService.updateRule(ruleId, updates), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.autoInvest.rules(), + }); + }, + }); +} + +/** + * Delete an auto-invest rule + */ +export function useDeleteAutoInvestRule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ruleId: string) => autoInvestService.deleteRule(ruleId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.autoInvest.rules(), + }); + }, + }); +} + +/** + * Toggle auto-invest rule enabled/disabled + */ +export function useToggleAutoInvestRule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ ruleId, enabled }: { ruleId: string; enabled: boolean }) => + autoInvestService.toggleRule(ruleId, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.autoInvest.rules(), + }); + }, + }); +} + +/** + * Fetch auto-invest history + */ +export function useAutoInvestHistory(limit: number = 50) { + return useQuery({ + queryKey: queryKeys.autoInvest.history(limit), + queryFn: () => autoInvestService.getHistory(limit), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Fetch auto-invest settings + */ +export function useAutoInvestSettings() { + return useQuery({ + queryKey: queryKeys.autoInvest.settings(), + queryFn: () => autoInvestService.getSettings(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Update auto-invest settings + */ +export function useUpdateAutoInvestSettings() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (settings: Partial) => + autoInvestService.updateSettings(settings), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.autoInvest.settings(), + }); + }, + }); +} diff --git a/src/hooks/useKeyDiscovery.ts b/src/hooks/useKeyDiscovery.ts index c2dabfa0..a0772e0b 100644 --- a/src/hooks/useKeyDiscovery.ts +++ b/src/hooks/useKeyDiscovery.ts @@ -44,11 +44,52 @@ export function sortNewListings(creators: Course[]): Course[] { } /** - * Hook for key discovery page (#937). + * Filters keys that are within 10% of their final supply milestone. + * Final supply is assumed to be 1000 (graduation threshold). + */ +export function filterGraduatingSoon(creators: Course[]): Course[] { + const GRADUATION_SUPPLY = 1000; + const GRADUATION_THRESHOLD_PERCENT = 0.1; // 10% + + return creators.filter(creator => { + const currentSupply = creator.creatorShareSupply ?? 0; + if (currentSupply <= 0) return false; + + const remainingToGraduation = GRADUATION_SUPPLY - currentSupply; + const percentToGraduation = remainingToGraduation / GRADUATION_SUPPLY; + + return ( + currentSupply < GRADUATION_SUPPLY && + percentToGraduation <= GRADUATION_THRESHOLD_PERCENT + ); + }); +} + +/** + * Filters keys deployed in the last 7 days. + */ +export function filterNewLaunches(creators: Course[]): Course[] { + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + return creators.filter(creator => { + const dateStr = + creator.createdAt || creator.joinedAt || creator.nextDropAt; + if (!dateStr) return false; + const createdTime = new Date(dateStr); + return ( + createdTime >= sevenDaysAgo && !Number.isNaN(createdTime.getTime()) + ); + }); +} + +/** + * Hook for key discovery page (#937) and discovery feed. * * Provides: * - Top 5 keys by 24h volume (Trending) * - Latest 10 keys ordered by creation date descending (New Listings) + * - Keys within 10% of graduation milestone * - Automatic background data refresh every 60 seconds without full page reload */ export function useKeyDiscovery(options?: UseKeyDiscoveryOptions) { @@ -100,3 +141,53 @@ export function useKeyDiscovery(options?: UseKeyDiscoveryOptions) { refetch, }; } + +/** + * Hook for discovery feed with trending, new launches, and graduating soon sections. + * Refreshes every 5 minutes. + */ +export function useDiscoveryFeed() { + const FIVE_MINUTES_MS = 5 * 60 * 1000; + + const allKeysQuery = useQuery({ + queryKey: queryKeys.creators.list(), + queryFn: () => courseService.getCourses({}), + staleTime: FIVE_MINUTES_MS, + refetchInterval: FIVE_MINUTES_MS, + }); + + const trendingKeys = useMemo(() => { + if (!allKeysQuery.data) return []; + return sortTrendingKeys(allKeysQuery.data); + }, [allKeysQuery.data]); + + const newLaunches = useMemo(() => { + if (!allKeysQuery.data) return []; + const newKeys = filterNewLaunches(allKeysQuery.data); + return [...newKeys] + .sort( + (a, b) => + new Date(b.createdAt || b.joinedAt || '').getTime() - + new Date(a.createdAt || a.joinedAt || '').getTime() + ) + .slice(0, 5); + }, [allKeysQuery.data]); + + const graduatingSoon = useMemo(() => { + if (!allKeysQuery.data) return []; + return filterGraduatingSoon(allKeysQuery.data).slice(0, 5); + }, [allKeysQuery.data]); + + const isLoading = allKeysQuery.isLoading; + const isRefreshing = allKeysQuery.isFetching && !allKeysQuery.isLoading; + const error = allKeysQuery.error; + + return { + trendingKeys, + newLaunches, + graduatingSoon, + isLoading, + isRefreshing, + error, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index d7b8d133..e0267ee4 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -75,10 +75,10 @@ export const queryKeys = { activity: (address: string) => ['wallet', address, 'activity'] as const, tradeHistory: (address: string) => ['wallet', address, 'tradeHistory'] as const, - vestingPositions: (address: string) => - ['wallet', address, 'vestingPositions'] as const, - stakingPositions: (address: string) => - ['wallet', address, 'stakingPositions'] as const, + vestingPositions: (address: string) => + ['wallet', address, 'vestingPositions'] as const, + stakingPositions: (address: string) => + ['wallet', address, 'stakingPositions'] as const, xlmBalance: (address: string) => ['wallet', address, 'xlmBalance'] as const, }, @@ -103,7 +103,8 @@ export const queryKeys = { }, admin: { treasury: () => ['admin', 'treasury', 'balance'] as const, - treasuryDistributions: () => ['admin', 'treasury', 'distributions'] as const, + treasuryDistributions: () => + ['admin', 'treasury', 'distributions'] as const, treasuryFees: () => ['admin', 'treasury', 'fees'] as const, oracleCallers: () => ['admin', 'oracle', 'callers'] as const, multiSigPending: () => ['admin', 'multisig', 'pending'] as const, @@ -147,8 +148,7 @@ export const queryKeys = { }, staking: { all: () => ['staking'] as const, - dashboard: (wallet: string) => - ['staking', wallet, 'dashboard'] as const, + dashboard: (wallet: string) => ['staking', wallet, 'dashboard'] as const, }, atomicSwap: { all: () => ['atomic-swap'] as const, @@ -187,4 +187,11 @@ export const queryKeys = { stakes: (wallet: string, keyId: string) => ['staking-vault', 'stakes', wallet, keyId] as const, }, + autoInvest: { + all: () => ['auto-invest'] as const, + rules: () => ['auto-invest', 'rules'] as const, + history: (limit?: number) => + ['auto-invest', 'history', limit ?? 50] as const, + settings: () => ['auto-invest', 'settings'] as const, + }, } as const; diff --git a/src/pages/AutoInvestDashboardPage.tsx b/src/pages/AutoInvestDashboardPage.tsx new file mode 100644 index 00000000..b99557da --- /dev/null +++ b/src/pages/AutoInvestDashboardPage.tsx @@ -0,0 +1,243 @@ +import { useState } from 'react'; +import { Plus, Trash2, Edit2, ToggleLeft, ToggleRight } from 'lucide-react'; +import { + useAutoInvestRules, + useAutoInvestHistory, + useAutoInvestSettings, + useToggleAutoInvestRule, + useDeleteAutoInvestRule, + useUpdateAutoInvestSettings, +} from '@/hooks/useAutoInvest'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import AutoInvestRuleForm from '@/components/autoInvest/AutoInvestRuleForm'; +import AutoInvestHistoryTable from '@/components/autoInvest/AutoInvestHistoryTable'; +import BalanceThresholdSettings from '@/components/autoInvest/BalanceThresholdSettings'; + +export default function AutoInvestDashboardPage() { + useNavigationTiming('auto-invest-dashboard'); + useDocumentTitle('Auto-Invest Dashboard — AccessLayer'); + + const [showRuleForm, setShowRuleForm] = useState(false); + const [editingRuleId, setEditingRuleId] = useState(null); + + const { data: rules = [], isLoading: rulesLoading } = useAutoInvestRules(); + const { data: history = [], isLoading: historyLoading } = + useAutoInvestHistory(); + const { data: settings } = useAutoInvestSettings(); + const toggleRule = useToggleAutoInvestRule(); + const deleteRule = useDeleteAutoInvestRule(); + const updateSettings = useUpdateAutoInvestSettings(); + + const editingRule = editingRuleId + ? rules.find(r => r.id === editingRuleId) || undefined + : undefined; + + const handleToggleRule = (ruleId: string, currentEnabled: boolean) => { + toggleRule.mutate({ ruleId, enabled: !currentEnabled }); + }; + + const handleDeleteRule = (ruleId: string) => { + if (confirm('Are you sure you want to delete this rule?')) { + deleteRule.mutate(ruleId); + } + }; + + const handleCloseForm = () => { + setShowRuleForm(false); + setEditingRuleId(null); + }; + + return ( +
+
+ {/* Header */} +
+

+ Auto-Invest Dashboard +

+

+ Configure automatic purchase rules for new creator keys that + match your preferences. +

+
+ + {/* Settings Section */} + + + updateSettings.mutate({ balanceThreshold: threshold }) + } + isUpdating={updateSettings.isPending} + /> + + + {/* Rules Section */} + +
+
+
+

+ Investment Rules +

+

+ {rules.length} rule{rules.length !== 1 ? 's' : ''}{' '} + configured +

+
+ {!showRuleForm && ( + + )} +
+ + {/* Rule Form */} + {showRuleForm && ( +
+ +
+ )} + + {/* Rules List */} + {rulesLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : rules.length === 0 ? ( +
+

+ No investment rules yet. Create one to get started. +

+
+ ) : ( +
+ {rules.map(rule => ( +
+
+
+

+ {rule.name} +

+ + {rule.enabled ? 'Active' : 'Inactive'} + +
+
+ {rule.category && ( +

Category: {rule.category}

+ )} + {rule.minRating !== undefined && ( +

Min Rating: {rule.minRating}★

+ )} +

Max Price: {rule.maxPrice} XLM

+ {rule.minUniqueTradersVolumeXlm !== + undefined && ( +

+ Min Volume:{' '} + {formatNumber( + rule.minUniqueTradersVolumeXlm + )}{' '} + XLM +

+ )} +

Max Per Key: {rule.maxPerKey} XLM

+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ + {/* History Section */} + +
+
+

+ Execution History +

+

+ Recent auto-invest purchases triggered by your rules +

+
+ + {historyLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 11b0b11b..c50b052b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -8,6 +8,7 @@ import MarketOverview from '../components/home/MarketOverview'; import TrendingCreators from '../components/home/TrendingCreators'; import TrendingLeaderboard from '../components/home/TrendingLeaderboard'; import RecentlyViewedSection from '../components/home/RecentlyViewedSection'; +import DiscoveryFeed from '../components/home/DiscoveryFeed'; import { useNavigationTiming } from '../hooks/useNavigationTiming'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import { useLocation } from 'react-router'; @@ -35,6 +36,9 @@ export default function HomePage() { + + + diff --git a/src/routes.tsx b/src/routes.tsx index 70e0e5af..bc9510f5 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -28,6 +28,7 @@ import HolderLeaderboardPage from './pages/HolderLeaderboardPage'; import BundlesPage from './pages/BundlesPage'; import BundleDetailPage from './pages/BundleDetailPage'; import SearchResultsPage from './pages/SearchResultsPage'; +import AutoInvestDashboardPage from './pages/AutoInvestDashboardPage'; import RouteErrorBoundary from './components/common/RouteErrorBoundary'; function withRouteErrorBoundary(element: ReactNode, routeName: string) { @@ -178,6 +179,10 @@ export const routes = [ path: '/staking', element: , }, + { + path: '/auto-invest', + element: , + }, { path: '/swap/create', element: , diff --git a/src/services/autoInvest.service.ts b/src/services/autoInvest.service.ts new file mode 100644 index 00000000..261cc4f1 --- /dev/null +++ b/src/services/autoInvest.service.ts @@ -0,0 +1,145 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import type { + AutoInvestRule, + AutoInvestHistory, + AutoInvestSettings, +} from '@/types/autoInvest'; + +export class AutoInvestService extends BaseApiService { + /** + * Get all auto-invest rules for the current user + */ + async getRules(): Promise { + try { + const response = + await this.api.get>( + '/auto-invest/rules' + ); + return response.data?.data || []; + } catch (error) { + this.handleError(error); + return []; + } + } + + /** + * Create a new auto-invest rule + */ + async createRule( + rule: Omit + ) { + try { + const response = await this.api.post>( + '/auto-invest/rules', + rule + ); + return response.data?.data; + } catch (error) { + this.handleError(error); + throw error; + } + } + + /** + * Update an auto-invest rule + */ + async updateRule(ruleId: string, updates: Partial) { + try { + const response = await this.api.put>( + `/auto-invest/rules/${ruleId}`, + updates + ); + return response.data?.data; + } catch (error) { + this.handleError(error); + throw error; + } + } + + /** + * Delete an auto-invest rule + */ + async deleteRule(ruleId: string) { + try { + await this.api.delete(`/auto-invest/rules/${ruleId}`); + } catch (error) { + this.handleError(error); + throw error; + } + } + + /** + * Toggle rule enabled/disabled status + */ + async toggleRule(ruleId: string, enabled: boolean) { + try { + const response = await this.api.patch>( + `/auto-invest/rules/${ruleId}`, + { enabled } + ); + return response.data?.data; + } catch (error) { + this.handleError(error); + throw error; + } + } + + /** + * Get auto-invest history + */ + async getHistory( + limit: number = 50, + offset: number = 0 + ): Promise { + try { + const response = await this.api.get>( + `/auto-invest/history?limit=${limit}&offset=${offset}` + ); + return response.data?.data || []; + } catch (error) { + this.handleError(error); + return []; + } + } + + /** + * Get auto-invest settings + */ + async getSettings(): Promise { + try { + const response = await this.api.get>( + '/auto-invest/settings' + ); + return ( + response.data?.data || { + balanceThreshold: 0, + paused: false, + } + ); + } catch (error) { + this.handleError(error); + return { + balanceThreshold: 0, + paused: false, + }; + } + } + + /** + * Update auto-invest settings + */ + async updateSettings(settings: Partial) { + try { + const response = await this.api.put>( + '/auto-invest/settings', + settings + ); + return response.data?.data; + } catch (error) { + this.handleError(error); + throw error; + } + } +} + +export const autoInvestService = new AutoInvestService(); diff --git a/src/types/autoInvest.ts b/src/types/autoInvest.ts new file mode 100644 index 00000000..9ffa5823 --- /dev/null +++ b/src/types/autoInvest.ts @@ -0,0 +1,42 @@ +/** + * Auto-invest rules configuration types + */ + +export interface AutoInvestRule { + id: string; + name: string; + enabled: boolean; + category?: string; + minRating?: number; + maxPrice: number; + minUniqueTradersVolumeXlm?: number; + maxPerKey: number; + createdAt: string; + updatedAt: string; +} + +export interface AutoInvestHistory { + id: string; + ruleId: string; + ruleName: string; + creatorKeyId: string; + creatorKeyTitle: string; + amountXlm: number; + executedAt: string; + status: 'success' | 'failed' | 'pending'; + errorMessage?: string; +} + +export interface AutoInvestSettings { + balanceThreshold: number; // Minimum balance in XLM to maintain + paused: boolean; +} + +export interface AutoInvestExecutionResult { + success: boolean; + ruleId: string; + ruleName: string; + creatorKeyId: string; + amountXlm: number; + message: string; +}