From 488bb7bbd51113aa70a3d3b3256eecce1160d94e Mon Sep 17 00:00:00 2001 From: Abidoyesimze <154632691+Abidoyesimze@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:21:33 +0100 Subject: [PATCH 1/5] feat: add notification preferences settings page (#1083) Per-type email and in-app toggles with a bulk enable/disable switch. Changes save immediately; failed saves roll back and offer a retry. Co-Authored-By: Claude Opus 5.5 --- src/components/ui/switch.tsx | 50 ++++ src/constants/notificationPreferences.ts | 39 ++++ src/hooks/useNotificationPreferences.ts | 220 +++++++++++++++++ src/pages/NotificationPreferencesPage.tsx | 219 +++++++++++++++++ src/pages/NotificationsPage.tsx | 8 +- .../NotificationPreferencesPage.test.tsx | 221 ++++++++++++++++++ src/routes.tsx | 5 + .../notificationPreferences.service.ts | 64 +++++ 8 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/switch.tsx create mode 100644 src/constants/notificationPreferences.ts create mode 100644 src/hooks/useNotificationPreferences.ts create mode 100644 src/pages/NotificationPreferencesPage.tsx create mode 100644 src/pages/__tests__/NotificationPreferencesPage.test.tsx create mode 100644 src/services/notificationPreferences.service.ts diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 00000000..81cf9705 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface SwitchProps extends Omit< + React.ButtonHTMLAttributes, + 'onChange' +> { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +/** + * Accessible on/off switch. Renders a native button with `role="switch"` + * so it is keyboard operable (Space/Enter) and announces its state. + */ +function Switch({ + checked, + onCheckedChange, + className, + disabled, + ...props +}: SwitchProps) { + return ( + + ); +} + +export { Switch }; diff --git a/src/constants/notificationPreferences.ts b/src/constants/notificationPreferences.ts new file mode 100644 index 00000000..c09ad914 --- /dev/null +++ b/src/constants/notificationPreferences.ts @@ -0,0 +1,39 @@ +import type { NotificationPreferenceType } from '@/services/notificationPreferences.service'; + +export const NOTIFICATION_PREFERENCE_COPY: Record< + NotificationPreferenceType, + { label: string; description: string } +> = { + price_alert: { + label: 'Price alert triggered', + description: 'A key you set an alert on crosses your target price.', + }, + milestone_reached: { + label: 'Milestone reached', + description: 'A creator you hold hits a bonding curve milestone.', + }, + vesting_cliff: { + label: 'Vesting cliff', + description: 'A vesting cliff on your keys is about to unlock.', + }, + staking_reward: { + label: 'Staking reward', + description: 'New staking rewards are ready to claim.', + }, + governance_vote: { + label: 'Governance vote', + description: 'A vote opens or closes for a creator you hold.', + }, + referral_earned: { + label: 'Referral earned', + description: 'Someone you referred makes their first trade.', + }, + key_deprecated: { + label: 'Key deprecated', + description: 'A creator key you hold is deprecated or migrated.', + }, +}; + +export const SAVE_SUCCESS_COPY = 'Notification preferences saved'; +export const SAVE_ERROR_COPY = + "We couldn't save your notification preferences."; diff --git a/src/hooks/useNotificationPreferences.ts b/src/hooks/useNotificationPreferences.ts new file mode 100644 index 00000000..3b438d04 --- /dev/null +++ b/src/hooks/useNotificationPreferences.ts @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + NOTIFICATION_CHANNELS, + NOTIFICATION_PREFERENCE_TYPES, + notificationPreferencesService, + type NotificationChannel, + type NotificationPreferences, + type NotificationPreferenceType, +} from '@/services/notificationPreferences.service'; + +/** + * Channel defaults used when the API omits a notification type (e.g. a + * type added on the client before the backend knows about it). In-app is + * on so users still see the event; email stays opt-in. + */ +const DEFAULT_CHANNEL_PREFERENCES = { email: false, inApp: true } as const; + +/** + * Fills any notification types or channels missing from the API payload so + * every row always renders with a defined boolean state. + */ +export function normalizeNotificationPreferences( + raw: + | Partial>>> + | null + | undefined +): NotificationPreferences { + const result = {} as NotificationPreferences; + for (const type of NOTIFICATION_PREFERENCE_TYPES) { + const source = raw?.[type] ?? {}; + result[type] = { + email: + typeof source.email === 'boolean' + ? source.email + : DEFAULT_CHANNEL_PREFERENCES.email, + inApp: + typeof source.inApp === 'boolean' + ? source.inApp + : DEFAULT_CHANNEL_PREFERENCES.inApp, + }; + } + return result; +} + +export function areAllNotificationsEnabled( + preferences: NotificationPreferences +): boolean { + return NOTIFICATION_PREFERENCE_TYPES.every(type => + NOTIFICATION_CHANNELS.every(channel => preferences[type][channel]) + ); +} + +export function setAllNotificationPreferences( + enabled: boolean +): NotificationPreferences { + const result = {} as NotificationPreferences; + for (const type of NOTIFICATION_PREFERENCE_TYPES) { + result[type] = { email: enabled, inApp: enabled }; + } + return result; +} + +export type PreferencesLoadStatus = 'loading' | 'ready' | 'error'; +export type PreferencesSaveStatus = 'idle' | 'saving' | 'error'; + +export interface UseNotificationPreferencesOptions { + /** Injectable for tests; defaults to the API service. */ + fetchPreferences?: () => Promise; + /** Injectable for tests; defaults to the API service. */ + updatePreferences?: ( + preferences: NotificationPreferences + ) => Promise; + onSaveSuccess?: () => void; + onSaveError?: (error: unknown) => void; +} + +export interface UseNotificationPreferencesResult { + preferences: NotificationPreferences | null; + loadStatus: PreferencesLoadStatus; + saveStatus: PreferencesSaveStatus; + allEnabled: boolean; + setChannel: ( + type: NotificationPreferenceType, + channel: NotificationChannel, + enabled: boolean + ) => void; + setAll: (enabled: boolean) => void; + /** Re-sends the last change that failed to save. */ + retrySave: () => void; + /** Re-fetches preferences after a failed initial load. */ + reload: () => void; +} + +/** + * Loads the user's notification preferences and saves every change + * immediately. Changes are applied optimistically; if a save fails the UI + * rolls back to the last server-confirmed state and the failed change is + * kept so `retrySave` can re-send it. + */ +export function useNotificationPreferences({ + fetchPreferences = () => notificationPreferencesService.getPreferences(), + updatePreferences = preferences => + notificationPreferencesService.updatePreferences(preferences), + onSaveSuccess, + onSaveError, +}: UseNotificationPreferencesOptions = {}): UseNotificationPreferencesResult { + const [preferences, setPreferences] = + useState(null); + const [loadStatus, setLoadStatus] = + useState('loading'); + const [saveStatus, setSaveStatus] = useState('idle'); + const [reloadToken, setReloadToken] = useState(0); + + const confirmedRef = useRef(null); + const failedRef = useRef(null); + const latestRequestRef = useRef(0); + + // Keep the latest callbacks without re-running the load effect. + const fetchRef = useRef(fetchPreferences); + const updateRef = useRef(updatePreferences); + const onSaveSuccessRef = useRef(onSaveSuccess); + const onSaveErrorRef = useRef(onSaveError); + useEffect(() => { + fetchRef.current = fetchPreferences; + updateRef.current = updatePreferences; + onSaveSuccessRef.current = onSaveSuccess; + onSaveErrorRef.current = onSaveError; + }); + + useEffect(() => { + let cancelled = false; + setLoadStatus('loading'); + + fetchRef + .current() + .then(data => { + if (cancelled) return; + const normalized = normalizeNotificationPreferences(data); + confirmedRef.current = normalized; + setPreferences(normalized); + setLoadStatus('ready'); + }) + .catch(() => { + if (cancelled) return; + setLoadStatus('error'); + }); + + return () => { + cancelled = true; + }; + }, [reloadToken]); + + const save = useCallback((next: NotificationPreferences) => { + const requestId = ++latestRequestRef.current; + setPreferences(next); + setSaveStatus('saving'); + + updateRef + .current(next) + .then(saved => { + const confirmed = saved + ? normalizeNotificationPreferences(saved) + : next; + confirmedRef.current = confirmed; + // A newer change is already in flight; let it own the UI. + if (requestId !== latestRequestRef.current) return; + failedRef.current = null; + setPreferences(confirmed); + setSaveStatus('idle'); + onSaveSuccessRef.current?.(); + }) + .catch(error => { + if (requestId !== latestRequestRef.current) return; + failedRef.current = next; + setPreferences(confirmedRef.current); + setSaveStatus('error'); + onSaveErrorRef.current?.(error); + }); + }, []); + + const setChannel = useCallback( + ( + type: NotificationPreferenceType, + channel: NotificationChannel, + enabled: boolean + ) => { + if (!preferences) return; + save({ + ...preferences, + [type]: { ...preferences[type], [channel]: enabled }, + }); + }, + [preferences, save] + ); + + const setAll = useCallback( + (enabled: boolean) => { + if (!preferences) return; + save(setAllNotificationPreferences(enabled)); + }, + [preferences, save] + ); + + const retrySave = useCallback(() => { + if (failedRef.current) save(failedRef.current); + }, [save]); + + const reload = useCallback(() => setReloadToken(token => token + 1), []); + + return { + preferences, + loadStatus, + saveStatus, + allEnabled: preferences ? areAllNotificationsEnabled(preferences) : false, + setChannel, + setAll, + retrySave, + reload, + }; +} diff --git a/src/pages/NotificationPreferencesPage.tsx b/src/pages/NotificationPreferencesPage.tsx new file mode 100644 index 00000000..00c4a803 --- /dev/null +++ b/src/pages/NotificationPreferencesPage.tsx @@ -0,0 +1,219 @@ +import { AlertCircle, BellRing } from 'lucide-react'; +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { + useNotificationPreferences, + type UseNotificationPreferencesOptions, +} from '@/hooks/useNotificationPreferences'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; +import { + NOTIFICATION_PREFERENCE_TYPES, + type NotificationChannel, +} from '@/services/notificationPreferences.service'; +import { + NOTIFICATION_PREFERENCE_COPY, + SAVE_ERROR_COPY, + SAVE_SUCCESS_COPY, +} from '@/constants/notificationPreferences'; +import showToast from '@/utils/toast.util'; + +const CHANNEL_LABELS: Record = { + email: 'Email', + inApp: 'In-app', +}; + +type NotificationPreferencesPageProps = Pick< + UseNotificationPreferencesOptions, + 'fetchPreferences' | 'updatePreferences' +>; + +export default function NotificationPreferencesPage({ + fetchPreferences, + updatePreferences, +}: NotificationPreferencesPageProps = {}) { + useNavigationTiming('notification_preferences'); + + const { + preferences, + loadStatus, + saveStatus, + allEnabled, + setChannel, + setAll, + retrySave, + reload, + } = useNotificationPreferences({ + fetchPreferences, + updatePreferences, + onSaveSuccess: () => showToast.success(SAVE_SUCCESS_COPY), + onSaveError: () => showToast.error(SAVE_ERROR_COPY), + }); + + return ( +
+ + +
+
+ + {loadStatus === 'loading' && ( +
+ {NOTIFICATION_PREFERENCE_TYPES.map(type => ( +
+ ))} +
+ )} + + {loadStatus === 'error' && ( +
+
+ )} + + {loadStatus === 'ready' && preferences && ( + <> + {saveStatus === 'error' && ( +
+ + + +
+ )} + +
+
+

+ All notifications +

+

+ {allEnabled + ? 'Every type is on for email and in-app.' + : 'Turn every type on for both channels.'} +

+
+
+ + {saveStatus === 'saving' ? 'Saving…' : ''} + + +
+
+ + + + + + {( + Object.keys(CHANNEL_LABELS) as NotificationChannel[] + ).map(channel => ( + + ))} + + + + {NOTIFICATION_PREFERENCE_TYPES.map(type => { + const copy = NOTIFICATION_PREFERENCE_COPY[type]; + return ( + + + {( + Object.keys( + CHANNEL_LABELS + ) as NotificationChannel[] + ).map(channel => ( + + ))} + + ); + })} + +
+ Event + + {CHANNEL_LABELS[channel]} +
+

+ {copy.label} +

+

+ {copy.description} +

+
+ + setChannel(type, channel, enabled) + } + aria-label={`${copy.label} ${CHANNEL_LABELS[channel].toLowerCase()} notifications`} + /> +
+ + )} +
+ ); +} diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 71a003dc..410010bb 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -1,5 +1,5 @@ import { Bell } from 'lucide-react'; -import { useNavigate } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { cn } from '@/lib/utils'; import { useNotifications } from '@/hooks/useNotifications'; import { useProfileStore } from '@/hooks/useProfileStore'; @@ -36,6 +36,12 @@ export default function NotificationsPage() { {unreadCount} unread )} + + Preferences + {isLoading && ( diff --git a/src/pages/__tests__/NotificationPreferencesPage.test.tsx b/src/pages/__tests__/NotificationPreferencesPage.test.tsx new file mode 100644 index 00000000..166445b6 --- /dev/null +++ b/src/pages/__tests__/NotificationPreferencesPage.test.tsx @@ -0,0 +1,221 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import NotificationPreferencesPage from '@/pages/NotificationPreferencesPage'; +import { + NOTIFICATION_PREFERENCE_COPY, + SAVE_ERROR_COPY, + SAVE_SUCCESS_COPY, +} from '@/constants/notificationPreferences'; +import { + NOTIFICATION_PREFERENCE_TYPES, + type NotificationPreferences, +} from '@/services/notificationPreferences.service'; +import { setAllNotificationPreferences } from '@/hooks/useNotificationPreferences'; + +const { toastSuccess, toastError } = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { success: toastSuccess, error: toastError }, +})); + +function makePreferences(): NotificationPreferences { + return { + ...setAllNotificationPreferences(false), + price_alert: { email: true, inApp: true }, + staking_reward: { email: false, inApp: true }, + }; +} + +function getSwitch(name: string) { + return screen.getByRole('switch', { name }); +} + +function renderPage( + fetchPreferences = vi.fn(async () => makePreferences()), + updatePreferences = vi.fn(async (next: NotificationPreferences) => next) +) { + render( + + + + ); + return { fetchPreferences, updatePreferences }; +} + +// Multi-step render flows; allow headroom on loaded CI runners. +describe('NotificationPreferencesPage', { timeout: 15_000 }, () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders every notification type with the state returned by the API', async () => { + renderPage(); + + await screen.findByText('Price alert triggered'); + + for (const type of NOTIFICATION_PREFERENCE_TYPES) { + expect( + screen.getByText(NOTIFICATION_PREFERENCE_COPY[type].label) + ).toBeInTheDocument(); + } + + expect( + getSwitch('Price alert triggered email notifications') + ).toHaveAttribute('aria-checked', 'true'); + expect(getSwitch('Staking reward email notifications')).toHaveAttribute( + 'aria-checked', + 'false' + ); + expect(getSwitch('Staking reward in-app notifications')).toHaveAttribute( + 'aria-checked', + 'true' + ); + expect(getSwitch('Key deprecated in-app notifications')).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('fills in types the API omits instead of crashing', async () => { + renderPage( + vi.fn( + async () => + ({ + price_alert: { email: true, inApp: false }, + }) as unknown as NotificationPreferences + ) + ); + + await screen.findByText('Key deprecated'); + expect( + getSwitch('Price alert triggered in-app notifications') + ).toHaveAttribute('aria-checked', 'false'); + expect(getSwitch('Key deprecated in-app notifications')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + + it('saves a single per-type, per-channel toggle and confirms with a toast', async () => { + const { updatePreferences } = renderPage(); + await screen.findByText('Governance vote'); + + fireEvent.click(getSwitch('Governance vote email notifications')); + + expect(getSwitch('Governance vote email notifications')).toHaveAttribute( + 'aria-checked', + 'true' + ); + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith(SAVE_SUCCESS_COPY) + ); + + expect(updatePreferences).toHaveBeenCalledTimes(1); + const sent = updatePreferences.mock.calls[0][0]; + expect(sent.governance_vote).toEqual({ email: true, inApp: false }); + // Other rows are untouched. + expect(sent.price_alert).toEqual({ email: true, inApp: true }); + expect(sent.staking_reward).toEqual({ email: false, inApp: true }); + }); + + it('bulk toggle enables every type on both channels', async () => { + const { updatePreferences } = renderPage(); + await screen.findByText('Governance vote'); + + const bulk = getSwitch('All notifications'); + expect(bulk).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(bulk); + + await waitFor(() => expect(toastSuccess).toHaveBeenCalled()); + expect(updatePreferences).toHaveBeenLastCalledWith( + setAllNotificationPreferences(true) + ); + expect(getSwitch('All notifications')).toHaveAttribute( + 'aria-checked', + 'true' + ); + for (const sw of screen.getAllByRole('switch')) { + expect(sw).toHaveAttribute('aria-checked', 'true'); + } + }); + + it('bulk toggle disables every type when all are enabled', async () => { + const { updatePreferences } = renderPage( + vi.fn(async () => setAllNotificationPreferences(true)) + ); + await screen.findByText('Governance vote'); + + const bulk = getSwitch('All notifications'); + expect(bulk).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(bulk); + + await waitFor(() => expect(toastSuccess).toHaveBeenCalled()); + expect(updatePreferences).toHaveBeenLastCalledWith( + setAllNotificationPreferences(false) + ); + for (const sw of screen.getAllByRole('switch')) { + expect(sw).toHaveAttribute('aria-checked', 'false'); + } + }); + + it('rolls back a failed save, shows an error, and retries the same change', async () => { + const updatePreferences = vi + .fn< + (next: NotificationPreferences) => Promise + >() + .mockRejectedValueOnce(new Error('Network error')) + .mockImplementation(async next => next); + renderPage(undefined, updatePreferences); + await screen.findByText('Vesting cliff'); + + fireEvent.click(getSwitch('Vesting cliff email notifications')); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(SAVE_ERROR_COPY); + expect(toastError).toHaveBeenCalledWith(SAVE_ERROR_COPY); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(getSwitch('Vesting cliff email notifications')).toHaveAttribute( + 'aria-checked', + 'false' + ); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith(SAVE_SUCCESS_COPY) + ); + expect(updatePreferences).toHaveBeenCalledTimes(2); + expect(updatePreferences.mock.calls[1][0].vesting_cliff).toEqual({ + email: true, + inApp: false, + }); + expect(getSwitch('Vesting cliff email notifications')).toHaveAttribute( + 'aria-checked', + 'true' + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows a load error with a working retry', async () => { + const fetchPreferences = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue(makePreferences()); + renderPage(fetchPreferences); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/couldn't load/i); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + await screen.findByText('Price alert triggered'); + expect(fetchPreferences).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index b4add511..d8ee3b61 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -2,6 +2,7 @@ import HomePage from './pages/HomePage'; import NotFoundPage from './pages/NotFoundPage'; import CreatorDetailPage from './pages/CreatorDetailPage'; import NotificationsPage from './pages/NotificationsPage'; +import NotificationPreferencesPage from './pages/NotificationPreferencesPage'; export const routes = [ { @@ -24,6 +25,10 @@ export const routes = [ path: '/notifications', element: , }, + { + path: '/settings/notifications', + element: , + }, { path: '*', element: , diff --git a/src/services/notificationPreferences.service.ts b/src/services/notificationPreferences.service.ts new file mode 100644 index 00000000..ab103174 --- /dev/null +++ b/src/services/notificationPreferences.service.ts @@ -0,0 +1,64 @@ +// src/services/notificationPreferences.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** Platform events a user can opt in or out of being notified about. */ +export const NOTIFICATION_PREFERENCE_TYPES = [ + 'price_alert', + 'milestone_reached', + 'vesting_cliff', + 'staking_reward', + 'governance_vote', + 'referral_earned', + 'key_deprecated', +] as const; + +export type NotificationPreferenceType = + (typeof NOTIFICATION_PREFERENCE_TYPES)[number]; + +/** Delivery channels each notification type can be routed to. */ +export const NOTIFICATION_CHANNELS = ['email', 'inApp'] as const; + +export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]; + +export type ChannelPreferences = Record; + +export type NotificationPreferences = Record< + NotificationPreferenceType, + ChannelPreferences +>; + +class NotificationPreferencesService extends BaseApiService { + /** Fetch the current user's preferences — GET /notifications/preferences */ + async getPreferences(): Promise { + try { + const response = await this.api.get< + APIResponse + >('/notifications/preferences'); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Replace the current user's preferences — PUT /notifications/preferences + * + * The full map is sent on every save so the server never has to merge + * partial updates that may arrive out of order. + */ + async updatePreferences( + preferences: NotificationPreferences + ): Promise { + try { + const response = await this.api.put< + APIResponse + >('/notifications/preferences', preferences); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const notificationPreferencesService = + new NotificationPreferencesService(); From a2a090eeddd1c61640530f5ec0337587391a5547 Mon Sep 17 00:00:00 2001 From: Abidoyesimze <154632691+Abidoyesimze@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:21:37 +0100 Subject: [PATCH 2/5] feat: add transaction history page with filters and CSV export (#1084) Lists buys, sells, stakes, unstakes, claims, referral payouts and swaps with type and date range filters, cursor-based load more (deduplicated) and CSV export of the filtered view. Co-Authored-By: Claude Opus 5.5 --- src/components/common/WalletStatusChip.tsx | 9 +- src/hooks/useTransactionHistory.ts | 155 +++++++ src/pages/TransactionHistoryPage.tsx | 393 ++++++++++++++++++ .../__tests__/TransactionHistoryPage.test.tsx | 296 +++++++++++++ src/routes.tsx | 5 + src/services/transactionHistory.service.ts | 78 ++++ .../transactionHistory.utils.test.ts | 162 ++++++++ src/utils/transactionHistory.utils.ts | 236 +++++++++++ 8 files changed, 1333 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useTransactionHistory.ts create mode 100644 src/pages/TransactionHistoryPage.tsx create mode 100644 src/pages/__tests__/TransactionHistoryPage.test.tsx create mode 100644 src/services/transactionHistory.service.ts create mode 100644 src/utils/__tests__/transactionHistory.utils.test.ts create mode 100644 src/utils/transactionHistory.utils.ts diff --git a/src/components/common/WalletStatusChip.tsx b/src/components/common/WalletStatusChip.tsx index bd8c5c5e..9c599b9a 100644 --- a/src/components/common/WalletStatusChip.tsx +++ b/src/components/common/WalletStatusChip.tsx @@ -1,6 +1,6 @@ import { useAccount, useChainId, useDisconnect } from 'wagmi'; import { Link } from 'react-router'; -import { Copy, Check, LogOut } from 'lucide-react'; +import { Copy, Check, History, LogOut } from 'lucide-react'; import { useState } from 'react'; import { DropdownMenu, @@ -97,6 +97,13 @@ export function WalletStatusChip({ className = '' }: { className?: string }) { {copied ? 'Copied' : 'Copy address'} + + + + Transaction history + + + disconnect()} className="text-red-600 focus:text-red-600" diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts new file mode 100644 index 00000000..d8a4137b --- /dev/null +++ b/src/hooks/useTransactionHistory.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + transactionHistoryService, + type GetWalletTransactionsParams, + type WalletTransaction, + type WalletTransactionPage, +} from '@/services/transactionHistory.service'; +import { + getTypesForFilter, + matchesTransactionFilters, + mergeTransactionPages, + type ResolvedDateRange, + type TransactionTypeFilter, +} from '@/utils/transactionHistory.utils'; + +export type FetchTransactionPage = ( + params: GetWalletTransactionsParams +) => Promise; + +export interface UseTransactionHistoryOptions { + address: string | undefined; + filter: TransactionTypeFilter; + /** `null` while a custom range is incomplete; nothing is fetched. */ + range: ResolvedDateRange | null; + /** Injectable for tests; defaults to the API service. */ + fetchPage?: FetchTransactionPage; +} + +export type TransactionHistoryStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface UseTransactionHistoryResult { + transactions: WalletTransaction[]; + status: TransactionHistoryStatus; + hasMore: boolean; + isLoadingMore: boolean; + loadMoreError: boolean; + loadMore: () => void; + retry: () => void; +} + +/** + * Cursor-paginated wallet activity. Changing the address, type filter or + * date range resets the list and fetches a fresh first page; `loadMore` + * appends the next page, skipping any rows already shown. + */ +export function useTransactionHistory({ + address, + filter, + range, + fetchPage = params => transactionHistoryService.getTransactions(params), +}: UseTransactionHistoryOptions): UseTransactionHistoryResult { + const [transactions, setTransactions] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [status, setStatus] = useState('idle'); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(false); + const [retryToken, setRetryToken] = useState(0); + + // Bumped whenever the query changes so late responses from a previous + // filter/range never leak into the current list. + const generationRef = useRef(0); + const fetchPageRef = useRef(fetchPage); + useEffect(() => { + fetchPageRef.current = fetchPage; + }); + + const from = range?.from; + const to = range?.to; + const hasRange = range !== null; + + const buildParams = useCallback( + (cursor: string | null): GetWalletTransactionsParams => ({ + address: address ?? '', + types: getTypesForFilter(filter), + from, + to, + cursor, + }), + [address, filter, from, to] + ); + + const keepMatching = useCallback( + (items: WalletTransaction[]) => + items.filter(tx => + matchesTransactionFilters(tx, filter, { from, to }) + ), + [filter, from, to] + ); + + useEffect(() => { + const generation = ++generationRef.current; + setTransactions([]); + setNextCursor(null); + setIsLoadingMore(false); + setLoadMoreError(false); + + if (!address || !hasRange) { + setStatus('idle'); + return; + } + + setStatus('loading'); + fetchPageRef + .current(buildParams(null)) + .then(page => { + if (generation !== generationRef.current) return; + setTransactions( + mergeTransactionPages([], keepMatching(page.items)) + ); + setNextCursor(page.nextCursor); + setStatus('ready'); + }) + .catch(() => { + if (generation !== generationRef.current) return; + setStatus('error'); + }); + }, [address, hasRange, buildParams, keepMatching, retryToken]); + + const loadMore = useCallback(() => { + if (!nextCursor || isLoadingMore || status !== 'ready') return; + const generation = generationRef.current; + setIsLoadingMore(true); + setLoadMoreError(false); + + fetchPageRef + .current(buildParams(nextCursor)) + .then(page => { + if (generation !== generationRef.current) return; + setTransactions(current => + mergeTransactionPages(current, keepMatching(page.items)) + ); + setNextCursor(page.nextCursor); + }) + .catch(() => { + if (generation !== generationRef.current) return; + setLoadMoreError(true); + }) + .finally(() => { + if (generation !== generationRef.current) return; + setIsLoadingMore(false); + }); + }, [nextCursor, isLoadingMore, status, buildParams, keepMatching]); + + const retry = useCallback(() => setRetryToken(token => token + 1), []); + + return { + transactions, + status, + hasMore: nextCursor !== null, + isLoadingMore, + loadMoreError, + loadMore, + retry, + }; +} diff --git a/src/pages/TransactionHistoryPage.tsx b/src/pages/TransactionHistoryPage.tsx new file mode 100644 index 00000000..c2b40f65 --- /dev/null +++ b/src/pages/TransactionHistoryPage.tsx @@ -0,0 +1,393 @@ +import { useMemo, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { + AlertCircle, + ArrowLeftRight, + Download, + Gift, + History, + Lock, + ShoppingCart, + Tag, + Unlock, + Users, + type LucideIcon, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import ConnectWalletButton from '@/components/common/ConnectWalletButton'; +import Web3Provider from '@/providers/Web3Provider'; +import { cn } from '@/lib/utils'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; +import { + useTransactionHistory, + type FetchTransactionPage, +} from '@/hooks/useTransactionHistory'; +import type { + WalletTransaction, + WalletTransactionType, +} from '@/services/transactionHistory.service'; +import { + DATE_RANGE_PRESETS, + TRANSACTION_TYPE_FILTERS, + TRANSACTION_TYPE_LABELS, + buildTransactionCsvFilename, + describeTransaction, + downloadCsv, + resolveDateRange, + transactionsToCsv, + type CustomDateRange, + type DateRangePreset, + type TransactionTypeFilter, +} from '@/utils/transactionHistory.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatRelativeTime } from '@/utils/time.utils'; + +const TRANSACTION_TYPE_ICONS: Record = { + buy: ShoppingCart, + sell: Tag, + stake: Lock, + unstake: Unlock, + claim: Gift, + referral: Users, + swap: ArrowLeftRight, +}; + +const FILTER_PILL_CLASS = + 'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50'; + +function formatXlm(value: number) { + return `${formatNumber(value, { maximumFractionDigits: 4 })} XLM`; +} + +function TransactionRow({ tx }: { tx: WalletTransaction }) { + const Icon = TRANSACTION_TYPE_ICONS[tx.type]; + const isIn = tx.direction === 'in'; + + return ( +
  • + + +
    +

    + {describeTransaction(tx)} +

    +

    + {TRANSACTION_TYPE_LABELS[tx.type]} + + {tx.keyName} + + +

    +
    +
    +

    + {isIn ? 'Received' : 'Sent'} + {isIn ? '+' : '−'} + {formatXlm(tx.amount)} +

    +

    + Fee {formatXlm(tx.fee)} +

    +
    +
  • + ); +} + +export interface TransactionHistoryViewProps { + address: string | undefined; + fetchPage?: FetchTransactionPage; +} + +export function TransactionHistoryView({ + address, + fetchPage, +}: TransactionHistoryViewProps) { + const [filter, setFilter] = useState('all'); + const [preset, setPreset] = useState('30d'); + const [custom, setCustom] = useState({ + start: '', + end: '', + }); + + // Anchor "now" to when the preset/custom inputs change so the range is + // stable across re-renders and doesn't trigger refetch loops. + const range = useMemo( + () => resolveDateRange(preset, custom), + [preset, custom] + ); + + const { + transactions, + status, + hasMore, + isLoadingMore, + loadMoreError, + loadMore, + retry, + } = useTransactionHistory({ address, filter, range, fetchPage }); + + const customRangeInvalid = + preset === 'custom' && Boolean(custom.start && custom.end) && !range; + + const handleExport = () => { + downloadCsv( + buildTransactionCsvFilename(filter, preset), + transactionsToCsv(transactions) + ); + }; + + return ( + <> +
    +
    + {TRANSACTION_TYPE_FILTERS.map(option => ( + + ))} +
    + +
    +
    +
    + {DATE_RANGE_PRESETS.map(option => ( + + ))} +
    + {preset === 'custom' && ( +
    + + +
    + )} +
    + + +
    + + {customRangeInvalid && ( +

    + The start date must be on or before the end date. +

    + )} + {preset === 'custom' && !range && !customRangeInvalid && ( +

    + Pick a start and end date to see activity. +

    + )} +
    + + {status === 'loading' && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
    + ))} +
    + )} + + {status === 'error' && ( +
    +
    + )} + + {status === 'ready' && transactions.length === 0 && ( +
    +
    + )} + + {status === 'ready' && transactions.length > 0 && ( + <> +
      + {transactions.map(tx => ( + + ))} +
    +
    + {loadMoreError && ( +

    + Couldn't load more activity. Try again. +

    + )} + {hasMore ? ( + + ) : ( +

    + You've reached the end of your activity. +

    + )} +
    + + )} + + ); +} + +function TransactionHistoryPageContent() { + useNavigationTiming('transaction_history'); + const { address, isConnected } = useAccount(); + + return ( +
    +
    +
    + + {isConnected && address ? ( + + ) : ( +
    +

    + Connect your wallet to see your transaction history. +

    + +
    + )} +
    + ); +} + +/** + * Wallet providers are mounted per-route (see a066172) so wallet extensions + * only initialise on pages that need them. + */ +export default function TransactionHistoryPage() { + return ( + + + + ); +} diff --git a/src/pages/__tests__/TransactionHistoryPage.test.tsx b/src/pages/__tests__/TransactionHistoryPage.test.tsx new file mode 100644 index 00000000..c8cb17fe --- /dev/null +++ b/src/pages/__tests__/TransactionHistoryPage.test.tsx @@ -0,0 +1,296 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TransactionHistoryView } from '@/pages/TransactionHistoryPage'; +import type { + GetWalletTransactionsParams, + WalletTransaction, + WalletTransactionPage, + WalletTransactionType, +} from '@/services/transactionHistory.service'; +import * as historyUtils from '@/utils/transactionHistory.utils'; + +const ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const HOUR = 60 * 60 * 1000; + +const TYPES: WalletTransactionType[] = [ + 'buy', + 'sell', + 'stake', + 'unstake', + 'claim', + 'referral', + 'swap', +]; + +function makeTx( + id: string, + type: WalletTransactionType, + hoursAgo: number +): WalletTransaction { + return { + id, + type, + keyName: `Creator ${id}`, + amount: 10, + direction: type === 'buy' || type === 'stake' ? 'out' : 'in', + fee: 0.01, + timestamp: Date.now() - hoursAgo * HOUR, + txHash: `hash-${id}`, + }; +} + +/** Fake API that honours type, range and cursor params. */ +function makeFakeApi(all: WalletTransaction[], pageSize = 100) { + return vi.fn( + async ({ + types, + from, + to, + cursor, + }: GetWalletTransactionsParams): Promise => { + const matching = all.filter( + tx => + (!types || types.includes(tx.type)) && + (from === undefined || tx.timestamp >= from) && + (to === undefined || tx.timestamp <= to) + ); + const start = cursor ? Number(cursor) : 0; + const end = start + pageSize; + return { + items: matching.slice(start, end), + nextCursor: end < matching.length ? String(end) : null, + }; + } + ); +} + +function getRowIds() { + const list = screen.getByTestId('transaction-history-list'); + return within(list) + .getAllByRole('listitem') + .map(li => + li.getAttribute('data-testid')?.replace('transaction-row-', '') + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// Multi-step render flows; allow headroom on loaded CI runners. +describe('TransactionHistoryView', { timeout: 15_000 }, () => { + it('renders every event type with its icon and description', async () => { + const all = TYPES.map((type, i) => makeTx(type, type, i + 1)); + render( + + ); + + await screen.findByTestId('transaction-history-list'); + + for (const type of TYPES) { + const row = screen.getByTestId(`transaction-row-${type}`); + expect( + within(row).getByTestId(`transaction-icon-${type}`) + ).toBeInTheDocument(); + expect(row).toHaveTextContent( + historyUtils.describeTransaction(all.find(t => t.type === type)!) + ); + expect(row).toHaveTextContent('Fee 0.01 XLM'); + } + expect(screen.getByTestId('transaction-row-referral')).toHaveTextContent( + '+10 XLM' + ); + expect(screen.getByTestId('transaction-row-buy')).toHaveTextContent( + '−10 XLM' + ); + }); + + it('narrows results with the type filter and sends the types to the API', async () => { + const all = TYPES.map((type, i) => makeTx(type, type, i + 1)); + const fetchPage = makeFakeApi(all); + render( + + ); + await screen.findByTestId('transaction-history-list'); + + fireEvent.click(screen.getByRole('button', { name: 'Stakes' })); + + await waitFor(() => expect(getRowIds()).toEqual(['stake', 'unstake'])); + expect(fetchPage).toHaveBeenLastCalledWith( + expect.objectContaining({ types: ['stake', 'unstake'] }) + ); + expect(screen.getByRole('button', { name: 'Stakes' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('drops rows outside the filter even if the API ignores it', async () => { + const all = [makeTx('a', 'buy', 1), makeTx('b', 'swap', 2)]; + const ignoresFilters = vi.fn(async () => ({ + items: all, + nextCursor: null, + })); + render( + + ); + await screen.findByTestId('transaction-history-list'); + + fireEvent.click(screen.getByRole('button', { name: 'Swaps' })); + await waitFor(() => expect(getRowIds()).toEqual(['b'])); + }); + + it('applies date presets and custom ranges', async () => { + const all = [ + makeTx('recent', 'buy', 1), + makeTx('week', 'buy', 24 * 5), + makeTx('month', 'buy', 24 * 20), + makeTx('old', 'buy', 24 * 60), + ]; + render( + + ); + + // Default preset is 30 days. + await waitFor(() => + expect(getRowIds()).toEqual(['recent', 'week', 'month']) + ); + + fireEvent.click(screen.getByRole('button', { name: '7 days' })); + await waitFor(() => expect(getRowIds()).toEqual(['recent', 'week'])); + + fireEvent.click(screen.getByRole('button', { name: 'All time' })); + await waitFor(() => + expect(getRowIds()).toEqual(['recent', 'week', 'month', 'old']) + ); + + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + const day = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const old = new Date(all[3].timestamp); + fireEvent.change(screen.getByLabelText('From'), { + target: { value: day(old) }, + }); + fireEvent.change(screen.getByLabelText('To'), { + target: { value: day(old) }, + }); + await waitFor(() => expect(getRowIds()).toEqual(['old'])); + }); + + it('flags an inverted custom range without fetching', async () => { + const fetchPage = makeFakeApi([makeTx('a', 'buy', 1)]); + render( + + ); + await screen.findByTestId('transaction-history-list'); + const callsBefore = fetchPage.mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + fireEvent.change(screen.getByLabelText('From'), { + target: { value: '2026-10-10' }, + }); + fireEvent.change(screen.getByLabelText('To'), { + target: { value: '2026-10-01' }, + }); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /start date must be on or before/i + ); + expect(fetchPage.mock.calls.length).toBe(callsBefore); + }); + + it('exports the filtered rows to CSV', async () => { + const all = [ + makeTx('a', 'buy', 1), + makeTx('b', 'claim', 2), + makeTx('c', 'buy', 3), + ]; + const download = vi + .spyOn(historyUtils, 'downloadCsv') + .mockImplementation(() => {}); + render( + + ); + await screen.findByTestId('transaction-history-list'); + + fireEvent.click(screen.getByRole('button', { name: 'Buys' })); + await waitFor(() => expect(getRowIds()).toEqual(['a', 'c'])); + + fireEvent.click(screen.getByRole('button', { name: /export csv/i })); + + expect(download).toHaveBeenCalledTimes(1); + const [filename, csv] = download.mock.calls[0]; + expect(filename).toMatch( + /^accesslayer-transactions-buys-30d-\d{4}-\d{2}-\d{2}\.csv$/ + ); + expect(csv).toBe(historyUtils.transactionsToCsv([all[0], all[2]])); + }); + + it('load more appends the next page without duplicates', async () => { + const all = Array.from({ length: 5 }, (_, i) => + makeTx(`t${i}`, 'buy', i + 1) + ); + // Page 2 overlaps page 1 by one row, as can happen when new activity + // shifts an offset-backed cursor. + const fetchPage = vi + .fn< + (p: GetWalletTransactionsParams) => Promise + >() + .mockResolvedValueOnce({ items: all.slice(0, 3), nextCursor: 'c1' }) + .mockResolvedValueOnce({ items: all.slice(2, 5), nextCursor: null }); + + render( + + ); + await waitFor(() => expect(getRowIds()).toEqual(['t0', 't1', 't2'])); + + fireEvent.click(screen.getByRole('button', { name: 'Load more' })); + + await waitFor(() => + expect(getRowIds()).toEqual(['t0', 't1', 't2', 't3', 't4']) + ); + expect(fetchPage).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: 'c1' }) + ); + expect( + screen.queryByRole('button', { name: 'Load more' }) + ).not.toBeInTheDocument(); + expect(screen.getByText(/reached the end/i)).toBeInTheDocument(); + }); + + it('shows an error state with retry', async () => { + const fetchPage = vi + .fn< + (p: GetWalletTransactionsParams) => Promise + >() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue({ + items: [makeTx('a', 'buy', 1)], + nextCursor: null, + }); + render( + + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /couldn't load/i + ); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(getRowIds()).toEqual(['a'])); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index d8ee3b61..954ff9fe 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -3,6 +3,7 @@ import NotFoundPage from './pages/NotFoundPage'; import CreatorDetailPage from './pages/CreatorDetailPage'; import NotificationsPage from './pages/NotificationsPage'; import NotificationPreferencesPage from './pages/NotificationPreferencesPage'; +import TransactionHistoryPage from './pages/TransactionHistoryPage'; export const routes = [ { @@ -29,6 +30,10 @@ export const routes = [ path: '/settings/notifications', element: , }, + { + path: '/transactions', + element: , + }, { path: '*', element: , diff --git a/src/services/transactionHistory.service.ts b/src/services/transactionHistory.service.ts new file mode 100644 index 00000000..78a99abe --- /dev/null +++ b/src/services/transactionHistory.service.ts @@ -0,0 +1,78 @@ +// src/services/transactionHistory.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** Every kind of wallet event the history page can show. */ +export type WalletTransactionType = + 'buy' | 'sell' | 'stake' | 'unstake' | 'claim' | 'referral' | 'swap'; + +/** Whether value moved into or out of the wallet. */ +export type WalletTransactionDirection = 'in' | 'out'; + +export interface WalletTransaction { + id: string; + type: WalletTransactionType; + /** Display name of the creator key involved (or the swap pair). */ + keyName: string; + creatorId?: string; + /** Transaction value in XLM. */ + amount: number; + direction: WalletTransactionDirection; + /** Network + protocol fee paid, in XLM. */ + fee: number; + /** Unix epoch milliseconds. */ + timestamp: number; + txHash?: string; +} + +export interface WalletTransactionPage { + items: WalletTransaction[]; + /** Opaque cursor for the next page; `null` when there are no more. */ + nextCursor: string | null; +} + +export interface GetWalletTransactionsParams { + address: string; + /** Restrict to these types; omitted means all types. */ + types?: WalletTransactionType[]; + /** Inclusive lower bound, epoch ms. */ + from?: number; + /** Inclusive upper bound, epoch ms. */ + to?: number; + cursor?: string | null; + limit?: number; +} + +export const TRANSACTION_HISTORY_PAGE_SIZE = 20; + +class TransactionHistoryService extends BaseApiService { + /** GET /wallet/:address/transactions */ + async getTransactions({ + address, + types, + from, + to, + cursor, + limit = TRANSACTION_HISTORY_PAGE_SIZE, + }: GetWalletTransactionsParams): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/wallet/${address}/transactions`, { + params: { + limit, + ...(types?.length ? { types: types.join(',') } : {}), + ...(from !== undefined + ? { from: new Date(from).toISOString() } + : {}), + ...(to !== undefined ? { to: new Date(to).toISOString() } : {}), + ...(cursor ? { cursor } : {}), + }, + }); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const transactionHistoryService = new TransactionHistoryService(); diff --git a/src/utils/__tests__/transactionHistory.utils.test.ts b/src/utils/__tests__/transactionHistory.utils.test.ts new file mode 100644 index 00000000..68e6985c --- /dev/null +++ b/src/utils/__tests__/transactionHistory.utils.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import type { WalletTransaction } from '@/services/transactionHistory.service'; +import { + getTypesForFilter, + matchesTransactionFilters, + mergeTransactionPages, + resolveDateRange, + transactionsToCsv, +} from '@/utils/transactionHistory.utils'; + +const DAY = 24 * 60 * 60 * 1000; +// 15 Oct 2026, 15:30 local time. +const NOW = new Date(2026, 9, 15, 15, 30).getTime(); + +function tx(overrides: Partial = {}): WalletTransaction { + return { + id: 'tx-1', + type: 'buy', + keyName: 'Lena Markov', + amount: 12.5, + direction: 'out', + fee: 0.01, + timestamp: NOW - 1000, + txHash: 'abc123', + ...overrides, + }; +} + +describe('getTypesForFilter', () => { + it('returns no restriction for "all"', () => { + expect(getTypesForFilter('all')).toBeUndefined(); + }); + + it('groups stake and unstake under "stakes"', () => { + expect(getTypesForFilter('stakes')).toEqual(['stake', 'unstake']); + }); + + it('maps plural filters to their single type', () => { + expect(getTypesForFilter('referrals')).toEqual(['referral']); + expect(getTypesForFilter('swaps')).toEqual(['swap']); + }); +}); + +describe('resolveDateRange', () => { + it('returns an open range for "all"', () => { + expect(resolveDateRange('all', { start: '', end: '' }, NOW)).toEqual({}); + }); + + it('starts "today" at local midnight', () => { + const range = resolveDateRange('today', { start: '', end: '' }, NOW); + expect(range).toEqual({ + from: new Date(2026, 9, 15).getTime(), + to: NOW, + }); + }); + + it('covers the trailing 7 and 30 days', () => { + expect(resolveDateRange('7d', { start: '', end: '' }, NOW)).toEqual({ + from: NOW - 7 * DAY, + to: NOW, + }); + expect(resolveDateRange('30d', { start: '', end: '' }, NOW)).toEqual({ + from: NOW - 30 * DAY, + to: NOW, + }); + }); + + it('spans whole local days for a custom range', () => { + const range = resolveDateRange( + 'custom', + { start: '2026-10-01', end: '2026-10-03' }, + NOW + ); + expect(range).toEqual({ + from: new Date(2026, 9, 1).getTime(), + to: new Date(2026, 9, 3, 23, 59, 59, 999).getTime(), + }); + }); + + it('returns null for incomplete or inverted custom ranges', () => { + expect( + resolveDateRange('custom', { start: '2026-10-01', end: '' }, NOW) + ).toBeNull(); + expect( + resolveDateRange( + 'custom', + { start: '2026-10-05', end: '2026-10-01' }, + NOW + ) + ).toBeNull(); + }); +}); + +describe('matchesTransactionFilters', () => { + it('filters by type group', () => { + expect( + matchesTransactionFilters(tx({ type: 'unstake' }), 'stakes', {}) + ).toBe(true); + expect(matchesTransactionFilters(tx({ type: 'buy' }), 'stakes', {})).toBe( + false + ); + }); + + it('includes both range bounds', () => { + const range = { from: 100, to: 200 }; + expect( + matchesTransactionFilters(tx({ timestamp: 100 }), 'all', range) + ).toBe(true); + expect( + matchesTransactionFilters(tx({ timestamp: 200 }), 'all', range) + ).toBe(true); + expect( + matchesTransactionFilters(tx({ timestamp: 99 }), 'all', range) + ).toBe(false); + expect( + matchesTransactionFilters(tx({ timestamp: 201 }), 'all', range) + ).toBe(false); + }); +}); + +describe('mergeTransactionPages', () => { + it('appends new rows and skips ids already present', () => { + const merged = mergeTransactionPages( + [tx({ id: 'a' }), tx({ id: 'b' })], + [tx({ id: 'b' }), tx({ id: 'c' }), tx({ id: 'c' })] + ); + expect(merged.map(t => t.id)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('transactionsToCsv', () => { + it('writes a header and one row per transaction', () => { + const csv = transactionsToCsv([ + tx({ id: 'a', timestamp: Date.UTC(2026, 9, 15, 12) }), + tx({ + id: 'b', + type: 'referral', + direction: 'in', + keyName: 'Kai Osei', + amount: 2, + fee: 0, + txHash: undefined, + timestamp: Date.UTC(2026, 9, 14, 8), + }), + ]); + expect(csv.split('\r\n')).toEqual([ + 'Date,Type,Key,Direction,Amount (XLM),Fee (XLM),Transaction hash', + '2026-10-15T12:00:00.000Z,Buy,Lena Markov,Out,12.5,0.01,abc123', + '2026-10-14T08:00:00.000Z,Referral payout,Kai Osei,In,2,0,', + ]); + }); + + it('quotes special characters and neutralises formulas', () => { + const csv = transactionsToCsv([ + tx({ keyName: 'Smith, "The" Artist' }), + tx({ id: 'x', keyName: '=HYPERLINK("evil")' }), + ]); + const [, first, second] = csv.split('\r\n'); + expect(first).toContain('"Smith, ""The"" Artist"'); + expect(second).toContain(`"'=HYPERLINK(""evil"")"`); + }); +}); diff --git a/src/utils/transactionHistory.utils.ts b/src/utils/transactionHistory.utils.ts new file mode 100644 index 00000000..857f171d --- /dev/null +++ b/src/utils/transactionHistory.utils.ts @@ -0,0 +1,236 @@ +import type { + WalletTransaction, + WalletTransactionType, +} from '@/services/transactionHistory.service'; + +export type TransactionTypeFilter = + 'all' | 'buys' | 'sells' | 'stakes' | 'claims' | 'swaps' | 'referrals'; + +export const TRANSACTION_TYPE_FILTERS: { + value: TransactionTypeFilter; + label: string; +}[] = [ + { value: 'all', label: 'All' }, + { value: 'buys', label: 'Buys' }, + { value: 'sells', label: 'Sells' }, + { value: 'stakes', label: 'Stakes' }, + { value: 'claims', label: 'Claims' }, + { value: 'swaps', label: 'Swaps' }, + { value: 'referrals', label: 'Referrals' }, +]; + +/** + * Maps each filter chip to the event types it includes. "Stakes" covers + * both staking and unstaking so users see the full lifecycle in one view. + * `null` means no type restriction. + */ +const FILTER_TYPES: Record< + TransactionTypeFilter, + WalletTransactionType[] | null +> = { + all: null, + buys: ['buy'], + sells: ['sell'], + stakes: ['stake', 'unstake'], + claims: ['claim'], + swaps: ['swap'], + referrals: ['referral'], +}; + +export function getTypesForFilter( + filter: TransactionTypeFilter +): WalletTransactionType[] | undefined { + return FILTER_TYPES[filter] ?? undefined; +} + +export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom'; + +export const DATE_RANGE_PRESETS: { value: DateRangePreset; label: string }[] = [ + { value: 'all', label: 'All time' }, + { value: 'today', label: 'Today' }, + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: 'custom', label: 'Custom' }, +]; + +export interface CustomDateRange { + /** `YYYY-MM-DD` from a date input, interpreted in local time. */ + start: string; + end: string; +} + +export interface ResolvedDateRange { + from?: number; + to?: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function parseLocalDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]) + ); + return Number.isNaN(date.getTime()) ? null : date; +} + +function startOfDay(date: Date): number { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + return copy.getTime(); +} + +function endOfDay(date: Date): number { + const copy = new Date(date); + copy.setHours(23, 59, 59, 999); + return copy.getTime(); +} + +/** + * Converts a preset (or custom inputs) into epoch-ms bounds. Returns + * `null` when a custom range is incomplete or inverted so callers can + * hold off fetching and show a validation hint instead. + */ +export function resolveDateRange( + preset: DateRangePreset, + custom: CustomDateRange, + now: number = Date.now() +): ResolvedDateRange | null { + switch (preset) { + case 'all': + return {}; + case 'today': + return { from: startOfDay(new Date(now)), to: now }; + case '7d': + return { from: now - 7 * DAY_MS, to: now }; + case '30d': + return { from: now - 30 * DAY_MS, to: now }; + case 'custom': { + const start = parseLocalDate(custom.start); + const end = parseLocalDate(custom.end); + if (!start || !end) return null; + const from = startOfDay(start); + const to = endOfDay(end); + if (from > to) return null; + return { from, to }; + } + } +} + +/** + * Client-side guard applied on top of the server query so the list never + * shows rows outside the active filter, even if the API ignores a param. + */ +export function matchesTransactionFilters( + tx: WalletTransaction, + filter: TransactionTypeFilter, + range: ResolvedDateRange +): boolean { + const types = getTypesForFilter(filter); + if (types && !types.includes(tx.type)) return false; + if (range.from !== undefined && tx.timestamp < range.from) return false; + if (range.to !== undefined && tx.timestamp > range.to) return false; + return true; +} + +/** Appends a page while dropping rows already shown (cursor overlap). */ +export function mergeTransactionPages( + existing: WalletTransaction[], + incoming: WalletTransaction[] +): WalletTransaction[] { + const seen = new Set(existing.map(tx => tx.id)); + const merged = [...existing]; + for (const tx of incoming) { + if (seen.has(tx.id)) continue; + seen.add(tx.id); + merged.push(tx); + } + return merged; +} + +export const TRANSACTION_TYPE_LABELS: Record = { + buy: 'Buy', + sell: 'Sell', + stake: 'Stake', + unstake: 'Unstake', + claim: 'Claim', + referral: 'Referral payout', + swap: 'Swap', +}; + +export function describeTransaction(tx: WalletTransaction): string { + switch (tx.type) { + case 'buy': + return `Bought ${tx.keyName} keys`; + case 'sell': + return `Sold ${tx.keyName} keys`; + case 'stake': + return `Staked ${tx.keyName} keys`; + case 'unstake': + return `Unstaked ${tx.keyName} keys`; + case 'claim': + return `Claimed ${tx.keyName} rewards`; + case 'referral': + return `Referral payout from ${tx.keyName}`; + case 'swap': + return `Swapped ${tx.keyName}`; + } +} + +const CSV_HEADERS = [ + 'Date', + 'Type', + 'Key', + 'Direction', + 'Amount (XLM)', + 'Fee (XLM)', + 'Transaction hash', +]; + +/** + * Quotes a CSV field when needed and neutralises spreadsheet formula + * injection, since key names are creator-controlled text. + */ +function escapeCsvField(value: string): string { + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +export function transactionsToCsv(transactions: WalletTransaction[]): string { + const rows = transactions.map(tx => + [ + new Date(tx.timestamp).toISOString(), + TRANSACTION_TYPE_LABELS[tx.type], + tx.keyName, + tx.direction === 'in' ? 'In' : 'Out', + String(tx.amount), + String(tx.fee), + tx.txHash ?? '', + ].map(escapeCsvField) + ); + return [CSV_HEADERS, ...rows].map(row => row.join(',')).join('\r\n'); +} + +export function buildTransactionCsvFilename( + filter: TransactionTypeFilter, + preset: DateRangePreset, + now: number = Date.now() +): string { + const date = new Date(now).toISOString().slice(0, 10); + return `accesslayer-transactions-${filter}-${preset}-${date}.csv`; +} + +export function downloadCsv(filename: string, csv: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} From 0bb27793d01866919fab5b8b26ce9e6ce0f5a2fb Mon Sep 17 00:00:00 2001 From: Abidoyesimze <154632691+Abidoyesimze@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:21:37 +0100 Subject: [PATCH 3/5] feat: add dark mode with system preference detection (#1085) Follows prefers-color-scheme on first visit, persists manual overrides in localStorage, syncs across tabs, and applies the theme before first paint. Adds a nav toggle, an appearance setting, and dark variants for the home page sections. Co-Authored-By: Claude Opus 5.5 --- index.html | 24 +++ src/components/common/ThemeToggle.tsx | 32 +++ src/components/home/CreatorSpotlight.tsx | 32 +-- src/components/home/FAQ.tsx | 20 +- src/components/home/Footer.tsx | 16 +- src/components/home/Header.tsx | 23 ++- src/components/home/TrendingCreatorCard.tsx | 18 +- src/components/home/TrendingCreators.tsx | 19 +- src/hooks/useTheme.ts | 41 ++++ src/index.css | 24 +++ src/lib/__tests__/theme.test.tsx | 204 ++++++++++++++++++++ src/lib/theme.ts | 168 ++++++++++++++++ src/main.tsx | 2 + src/pages/SettingsPage.tsx | 96 +++++++++ src/routes.tsx | 5 + src/utils/useSystemTheme.ts | 33 ++-- 16 files changed, 673 insertions(+), 84 deletions(-) create mode 100644 src/components/common/ThemeToggle.tsx create mode 100644 src/hooks/useTheme.ts create mode 100644 src/lib/__tests__/theme.test.tsx create mode 100644 src/lib/theme.ts create mode 100644 src/pages/SettingsPage.tsx diff --git a/index.html b/index.html index a5734ba4..9e1fc93b 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,30 @@ /> + + + diff --git a/src/components/common/ThemeToggle.tsx b/src/components/common/ThemeToggle.tsx new file mode 100644 index 00000000..92b6dd56 --- /dev/null +++ b/src/components/common/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import { Moon, Sun } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useTheme } from '@/hooks/useTheme'; + +interface ThemeToggleProps { + className?: string; +} + +/** Nav bar light/dark switch. Stores the choice as a manual override. */ +export default function ThemeToggle({ className }: ThemeToggleProps) { + const { isDark, toggleTheme } = useTheme(); + const label = isDark ? 'Switch to light mode' : 'Switch to dark mode'; + + return ( + + ); +} diff --git a/src/components/home/CreatorSpotlight.tsx b/src/components/home/CreatorSpotlight.tsx index 6837a10e..15cfdeb1 100644 --- a/src/components/home/CreatorSpotlight.tsx +++ b/src/components/home/CreatorSpotlight.tsx @@ -22,7 +22,7 @@ function SpotlightSkeletonCard() {
    Loading spotlight creator @@ -74,24 +74,24 @@ function SpotlightCreatorCard({ creator, rank }: SpotlightCreatorCardProps) { return ( {/* Rank badge */}
    {rank === 1 ? ( - + Top ) : ( - + #{rank} )}
    {/* Avatar */} -
    +
    {/* Name */} -

    +

    {name}

    {/* Stats row */} -
    +
    {/* Key price */}
    - + {keyPrice} - XLM + XLM
    {/* Holder count */} -
    +
    {formatHolderCount(holderCount)} @@ -125,7 +125,7 @@ function SpotlightCreatorCard({ creator, rank }: SpotlightCreatorCardProps) {
    {/* CTA */} -
    +
    View profile
    @@ -171,30 +171,30 @@ export default function CreatorSpotlight() { } return ( -
    +
    {/* Header */}
    - + Creator Spotlight

    - + Most collected creators
    - + discover who has the biggest following.

    View all diff --git a/src/components/home/FAQ.tsx b/src/components/home/FAQ.tsx index 2bda5a9d..5dff0a7c 100644 --- a/src/components/home/FAQ.tsx +++ b/src/components/home/FAQ.tsx @@ -49,21 +49,21 @@ function Row({ onToggle: () => void; }) { return ( -
    +
    + ); + } + + if (isFull) { + // aria-disabled (not `disabled`) keeps the button focusable so + // keyboard users can still reach the tooltip explaining why. + return ( + + + + ); + } + + return ( + + ); +} diff --git a/src/components/common/CompareBar.tsx b/src/components/common/CompareBar.tsx new file mode 100644 index 00000000..76a1c19e --- /dev/null +++ b/src/components/common/CompareBar.tsx @@ -0,0 +1,87 @@ +import { ArrowRight, X } from 'lucide-react'; +import { Link } from 'react-router'; +import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import { MAX_COMPARE_KEYS, useCompareStore } from '@/hooks/useCompareStore'; + +/** + * Sticky bottom bar listing the keys queued for comparison (#1086). + * Hidden until at least one key is selected. + */ +export default function CompareBar() { + const keys = useCompareStore(state => state.keys); + const remove = useCompareStore(state => state.remove); + const clear = useCompareStore(state => state.clear); + + if (keys.length === 0) return null; + + const canCompare = keys.length >= 2; + + return ( + <> + {/* Reserves space so the fixed bar never covers the page footer. */} + +
    + +
    + {/* CTA */} void; + remove: (id: string) => void; + clear: () => void; +} + +/** + * Keys selected for side-by-side comparison (#1086). Persisted to + * sessionStorage so the selection survives navigating to the compare page + * and reloads, but doesn't linger across browsing sessions. + */ +export const useCompareStore = create()( + persist( + set => ({ + keys: [], + add: key => + set(state => + state.keys.length >= MAX_COMPARE_KEYS || + state.keys.some(existing => existing.id === key.id) + ? state + : { keys: [...state.keys, key] } + ), + remove: id => + set(state => ({ keys: state.keys.filter(key => key.id !== id) })), + clear: () => set({ keys: [] }), + }), + { + name: COMPARE_STORAGE_KEY, + storage: createJSONStorage(() => sessionStorage), + partialize: state => ({ keys: state.keys }), + } + ) +); diff --git a/src/hooks/useKeyComparisonMetrics.ts b/src/hooks/useKeyComparisonMetrics.ts new file mode 100644 index 00000000..cc8922de --- /dev/null +++ b/src/hooks/useKeyComparisonMetrics.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from 'react'; +import { + keyMetricsService, + type KeyMetrics, +} from '@/services/keyMetrics.service'; + +export type KeyMetricsEntry = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; metrics: KeyMetrics }; + +export type FetchKeyMetrics = (creatorId: string) => Promise; + +/** + * Loads metrics for each compared key independently, so one failing + * creator doesn't blank the whole comparison table. + */ +export function useKeyComparisonMetrics( + creatorIds: string[], + fetchMetrics: FetchKeyMetrics = id => keyMetricsService.getKeyMetrics(id) +): Record { + const [entries, setEntries] = useState>({}); + const fetchRef = useRef(fetchMetrics); + useEffect(() => { + fetchRef.current = fetchMetrics; + }); + + const idsKey = creatorIds.join(','); + + useEffect(() => { + let cancelled = false; + const ids = idsKey ? idsKey.split(',') : []; + + setEntries(current => { + const next: Record = {}; + for (const id of ids) next[id] = current[id] ?? { status: 'loading' }; + return next; + }); + + for (const id of ids) { + fetchRef + .current(id) + .then(metrics => { + if (cancelled) return; + // Treat an empty payload like a failure so the table falls + // back to card data instead of rendering blanks as "ready". + if (!metrics) throw new Error('Missing key metrics'); + setEntries(current => + id in current + ? { ...current, [id]: { status: 'ready', metrics } } + : current + ); + }) + .catch(() => { + if (cancelled) return; + setEntries(current => + id in current + ? { ...current, [id]: { status: 'error' } } + : current + ); + }); + } + + return () => { + cancelled = true; + }; + }, [idsKey]); + + return entries; +} diff --git a/src/pages/ComparePage.tsx b/src/pages/ComparePage.tsx new file mode 100644 index 00000000..7636ff40 --- /dev/null +++ b/src/pages/ComparePage.tsx @@ -0,0 +1,322 @@ +import { ArrowLeft, ArrowRight, GitCompareArrows, X } from 'lucide-react'; +import { Link } from 'react-router'; +import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import { Button } from '@/components/ui/button'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; +import { + MAX_COMPARE_KEYS, + useCompareStore, + type CompareKey, +} from '@/hooks/useCompareStore'; +import { + useKeyComparisonMetrics, + type FetchKeyMetrics, + type KeyMetricsEntry, +} from '@/hooks/useKeyComparisonMetrics'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; +import type { KeyMetrics } from '@/services/keyMetrics.service'; +import { formatNumber } from '@/utils/numberFormat.utils'; + +const EMPTY = '—'; + +function formatXlm(value: number | null | undefined) { + return value == null + ? EMPTY + : `${formatNumber(value, { maximumFractionDigits: 2 })} XLM`; +} + +function snapshotPrice(key: CompareKey) { + return key.priceStroops != null + ? key.priceStroops / STROOPS_PER_XLM + : key.price; +} + +interface MetricRow { + id: string; + label: string; + render: (key: CompareKey, metrics: KeyMetrics | null) => React.ReactNode; + /** Rows that can fall back to the card snapshot while metrics load/fail. */ + usesSnapshot?: boolean; +} + +const METRIC_ROWS: MetricRow[] = [ + { + id: 'price', + label: 'Key price', + usesSnapshot: true, + render: (key, metrics) => formatXlm(metrics?.price ?? snapshotPrice(key)), + }, + { + id: 'volume', + label: '24h volume', + usesSnapshot: true, + render: (key, metrics) => formatXlm(metrics?.volume24h ?? key.volume24h), + }, + { + id: 'holders', + label: 'Holders', + render: (_, metrics) => formatNumber(metrics?.holders), + }, + { + id: 'traders', + label: 'Unique traders (30d)', + render: (_, metrics) => formatNumber(metrics?.uniqueTraders), + }, + { + id: 'rating', + label: 'Rating', + render: (_, metrics) => + !metrics + ? EMPTY + : metrics.rating == null + ? 'Unrated' + : `${formatNumber(metrics.rating, { maximumFractionDigits: 1 })} / 5`, + }, + { + id: 'milestone', + label: 'Curve milestone', + render: (_, metrics) => { + const milestone = metrics?.curveMilestone; + if (!milestone) return EMPTY; + const progress = Math.min(100, Math.max(0, milestone.progressPercent)); + return ( +
    + {milestone.label} +
    +
    +
    + + {Math.round(progress)}% to next milestone + +
    + ); + }, + }, + { + id: 'yield', + label: 'Yield', + render: (_, metrics) => + metrics?.yieldPercent == null + ? EMPTY + : `${formatNumber(metrics.yieldPercent, { maximumFractionDigits: 2 })}%`, + }, +]; + +function MetricCell({ + row, + compareKey, + entry, +}: { + row: MetricRow; + compareKey: CompareKey; + entry: KeyMetricsEntry | undefined; +}) { + const metrics = entry?.status === 'ready' ? entry.metrics : null; + const isLoading = !entry || entry.status === 'loading'; + + if (isLoading && !row.usesSnapshot) { + return ( + + ); + } + + return <>{row.render(compareKey, metrics)}; +} + +interface ComparePageProps { + /** Injectable for tests; defaults to the API service. */ + fetchMetrics?: FetchKeyMetrics; +} + +export default function ComparePage({ fetchMetrics }: ComparePageProps = {}) { + useNavigationTiming('key_compare'); + + const keys = useCompareStore(state => state.keys); + const remove = useCompareStore(state => state.remove); + const clear = useCompareStore(state => state.clear); + const metricsById = useKeyComparisonMetrics( + keys.map(key => key.id), + fetchMetrics + ); + + const hasMetricsError = keys.some( + key => metricsById[key.id]?.status === 'error' + ); + + return ( +
    + +
    + ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index cd62d618..970927c3 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ import Header from '../components/home/Header'; import Hero from '../components/home/Hero'; import CreatorSpotlight from '../components/home/CreatorSpotlight'; import TrendingCreators from '../components/home/TrendingCreators'; +import CompareBar from '../components/common/CompareBar'; import { useNavigationTiming } from '../hooks/useNavigationTiming'; export default function HomePage() { @@ -19,6 +20,7 @@ export default function HomePage() {