diff --git a/STRUCTURE.md b/STRUCTURE.md index ef4b66d..73acf82 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -69,12 +69,21 @@ components/ ├── BottomNav/ # Navigation component ├── BottomSheet/ # Modal sheet component ├── Chart/ # Price chart -├── DurationOptions/ # Trade duration +├── Duration/ # Trade duration selection +│ ├── components/ # Duration subcomponents +│ │ ├── DurationTab.tsx +│ │ ├── DurationTabList.tsx +│ │ ├── DurationValueList.tsx +│ │ └── HoursDurationValue.tsx +│ └── DurationController.tsx +├── DurationOptions/ # Legacy trade duration (to be deprecated) ├── TradeButton/ # Trade execution ├── TradeFields/ # Trade parameters └── ui/ # Shared UI components ├── button.tsx ├── card.tsx + ├── chip.tsx # Selection chip component + ├── primary-button.tsx # Primary action button ├── switch.tsx └── toggle.tsx ``` @@ -214,6 +223,9 @@ RSBUILD_SSE_PROTECTED_PATH=/sse - Use composition over inheritance - Keep components focused and single-responsibility - Document props and side effects + - Implement reusable UI components in ui/ directory + - Use TailwindCSS for consistent styling + - Support theme customization through design tokens 3. **State Management** - Use local state for UI-only state diff --git a/llms.txt b/llms.txt index 877c06a..e420a91 100644 --- a/llms.txt +++ b/llms.txt @@ -11,6 +11,36 @@ The application is structured around modular, self-contained components and uses - [Configuration Files](STRUCTURE.md#configuration-files): Build, development, and testing configurations - [Module Dependencies](STRUCTURE.md#module-dependencies): Core and development dependencies +## Typography System + +The application uses a consistent typography system based on IBM Plex Sans: + +### Text Styles + +#### Caption Regular (Small Text) +```css +font-family: IBM Plex Sans; +font-size: 12px; +font-weight: 400; +line-height: 18px; +text-align: left; +``` + +#### Body Regular (Default Text) +```css +font-family: IBM Plex Sans; +font-size: 16px; +font-weight: 400; +line-height: 24px; +text-align: left; +``` + +### Layout Principles +- Components should take full available width where appropriate +- Text content should be consistently left-aligned +- Maintain proper spacing and padding for readability +- Use responsive design patterns for different screen sizes + ## Architecture - [Component Structure](src/components/README.md): Comprehensive guide on TDD implementation, Atomic Component Design, and component organization diff --git a/src/components/BottomSheet/BottomSheet.tsx b/src/components/BottomSheet/BottomSheet.tsx index a2bf093..2601cc5 100644 --- a/src/components/BottomSheet/BottomSheet.tsx +++ b/src/components/BottomSheet/BottomSheet.tsx @@ -70,7 +70,7 @@ export const BottomSheet = () => { <> {/* Overlay */}
{ // Only close if clicking the overlay itself, not its children onDragDown?.(); @@ -92,7 +92,7 @@ export const BottomSheet = () => { rounded-t-[16px] animate-in fade-in-0 slide-in-from-bottom duration-300 - z-50 + z-[60] transition-transform overflow-hidden `} @@ -106,7 +106,7 @@ export const BottomSheet = () => {
{/* Content */} -
{body}
+
{body}
); diff --git a/src/components/BottomSheet/README.md b/src/components/BottomSheet/README.md index d68bb0e..0ba3b9b 100644 --- a/src/components/BottomSheet/README.md +++ b/src/components/BottomSheet/README.md @@ -1,14 +1,16 @@ # BottomSheet Component ## Overview -A reusable bottom sheet component with drag-to-dismiss functionality and drag callback support. +A reusable bottom sheet component that provides a mobile-friendly interface with smooth animations, drag-to-dismiss functionality, and theme-aware styling. ## Features - Single instance pattern using Zustand store - Dynamic height support (%, px, vh) - Theme-aware using Tailwind CSS variables +- Smooth animations for enter/exit transitions - Drag gesture support with callback - Content management through configuration +- Responsive overlay with fade effect ## Usage @@ -68,27 +70,52 @@ interface BottomSheetState { - Proper cleanup on sheet close and unmount ## Styling -Uses Tailwind CSS variables for theme support: +Uses Tailwind CSS for theme-aware styling and animations: ```tsx +// Theme colors className="bg-background" // Theme background className="bg-muted" // Theme muted color +className="bg-black/80" // Semi-transparent overlay + +// Animations +className="animate-in fade-in-0" // Fade in animation +className="slide-in-from-bottom" // Slide up animation +className="duration-300" // Animation duration +className="transition-transform" // Smooth transform transitions + +// Layout +className="rounded-t-[16px]" // Rounded top corners +className="max-w-[800px]" // Maximum width +className="overflow-hidden" // Content overflow handling ``` ## Implementation Details ### Touch Event Handling ```typescript -const handleTouchMove = (e: TouchEvent) => { - if (!isDragging.current) return; +const handleTouchMove = useCallback((e: TouchEvent) => { + if (!sheetRef.current || !isDragging.current) return; + + const touch = e.touches[0]; + const deltaY = touch.clientY - dragStartY.current; + currentY.current = deltaY; - const deltaY = e.touches[0].clientY - dragStartY.current; if (deltaY > 0) { - // Update sheet position sheetRef.current.style.transform = `translateY(${deltaY}px)`; - // Call drag callback if provided onDragDown?.(); } -}; +}, [onDragDown]); +``` + +### Overlay Handling +```tsx +
{ + onDragDown?.(); + setBottomSheet(false); + }} +/> ``` ### Height Processing diff --git a/src/components/BottomSheet/__tests__/BottomSheet.test.tsx b/src/components/BottomSheet/__tests__/BottomSheet.test.tsx index bef29a8..c3ee828 100644 --- a/src/components/BottomSheet/__tests__/BottomSheet.test.tsx +++ b/src/components/BottomSheet/__tests__/BottomSheet.test.tsx @@ -65,11 +65,13 @@ describe("BottomSheet", () => { expect(bottomSheet).toHaveStyle({ height: '50vh' }); }); - it("handles drag to dismiss", () => { + it("handles drag to dismiss and calls onDragDown", () => { + const mockOnDragDown = jest.fn(); mockUseBottomSheetStore.mockReturnValue({ showBottomSheet: true, key: 'test-key', height: '380px', + onDragDown: mockOnDragDown, setBottomSheet: mockSetBottomSheet }); @@ -83,6 +85,45 @@ describe("BottomSheet", () => { fireEvent.touchMove(document, { touches: [{ clientY: 150 }] }); fireEvent.touchEnd(document); + expect(mockOnDragDown).toHaveBeenCalled(); + expect(mockSetBottomSheet).toHaveBeenCalledWith(false); + }); + + it("applies animation classes when shown", () => { + mockUseBottomSheetStore.mockReturnValue({ + showBottomSheet: true, + key: 'test-key', + height: '380px', + setBottomSheet: mockSetBottomSheet + }); + + const { container } = render(); + + const overlay = container.querySelector('[class*="fixed inset-0"]'); + const sheet = container.querySelector('[class*="fixed bottom-0"]'); + + expect(overlay?.className).toContain('animate-in fade-in-0'); + expect(sheet?.className).toContain('animate-in fade-in-0 slide-in-from-bottom'); + expect(sheet?.className).toContain('duration-300'); + }); + + it("calls onDragDown when clicking overlay", () => { + const mockOnDragDown = jest.fn(); + mockUseBottomSheetStore.mockReturnValue({ + showBottomSheet: true, + key: 'test-key', + height: '380px', + onDragDown: mockOnDragDown, + setBottomSheet: mockSetBottomSheet + }); + + const { container } = render(); + + const overlay = container.querySelector('[class*="fixed inset-0"]'); + expect(overlay).toBeInTheDocument(); + fireEvent.click(overlay!); + + expect(mockOnDragDown).toHaveBeenCalled(); expect(mockSetBottomSheet).toHaveBeenCalledWith(false); }); diff --git a/src/components/Duration/DurationController.tsx b/src/components/Duration/DurationController.tsx new file mode 100644 index 0000000..3e7b749 --- /dev/null +++ b/src/components/Duration/DurationController.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { DurationTabList } from "./components/DurationTabList"; +import { DurationValueList } from "./components/DurationValueList"; +import { HoursDurationValue } from "./components/HoursDurationValue"; +import { useTradeStore } from "@/stores/tradeStore"; +import { useBottomSheetStore } from "@/stores/bottomSheetStore"; +import { PrimaryButton } from "@/components/ui/primary-button"; + +const getDurationValues = (type: string): number[] => { + switch (type) { + case "tick": + return [1, 2, 3, 4, 5]; + case "second": + return [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, + ]; + case "minute": + return [1, 2, 3, 5, 10, 15, 30]; + case "hour": + return [1, 2, 3, 4, 6, 8, 12, 24]; + case "day": + return [1]; + default: + return []; + } +}; + +export const DurationController: React.FC = () => { + const { duration, setDuration } = useTradeStore(); + const { setBottomSheet } = useBottomSheetStore(); + + // Initialize local state with store value + const [localDuration, setLocalDuration] = React.useState(duration); + const [value, type] = localDuration.split(" "); + const selectedType = type; + const selectedValue: string | number = type === "hour" ? value : parseInt(value, 10); + + const handleTypeSelect = (type: string) => { + if (type === "hour") { + setLocalDuration("1:0 hour"); + } else { + const values = getDurationValues(type); + const newValue = values[0]; + setLocalDuration(`${newValue} ${type}`); + } + }; + + const handleValueSelect = (value: number) => { + setLocalDuration(`${value} ${selectedType}`); + }; + + const handleSave = () => { + setDuration(localDuration); // Update store with local state + setBottomSheet(false); + }; + + return ( +
+
+
+ Duration +
+ +
+
+
+ {selectedType === "hour" ? ( + setLocalDuration(`${value} hour`)} + /> + ) : ( + + )} +
+
+ Save +
+
+ ); +}; diff --git a/src/components/Duration/README.md b/src/components/Duration/README.md new file mode 100644 index 0000000..422be1e --- /dev/null +++ b/src/components/Duration/README.md @@ -0,0 +1,76 @@ +# Duration Component + +## Overview +The Duration component is a comprehensive solution for handling trade duration selection in the Champion Trader application. It provides an intuitive interface for users to select trade durations across different time units (ticks, seconds, minutes, hours, and days). + +## Architecture + +### Main Components +- `DurationController`: The main controller component that orchestrates duration selection +- `DurationTabList`: Handles the selection of duration types (tick, second, minute, hour, day) +- `DurationValueList`: Displays and manages the selection of specific duration values +- `HoursDurationValue`: Special component for handling hour-based durations with minute precision + +### State Management +- Uses Zustand via `useTradeStore` for managing duration state +- Integrates with `useBottomSheetStore` for modal behavior + +## Features +- Supports multiple duration types: + - Ticks (1-5) + - Seconds (1-60) + - Minutes (1, 2, 3, 5, 10, 15, 30) + - Hours (1, 2, 3, 4, 6, 8, 12, 24) + - Days (1) +- Real-time duration updates +- Responsive and accessible UI +- Integration with bottom sheet for mobile-friendly interaction + +## Usage + +```tsx +import { DurationController } from '@/components/Duration'; + +// Inside your component +const YourComponent = () => { + return ( + + ); +}; +``` + +## State Format +The duration state follows the format: `" "`, for example: +- "5 tick" +- "30 second" +- "15 minute" +- "2 hour" +- "1 day" + +For hours, the format supports minute precision: "1:30 hour" + +## Test Coverage +The component is thoroughly tested with Jest and React Testing Library, covering: +- Component rendering +- Duration type selection +- Duration value selection +- State management integration +- UI interactions + +## Dependencies +- React +- Zustand (for state management) +- TailwindCSS (for styling) +- Primary Button component + +## Styling +The component uses TailwindCSS for styling with a focus on: +- Mobile-first design +- Consistent spacing and typography +- Clear visual hierarchy +- Accessible color contrast + +## Integration Points +- Integrates with the Trade Page for duration selection +- Works within the Bottom Sheet component for mobile interactions +- Connects with the global trade store for state management diff --git a/src/components/Duration/__tests__/DurationController.test.tsx b/src/components/Duration/__tests__/DurationController.test.tsx new file mode 100644 index 0000000..ad1d21b --- /dev/null +++ b/src/components/Duration/__tests__/DurationController.test.tsx @@ -0,0 +1,276 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { DurationController } from '../DurationController'; +import { useTradeStore } from '@/stores/tradeStore'; +import { useBottomSheetStore } from '@/stores/bottomSheetStore'; +import type { TradeState } from '@/stores/tradeStore'; +import type { BottomSheetState } from '@/stores/bottomSheetStore'; + +// Mock the stores +jest.mock('@/stores/tradeStore', () => ({ + useTradeStore: jest.fn(), +})); + +jest.mock('@/stores/bottomSheetStore', () => ({ + useBottomSheetStore: jest.fn(), +})); + +// Mock scrollIntoView and IntersectionObserver +window.HTMLElement.prototype.scrollIntoView = jest.fn(); +const mockIntersectionObserver = jest.fn(); +mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, +}); +window.IntersectionObserver = mockIntersectionObserver; + +describe('DurationController', () => { + const mockSetDuration = jest.fn(); + const mockSetBottomSheet = jest.fn(); + const mockSetStake = jest.fn(); + const mockToggleAllowEquals = jest.fn(); + + const setupMocks = (initialDuration = '1 tick') => { + // Setup store mocks with proper typing + (useTradeStore as jest.MockedFunction).mockReturnValue({ + stake: '10 USD', + duration: initialDuration, + allowEquals: false, + setStake: mockSetStake, + setDuration: mockSetDuration, + toggleAllowEquals: mockToggleAllowEquals, + } as TradeState); + + (useBottomSheetStore as jest.MockedFunction).mockReturnValue({ + showBottomSheet: false, + key: null, + height: '380px', + setBottomSheet: mockSetBottomSheet, + } as BottomSheetState); + }; + + beforeEach(() => { + setupMocks(); + // Clear mocks + mockSetDuration.mockClear(); + mockSetBottomSheet.mockClear(); + mockSetStake.mockClear(); + mockToggleAllowEquals.mockClear(); + mockIntersectionObserver.mockClear(); + }); + + describe('Initial Render', () => { + it('renders duration types and initial value', () => { + render(); + + expect(screen.getByText('Duration')).toBeInTheDocument(); + expect(screen.getByText('Ticks')).toBeInTheDocument(); + expect(screen.getByText('Seconds')).toBeInTheDocument(); + expect(screen.getByText('Minutes')).toBeInTheDocument(); + expect(screen.getByText('Hours')).toBeInTheDocument(); + expect(screen.getByText('End Time')).toBeInTheDocument(); + }); + + it('syncs with store on mount', () => { + setupMocks('5 tick'); + render(); + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('5 tick'); + }); + }); + + describe('Ticks Duration', () => { + it('shows correct tick values', () => { + render(); + + const valueItems = screen.getAllByRole('radio'); + const values = valueItems.map(item => item.getAttribute('value')); + + expect(values).toEqual(['1', '2', '3', '4', '5']); + }); + + it('selects default tick value', () => { + render(); + + const selectedValue = screen.getByRole('radio', { checked: true }); + expect(selectedValue.getAttribute('value')).toBe('1'); + }); + + it('updates duration on tick selection', () => { + render(); + + const valueItems = screen.getAllByRole('radio'); + const threeTicks = valueItems.find(item => item.getAttribute('value') === '3'); + + if (threeTicks) { + act(() => { + fireEvent.click(threeTicks); + }); + } + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('3 tick'); + }); + }); + + describe('Minutes Duration', () => { + beforeEach(() => { + render(); + act(() => { + fireEvent.click(screen.getByText('Minutes')); + }); + }); + + it('shows correct minute values', () => { + const valueItems = screen.getAllByRole('radio'); + const values = valueItems.map(item => item.getAttribute('value')); + + expect(values).toEqual(['1', '2', '3', '5', '10', '15', '30']); + }); + + it('selects default minute value', () => { + const selectedValue = screen.getByRole('radio', { checked: true }); + expect(selectedValue.getAttribute('value')).toBe('1'); + }); + + it('updates duration on minute selection', () => { + const valueItems = screen.getAllByRole('radio'); + const fiveMinutes = valueItems.find(item => item.getAttribute('value') === '5'); + + if (fiveMinutes) { + act(() => { + fireEvent.click(fiveMinutes); + }); + } + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('5 minute'); + }); + }); + + describe('Hours Duration', () => { + beforeEach(() => { + render(); + act(() => { + fireEvent.click(screen.getByText('Hours')); + }); + }); + + it('handles hour:minute format correctly', () => { + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('1:0 hour'); + }); + + it('preserves hour selection when switching tabs', () => { + // Switch to minutes + act(() => { + fireEvent.click(screen.getByText('Minutes')); + }); + + // Switch back to hours + act(() => { + fireEvent.click(screen.getByText('Hours')); + }); + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('1:0 hour'); + }); + }); + + describe('End Time Duration', () => { + beforeEach(() => { + render(); + act(() => { + fireEvent.click(screen.getByText('End Time')); + }); + }); + + it('updates duration for end time selection', () => { + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('1 day'); + }); + }); + + describe('State Management', () => { + it('preserves local state until save', () => { + render(); + + // Change to minutes + act(() => { + fireEvent.click(screen.getByText('Minutes')); + }); + + // Select 5 minutes + const valueItems = screen.getAllByRole('radio'); + const fiveMinutes = valueItems.find(item => item.getAttribute('value') === '5'); + + if (fiveMinutes) { + act(() => { + fireEvent.click(fiveMinutes); + }); + } + + // Verify store hasn't been updated yet + expect(mockSetDuration).not.toHaveBeenCalled(); + + // Save changes + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + // Verify store is updated with new value + expect(mockSetDuration).toHaveBeenCalledWith('5 minute'); + expect(mockSetBottomSheet).toHaveBeenCalledWith(false); + }); + + it('handles rapid tab switching without losing state', () => { + render(); + + // Rapid switches between duration types + act(() => { + fireEvent.click(screen.getByText('Minutes')); + fireEvent.click(screen.getByText('Hours')); + fireEvent.click(screen.getByText('End Time')); + fireEvent.click(screen.getByText('Minutes')); + }); + + // Select and save a value + const valueItems = screen.getAllByRole('radio'); + const threeMinutes = valueItems.find(item => item.getAttribute('value') === '3'); + + if (threeMinutes) { + act(() => { + fireEvent.click(threeMinutes); + }); + } + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('3 minute'); + }); + + it('handles invalid duration format gracefully', () => { + setupMocks('invalid duration'); + render(); + + // Should use the provided invalid duration + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockSetDuration).toHaveBeenCalledWith('invalid duration'); + }); + }); +}); diff --git a/src/components/Duration/components/DurationTab.tsx b/src/components/Duration/components/DurationTab.tsx new file mode 100644 index 0000000..33e0724 --- /dev/null +++ b/src/components/Duration/components/DurationTab.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Chip } from '@/components/ui/chip'; + +interface DurationTabProps { + label: string; + isSelected: boolean; + onSelect: () => void; +} + +export const DurationTab: React.FC = ({ + label, + isSelected, + onSelect +}) => { + return ( + + {label} + + ); +}; diff --git a/src/components/Duration/components/DurationTabList.tsx b/src/components/Duration/components/DurationTabList.tsx new file mode 100644 index 0000000..5e9b017 --- /dev/null +++ b/src/components/Duration/components/DurationTabList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { DurationTab } from './DurationTab'; + +interface DurationTabListProps { + selectedType: string; + onTypeSelect: (type: string) => void; +} + +const DURATION_TYPES = [ + { label: 'Ticks', value: 'tick' }, + { label: 'Seconds', value: 'second' }, + { label: 'Minutes', value: 'minute' }, + { label: 'Hours', value: 'hour' }, + { label: 'End Time', value: 'day' } +]; + +export const DurationTabList: React.FC = ({ + selectedType, + onTypeSelect +}) => { + return ( +
+
+ {DURATION_TYPES.map(({ label, value }) => ( +
+ onTypeSelect(value)} + /> +
+ ))} +
+
+ ); +}; diff --git a/src/components/Duration/components/DurationValueList.tsx b/src/components/Duration/components/DurationValueList.tsx new file mode 100644 index 0000000..5e7f59f --- /dev/null +++ b/src/components/Duration/components/DurationValueList.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useRef } from "react"; + +interface DurationValueListProps { + selectedValue: number; + durationType: string; + onValueSelect: (value: number) => void; + getDurationValues: (type: string) => number[]; +} + +const getUnitLabel = (type: string, value: number): string => { + switch (type) { + case "tick": + return value === 1 ? "tick" : "ticks"; + case "second": + return value === 1 ? "second" : "seconds"; + case "minute": + return value === 1 ? "minute" : "minutes"; + case "hour": + return value === 1 ? "hour" : "hours"; + case "day": + return "day"; + default: + return ""; + } +}; + +const ITEM_HEIGHT = 48; +const SPACER_HEIGHT = 110; + +export const DurationValueList: React.FC = ({ + selectedValue, + durationType, + onValueSelect, + getDurationValues +}) => { + const containerRef = useRef(null); + const values = getDurationValues(durationType); + const intersectionObserverRef = useRef(); + const timeoutRef = useRef(); + + const handleClick = (value: number) => { + // First call onValueSelect to update the selected value + onValueSelect(value); + + // Then scroll the clicked item into view with smooth animation + const clickedItem = containerRef.current?.querySelector(`[data-value="${value}"]`); + if (clickedItem) { + clickedItem.scrollIntoView({ + block: 'center', + behavior: 'smooth' + }); + } + }; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // First scroll to selected value + const selectedItem = container.querySelector(`[data-value="${selectedValue}"]`); + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'center', behavior: 'instant' }); + } + + // Add a small delay before setting up the observer to ensure scroll completes + timeoutRef.current = setTimeout(() => { + intersectionObserverRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const value = parseInt( + entry.target.getAttribute("data-value") || "0", + 10 + ); + onValueSelect(value); + } + }); + }, + { + root: container, + rootMargin: "-51% 0px -49% 0px", + threshold: 0, + } + ); + + const items = container.querySelectorAll(".duration-value-item"); + items.forEach((item) => intersectionObserverRef.current?.observe(item)); + + return () => { + if (intersectionObserverRef.current) { + intersectionObserverRef.current.disconnect(); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, 100); + }, []); + + return ( +
+ {/* Selection zone with gradient background */} +
+ + {/* Scrollable content */} +
+ {/* Top spacer */} +
+ + {values.map((value) => ( + + ))} + + {/* Bottom spacer */} +
+
+
+ ); +}; diff --git a/src/components/Duration/components/HoursDurationValue.tsx b/src/components/Duration/components/HoursDurationValue.tsx new file mode 100644 index 0000000..cc3ee72 --- /dev/null +++ b/src/components/Duration/components/HoursDurationValue.tsx @@ -0,0 +1,67 @@ +import React, { useRef } from "react"; +import { DurationValueList } from "./DurationValueList"; + +interface HoursDurationValueProps { + selectedValue: string; // "2:12" format + onValueSelect: (value: string) => void; +} + +const getHoursValues = (): number[] => [1, 2, 3, 4, 6, 8, 12, 24]; +const getMinutesValues = (): number[] => Array.from({ length: 60 }, (_, i) => i); + +export const HoursDurationValue: React.FC = ({ + selectedValue, + onValueSelect, +}) => { + // Use refs to store last valid values + const lastValidHours = useRef(); + const lastValidMinutes = useRef(); + + // Initialize refs if they're undefined + if (!lastValidHours.current || !lastValidMinutes.current) { + const [h, m] = selectedValue.split(":").map(Number); + lastValidHours.current = h; + lastValidMinutes.current = m; + } + + const handleHoursSelect = (newHours: number) => { + lastValidHours.current = newHours; + onValueSelect(`${newHours}:${lastValidMinutes.current}`); + }; + + const handleMinutesSelect = (newMinutes: number) => { + lastValidMinutes.current = newMinutes; + onValueSelect(`${lastValidHours.current}:${newMinutes}`); + }; + + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/src/components/Duration/components/__tests__/DurationTab.test.tsx b/src/components/Duration/components/__tests__/DurationTab.test.tsx new file mode 100644 index 0000000..78f5bad --- /dev/null +++ b/src/components/Duration/components/__tests__/DurationTab.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DurationTab } from '../DurationTab'; + +describe('DurationTab', () => { + const defaultProps = { + label: 'Minutes', + isSelected: false, + onSelect: jest.fn(), + }; + + beforeEach(() => { + defaultProps.onSelect.mockClear(); + }); + + describe('Rendering', () => { + it('renders label correctly', () => { + render(); + expect(screen.getByText('Minutes')).toBeInTheDocument(); + }); + + it('renders with different labels', () => { + const labels = ['Ticks', 'Seconds', 'Hours', 'End Time']; + + labels.forEach(label => { + const { rerender } = render(); + expect(screen.getByText(label)).toBeInTheDocument(); + rerender(); // Reset to default + }); + }); + + it('updates visual state when isSelected changes', () => { + const { rerender } = render(); + const initialButton = screen.getByRole('button'); + const initialClassName = initialButton.className; + + rerender(); + const selectedButton = screen.getByRole('button'); + const selectedClassName = selectedButton.className; + + expect(initialClassName).not.toBe(selectedClassName); + expect(selectedClassName).toContain('bg-black'); + }); + }); + + describe('Interaction', () => { + it('handles click events', () => { + render(); + + fireEvent.click(screen.getByText('Minutes')); + expect(defaultProps.onSelect).toHaveBeenCalledTimes(1); + }); + + it('maintains interactivity after multiple clicks', () => { + render(); + const button = screen.getByRole('button'); + + // Multiple clicks should trigger multiple calls + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + expect(defaultProps.onSelect).toHaveBeenCalledTimes(3); + }); + }); + + describe('Styling', () => { + it('applies selected styles when isSelected is true', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('bg-black'); + expect(button.className).toContain('text-white'); + }); + + it('applies unselected styles when isSelected is false', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('bg-white'); + expect(button.className).toContain('text-black/60'); + }); + + it('maintains consistent height', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('h-8'); + }); + }); + + describe('Error Handling', () => { + // Using console.error spy to verify prop type warnings + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + it('handles missing props gracefully', () => { + // @ts-ignore - Testing JS usage + expect(() => render()).not.toThrow(); + }); + + it('handles invalid prop types gracefully', () => { + // @ts-ignore - Testing JS usage + expect(() => render()).not.toThrow(); + }); + }); +}); diff --git a/src/components/Duration/components/__tests__/DurationTabList.test.tsx b/src/components/Duration/components/__tests__/DurationTabList.test.tsx new file mode 100644 index 0000000..be55a22 --- /dev/null +++ b/src/components/Duration/components/__tests__/DurationTabList.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DurationTabList } from '../DurationTabList'; + +describe('DurationTabList', () => { + const defaultProps = { + selectedType: 'tick', + onTypeSelect: jest.fn(), + }; + + beforeEach(() => { + defaultProps.onTypeSelect.mockClear(); + }); + + it('renders all expected duration types', () => { + render(); + // Originally expected types are "Ticks", "Minutes", "Hours", and "End Time" + expect(screen.getByText('Ticks')).toBeInTheDocument(); + expect(screen.getByText('Minutes')).toBeInTheDocument(); + expect(screen.getByText('Hours')).toBeInTheDocument(); + expect(screen.getByText('End Time')).toBeInTheDocument(); + }); + + it('handles click events', () => { + const mockOnTypeSelect = jest.fn(); + render(); + fireEvent.click(screen.getByText('Minutes')); + expect(mockOnTypeSelect).toHaveBeenCalledWith('minute'); + }); + + it('handles keyboard navigation', () => { + const mockOnTypeSelect = jest.fn(); + render(); + const minutesTab = screen.getByText('Minutes'); + minutesTab.focus(); + // Simulate Enter key press + fireEvent.keyDown(minutesTab, { key: 'Enter', code: 'Enter' }); + expect(mockOnTypeSelect).toHaveBeenCalledWith('minute'); + mockOnTypeSelect.mockClear(); + // Simulate Space key press + fireEvent.keyDown(minutesTab, { key: ' ', code: 'Space' }); + expect(mockOnTypeSelect).toHaveBeenCalledWith('minute'); + }); +}); diff --git a/src/components/Duration/components/__tests__/DurationValueList.test.tsx b/src/components/Duration/components/__tests__/DurationValueList.test.tsx new file mode 100644 index 0000000..8348c85 --- /dev/null +++ b/src/components/Duration/components/__tests__/DurationValueList.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DurationValueList } from '../DurationValueList'; + +// Mock IntersectionObserver +const mockIntersectionObserver = jest.fn(); +const mockObserve = jest.fn(); +const mockDisconnect = jest.fn(); +const mockUnobserve = jest.fn(); + +mockIntersectionObserver.mockImplementation(() => ({ + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, +})); +window.IntersectionObserver = mockIntersectionObserver; + +// Mock scrollIntoView +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +describe('DurationValueList', () => { + const defaultProps = { + selectedValue: 1, + durationType: 'tick', + onValueSelect: jest.fn(), + getDurationValues: () => [1, 2, 3, 4, 5], + }; + + beforeEach(() => { + defaultProps.onValueSelect.mockClear(); + (window.HTMLElement.prototype.scrollIntoView as jest.Mock).mockClear(); + mockIntersectionObserver.mockClear(); + mockObserve.mockClear(); + mockDisconnect.mockClear(); + mockUnobserve.mockClear(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders duration values with correct unit labels', () => { + render(); + + // Check singular form + expect(screen.getByText('1 tick')).toBeInTheDocument(); + // Check plural form + expect(screen.getByText('2 ticks')).toBeInTheDocument(); + }); + + it('handles different duration types correctly', () => { + const props = { + ...defaultProps, + durationType: 'minute', + getDurationValues: () => [1, 2, 5], + }; + + render(); + + expect(screen.getByText('1 minute')).toBeInTheDocument(); + expect(screen.getByText('2 minutes')).toBeInTheDocument(); + expect(screen.getByText('5 minutes')).toBeInTheDocument(); + }); + + it('marks selected value as checked', () => { + render(); + + const selectedInput = screen.getByDisplayValue('1') as HTMLInputElement; + expect(selectedInput.checked).toBe(true); + + const unselectedInput = screen.getByDisplayValue('2') as HTMLInputElement; + expect(unselectedInput.checked).toBe(false); + }); + + it('calls onValueSelect and scrolls when value is clicked', () => { + render(); + + fireEvent.click(screen.getByText('3 ticks')); + + expect(defaultProps.onValueSelect).toHaveBeenCalledWith(3); + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ + block: 'center', + behavior: 'smooth', + }); + }); + + it('applies correct styles to selected and unselected values', () => { + render(); + + const selectedText = screen.getByText('1 tick'); + const unselectedText = screen.getByText('2 ticks'); + + expect(selectedText.className).toContain('text-black'); + expect(unselectedText.className).toContain('text-gray-300'); + }); + + it('renders with correct spacing and layout', () => { + const { container } = render(); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass('relative h-[268px]'); + + const scrollContainer = wrapper.querySelector('div:nth-child(2)'); + expect(scrollContainer).toHaveClass('h-full overflow-y-auto scroll-smooth snap-y snap-mandatory [&::-webkit-scrollbar]:hidden'); + }); + + it('handles day duration type without plural form', () => { + const props = { + ...defaultProps, + durationType: 'day', + getDurationValues: () => [1], + }; + + render(); + + expect(screen.getByText('1 day')).toBeInTheDocument(); + }); + + it('initializes with correct scroll position', () => { + render(); + + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ + block: 'center', + behavior: 'instant', + }); + }); + + it('handles value changes after initial render', () => { + const { rerender } = render(); + + // Change selected value + rerender(); + + const newSelectedInput = screen.getByDisplayValue('3') as HTMLInputElement; + expect(newSelectedInput.checked).toBe(true); + }); +}); diff --git a/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx new file mode 100644 index 0000000..451d128 --- /dev/null +++ b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx @@ -0,0 +1,100 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { HoursDurationValue } from '../HoursDurationValue'; + +// Mock the DurationValueList component since we're testing HoursDurationValue in isolation +jest.mock('../DurationValueList', () => ({ + DurationValueList: ({ selectedValue, durationType, onValueSelect }: any) => ( +
+ + Current value: {selectedValue} +
+ ), +})); + +describe('HoursDurationValue', () => { + const defaultProps = { + selectedValue: '2:30', + onValueSelect: jest.fn(), + }; + + beforeEach(() => { + defaultProps.onValueSelect.mockClear(); + }); + + it('renders hours and minutes sections with proper ARIA labels', () => { + render(); + + const container = screen.getByRole('group'); + expect(container).toHaveAttribute('aria-label', 'Duration in hours and minutes'); + + const hoursSection = screen.getByLabelText('Hours'); + const minutesSection = screen.getByLabelText('Minutes'); + + expect(hoursSection).toBeInTheDocument(); + expect(minutesSection).toBeInTheDocument(); + }); + + it('initializes with correct hour and minute values', () => { + render(); + + expect(screen.getByTestId('duration-value-list-hour')).toHaveTextContent('Current value: 3'); + expect(screen.getByTestId('duration-value-list-minute')).toHaveTextContent('Current value: 45'); + }); + + it('handles hour selection', () => { + render(); + + fireEvent.click(screen.getByTestId('increment-hour')); + + expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:30'); + }); + + it('handles minute selection', () => { + render(); + + fireEvent.click(screen.getByTestId('increment-minute')); + + expect(defaultProps.onValueSelect).toHaveBeenCalledWith('2:31'); + }); + + it('maintains last valid values when selecting new values', () => { + const { rerender } = render(); + + // Change hours + fireEvent.click(screen.getByTestId('increment-hour')); + expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:30'); + + // Update component with new value + rerender(); + + // Change minutes + fireEvent.click(screen.getByTestId('increment-minute')); + expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:31'); + }); + + it('renders with correct layout', () => { + const { container } = render(); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass('flex w-full'); + + const [hoursDiv, minutesDiv] = wrapper.childNodes; + expect(hoursDiv).toHaveClass('flex-1'); + expect(minutesDiv).toHaveClass('flex-1'); + }); + + it('passes correct props to DurationValueList components', () => { + render(); + + const hoursList = screen.getByTestId('duration-value-list-hour'); + const minutesList = screen.getByTestId('duration-value-list-minute'); + + expect(hoursList).toBeInTheDocument(); + expect(minutesList).toBeInTheDocument(); + }); +}); diff --git a/src/components/Duration/index.ts b/src/components/Duration/index.ts new file mode 100644 index 0000000..3fe9f81 --- /dev/null +++ b/src/components/Duration/index.ts @@ -0,0 +1,3 @@ +export { DurationController } from './DurationController'; +export { DurationTabList } from './components/DurationTabList'; +export { DurationValueList } from './components/DurationValueList'; diff --git a/src/components/TradeButton/Button.tsx b/src/components/TradeButton/Button.tsx index 3acbab0..4fd739f 100644 --- a/src/components/TradeButton/Button.tsx +++ b/src/components/TradeButton/Button.tsx @@ -6,6 +6,7 @@ interface TradeButtonProps { label: string; value: string; title_position: "left" | "right"; + onClick?: () => void; } export const TradeButton: React.FC = ({ @@ -14,13 +15,23 @@ export const TradeButton: React.FC = ({ label, value, title_position, + onClick, }) => { const isLeft = title_position === "left"; return ( + ); + } + return ( - - -
{label}
-
{value}
-
-
+
+ {label} + {formattedValue} +
); }; diff --git a/src/components/TradeFields/__tests__/TradeParam.test.tsx b/src/components/TradeFields/__tests__/TradeParam.test.tsx new file mode 100644 index 0000000..120b886 --- /dev/null +++ b/src/components/TradeFields/__tests__/TradeParam.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeParam from '../TradeParam'; +import { formatDurationDisplay } from '@/utils/duration'; + +// Mock the duration utility +jest.mock('@/utils/duration', () => ({ + formatDurationDisplay: jest.fn(), +})); + +describe('TradeParam', () => { + const mockFormatDurationDisplay = formatDurationDisplay as jest.Mock; + + beforeEach(() => { + mockFormatDurationDisplay.mockReset(); + mockFormatDurationDisplay.mockImplementation((value) => value); + }); + + it('renders label and value correctly', () => { + render(); + + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + }); + + it('formats duration value when label is "Duration"', () => { + mockFormatDurationDisplay.mockReturnValue('2 minutes'); + render(); + + expect(mockFormatDurationDisplay).toHaveBeenCalledWith('2 minute'); + expect(screen.getByText('2 minutes')).toBeInTheDocument(); + }); + + it('does not format value for other labels', () => { + render(); + + expect(mockFormatDurationDisplay).not.toHaveBeenCalled(); + expect(screen.getByText('100')).toBeInTheDocument(); + }); + + it('renders as a button with correct ARIA label when onClick is provided', () => { + const handleClick = jest.fn(); + render(); + + const button = screen.getByRole('button', { name: 'Amount: 100' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('type', 'button'); + }); + + it('handles click events', () => { + const handleClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('handles keyboard interactions', () => { + const handleClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + + // Test Enter key + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + expect(handleClick).toHaveBeenCalledTimes(1); + + // Test Space key + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(handleClick).toHaveBeenCalledTimes(2); + }); + + it('renders as a div when onClick is not provided', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/src/components/ui/README.md b/src/components/ui/README.md new file mode 100644 index 0000000..3b61f87 --- /dev/null +++ b/src/components/ui/README.md @@ -0,0 +1,117 @@ +# UI Components + +## Overview +This directory contains reusable UI components built with React, TypeScript, and TailwindCSS. Each component follows atomic design principles and maintains consistent styling across the application. + +## Components + +### Chip Component + +A reusable chip/tag component that supports selection states and click interactions. + +#### Features +- Selectable state with visual feedback +- Consistent height and padding +- Smooth transitions +- Shadow and ring effects for depth +- Mobile-friendly touch target + +#### Props +```typescript +interface ChipProps { + children: React.ReactNode; // Content to display inside the chip + isSelected?: boolean; // Optional selection state + onClick?: () => void; // Optional click handler +} +``` + +#### Usage +```tsx +import { Chip } from '@/components/ui/chip'; + +// Basic usage +Label + +// With selection state +Selected + +// With click handler + console.log('clicked')}>Clickable +``` + +#### Styling +The component uses TailwindCSS with: +- Fixed height (32px) +- Rounded full corners +- IBM Plex font +- Transitions for all properties +- Different styles for selected/unselected states: + - Selected: Black background with white text + - Unselected: White background with semi-transparent black text, subtle ring and shadow + +#### Example in Duration Component +```tsx + onTypeSelect('minute')} +> + Minutes + +``` + +### PrimaryButton Component + +A styled button component that extends the base Button component with primary action styling. + +#### Features +- Full width by default +- Black background with hover state +- Consistent padding and rounded corners +- Semibold text weight +- Forward ref support +- Extends all HTML button attributes + +#### Props +```typescript +interface PrimaryButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; // Content to display inside the button + className?: string; // Optional additional classes +} +``` + +#### Usage +```tsx +import { PrimaryButton } from '@/components/ui/primary-button'; + +// Basic usage + + Submit + + +// With custom className + + Save Changes + + +// With onClick handler + console.log('clicked')}> + Click Me + +``` + +#### Styling +The component uses TailwindCSS with: +- Full width layout +- Large padding (py-6) +- Base text size +- Semibold font weight +- Black background with slightly transparent hover state +- Large border radius (rounded-lg) +- Supports className prop for additional customization + +#### Example in Duration Component +```tsx + + Save + +``` diff --git a/src/components/ui/__tests__/chip.test.tsx b/src/components/ui/__tests__/chip.test.tsx new file mode 100644 index 0000000..54dd643 --- /dev/null +++ b/src/components/ui/__tests__/chip.test.tsx @@ -0,0 +1,75 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { Chip } from '../chip'; + +describe('Chip', () => { + const defaultProps = { + children: 'Test Chip', + }; + + it('renders children correctly', () => { + render({defaultProps.children}); + expect(screen.getByText('Test Chip')).toBeInTheDocument(); + }); + + it('handles click events', () => { + const handleClick = jest.fn(); + render({defaultProps.children}); + + fireEvent.click(screen.getByText('Test Chip')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('applies selected styles when isSelected is true', () => { + const { container } = render( + {defaultProps.children} + ); + + const button = container.firstChild as HTMLElement; + expect(button.className).toContain('bg-black text-white'); + }); + + it('applies unselected styles when isSelected is false', () => { + const { container } = render( + {defaultProps.children} + ); + + const button = container.firstChild as HTMLElement; + expect(button.className).toContain('bg-white text-black/60'); + }); + + it('maintains consistent height', () => { + const { container } = render({defaultProps.children}); + + const button = container.firstChild as HTMLElement; + expect(button.className).toContain('h-8 min-h-[32px] max-h-[32px]'); + }); + + it('handles long text with ellipsis', () => { + const { container } = render( + {'Very long text that should be truncated'} + ); + + const button = container.firstChild as HTMLElement; + expect(button.className).toContain('whitespace-nowrap'); + }); + + it('is accessible with keyboard navigation', () => { + const handleClick = jest.fn(); + render( + {defaultProps.children} + ); + + const button = screen.getByText('Test Chip'); + button.focus(); + + // Simulate Enter key press + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + expect(handleClick).toHaveBeenCalled(); + + handleClick.mockClear(); + + // Simulate Space key press + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(handleClick).toHaveBeenCalled(); + }); +}); diff --git a/src/components/ui/__tests__/primary-button.test.tsx b/src/components/ui/__tests__/primary-button.test.tsx new file mode 100644 index 0000000..1dc3c88 --- /dev/null +++ b/src/components/ui/__tests__/primary-button.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { PrimaryButton } from '../primary-button'; + +describe('PrimaryButton', () => { + const defaultProps = { + children: 'Test Button', + }; + + it('renders children correctly', () => { + render({defaultProps.children}); + expect(screen.getByText('Test Button')).toBeInTheDocument(); + }); + + it('handles click events', () => { + const handleClick = jest.fn(); + render({defaultProps.children}); + fireEvent.click(screen.getByText('Test Button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('applies hover styles', () => { + // This test can be enhanced with visual regression tools. + const { container } = render({defaultProps.children}); + const button = container.firstChild as HTMLElement; + expect(button.className).toContain('hover:bg-black/90'); + }); + + it('spreads additional props to button element', () => { + const { container } = render( + + {defaultProps.children} + + ); + const button = container.firstChild as HTMLElement; + expect(button).toHaveAttribute('data-testid', 'custom-button'); + expect(button).toHaveAttribute('aria-label', 'Custom Button'); + }); +}); diff --git a/src/components/ui/chip.tsx b/src/components/ui/chip.tsx new file mode 100644 index 0000000..b954073 --- /dev/null +++ b/src/components/ui/chip.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface ChipProps { + children: React.ReactNode; + isSelected?: boolean; + onClick?: () => void; +} + +export const Chip: React.FC = ({ children, isSelected, onClick }) => { + return ( + + ); +}; diff --git a/src/components/ui/primary-button.tsx b/src/components/ui/primary-button.tsx new file mode 100644 index 0000000..cb1e338 --- /dev/null +++ b/src/components/ui/primary-button.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "./button"; + +interface PrimaryButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export const PrimaryButton = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( + + ); + } +); + +PrimaryButton.displayName = "PrimaryButton"; diff --git a/src/config/bottomSheetConfig.tsx b/src/config/bottomSheetConfig.tsx index d5dfe9b..7417ddf 100644 --- a/src/config/bottomSheetConfig.tsx +++ b/src/config/bottomSheetConfig.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import { DurationController } from '@/components/Duration'; export interface BottomSheetConfig { [key: string]: { @@ -15,5 +16,12 @@ export const bottomSheetConfig: BottomSheetConfig = {
) + }, + 'duration': { + body: ( +
+ +
+ ) } }; diff --git a/src/global.css b/src/global.css index b268bf6..3644bb3 100644 --- a/src/global.css +++ b/src/global.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; @@ -71,6 +73,18 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-ibm-plex text-body; + text-underline-position: from-font; + text-decoration-skip-ink: none; + } +} + +@layer utilities { + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; } } diff --git a/src/screens/TradePage/TradePage.tsx b/src/screens/TradePage/TradePage.tsx index 16afc32..824feff 100644 --- a/src/screens/TradePage/TradePage.tsx +++ b/src/screens/TradePage/TradePage.tsx @@ -36,6 +36,10 @@ export const TradePage: React.FC = () => { const handleStakeClick = () => { setBottomSheet(true, 'stake'); }; + + const handleDurationClick = () => { + setBottomSheet(true, 'duration', '470px'); + }; return (
@@ -89,6 +93,7 @@ export const TradePage: React.FC = () => { label="Duration" value={duration} className={isLandscape ? 'w-full' : ''} + onClick={handleDurationClick} /> ({ - TradeButton: ({ title, value }: { title: string; value: string }) => ( -
-
{title}
-
{value}
-
- ) +// Mock the stores +jest.mock('@/stores/tradeStore'); +jest.mock('@/stores/bottomSheetStore'); + +// Mock the components that are loaded with Suspense +jest.mock('@/components/AddMarketButton', () => ({ + AddMarketButton: () =>
Add Market Button
})); jest.mock('@/components/Chart', () => ({ - Chart: () =>
Chart Component
+ Chart: () =>
Chart
})); -jest.mock('@/components/AddMarketButton', () => ({ - AddMarketButton: () =>
Add Market Button
+jest.mock('@/components/BalanceDisplay', () => ({ + BalanceDisplay: () =>
Balance Display
})); -// Mock shadcn components -jest.mock('@/components/ui/card', () => ({ - Card: ({ children }: { children: React.ReactNode }) =>
{children}
, - CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +jest.mock('@/components/DurationOptions', () => ({ + DurationOptions: () =>
Duration Options
})); -jest.mock('@/components/ui/switch', () => ({ - Switch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => ( - - ), +jest.mock('@/components/TradeButton', () => ({ + TradeButton: () =>
Trade Button
})); -// Mock the store -const mockUseTradeStore = useTradeStore as unknown as jest.MockedFunction<() => TradeState>; -jest.mock('@/stores/tradeStore', () => ({ - useTradeStore: jest.fn() +jest.mock('@/components/BottomSheet', () => ({ + BottomSheet: () =>
Bottom Sheet
})); +// Type the mocked modules +const mockedUseTradeStore = useTradeStore as jest.MockedFunction; +const mockedUseBottomSheetStore = useBottomSheetStore as jest.MockedFunction; + describe('TradePage', () => { + const mockToggleAllowEquals = jest.fn(); + const mockSetBottomSheet = jest.fn(); + beforeEach(() => { - mockUseTradeStore.mockImplementation(() => ({ - stake: '10 USD', - duration: '10 tick', + // Setup store mocks + mockedUseTradeStore.mockReturnValue({ + stake: '10.00', + duration: '1 minute', allowEquals: false, - setStake: jest.fn(), - setDuration: jest.fn(), - toggleAllowEquals: jest.fn() - })); + toggleAllowEquals: mockToggleAllowEquals + } as any); + + mockedUseBottomSheetStore.mockReturnValue({ + setBottomSheet: mockSetBottomSheet + } as any); + + // Clear mocks + mockToggleAllowEquals.mockClear(); + mockSetBottomSheet.mockClear(); + }); + + it('renders all trade components', () => { + render(); + + // Balance display is only visible in landscape mode + expect(screen.queryByTestId('balance-display')).not.toBeInTheDocument(); + expect(screen.getByTestId('bottom-sheet')).toBeInTheDocument(); + expect(screen.getAllByTestId('add-market-button')).toHaveLength(1); // Only portrait mode by default + expect(screen.getByTestId('duration-options')).toBeInTheDocument(); }); - it('renders market information', async () => { - await act(async () => { - render(); - }); - - // Use getAllByText since the text appears in both landscape and portrait views - expect(screen.getByText('Vol. 100 (1s) Index')).toBeInTheDocument(); - expect(screen.getByText('Rise/Fall')).toBeInTheDocument(); + it('toggles allow equals', () => { + render(); + + const toggleSwitch = screen.getByRole('switch', { name: 'Allow equals' }); + fireEvent.click(toggleSwitch); + + expect(mockToggleAllowEquals).toHaveBeenCalled(); + }); + + it('renders market info', () => { + render(); + + // Get all instances of market info + const marketTitles = screen.getAllByText('Vol. 100 (1s) Index'); + const marketSubtitles = screen.getAllByText('Rise/Fall'); + + // Verify both landscape and portrait instances + expect(marketTitles).toHaveLength(1); // Only portrait mode by default + expect(marketSubtitles).toHaveLength(1); }); - it('renders trade parameters from store', async () => { - await act(async () => { - render(); - }); - - expect(screen.getByText('10 USD')).toBeInTheDocument(); - expect(screen.getByText('10 tick')).toBeInTheDocument(); + it('opens duration bottom sheet when duration is clicked', () => { + render(); + + const durationParam = screen.getByText('Duration').closest('button'); + fireEvent.click(durationParam!); + + expect(mockSetBottomSheet).toHaveBeenCalledWith(true, 'duration', '470px'); }); - it('renders trade buttons', async () => { - await act(async () => { - render(); - }); - - expect(screen.getByText('Rise')).toBeInTheDocument(); - expect(screen.getByText('Fall')).toBeInTheDocument(); + it('opens stake bottom sheet when stake is clicked', () => { + render(); + + const stakeParam = screen.getByText('Stake').closest('button'); + fireEvent.click(stakeParam!); + + expect(mockSetBottomSheet).toHaveBeenCalledWith(true, 'stake'); }); - it('toggles allow equals when clicked', async () => { - const toggleMock = jest.fn(); - mockUseTradeStore.mockImplementation(() => ({ - stake: '10 USD', - duration: '10 tick', - allowEquals: false, - setStake: jest.fn(), - setDuration: jest.fn(), - toggleAllowEquals: toggleMock - })); - - await act(async () => { - render(); - }); - - const switchButton = screen.getByTestId('allow-equals-switch'); - expect(switchButton).toHaveAttribute('data-state', 'unchecked'); - - await act(async () => { - switchButton.click(); - }); - - expect(toggleMock).toHaveBeenCalled(); + it('renders trade buttons', () => { + render(); + + const tradeButtons = screen.getAllByTestId('trade-button'); + expect(tradeButtons).toHaveLength(2); // Rise and Fall buttons }); }); diff --git a/src/services/api/sse/README.md b/src/services/api/sse/README.md index 4184a1b..f527526 100644 --- a/src/services/api/sse/README.md +++ b/src/services/api/sse/README.md @@ -1,97 +1,95 @@ # Server-Sent Events (SSE) Services -This folder contains the SSE service implementations that power real-time data streaming for market and contract price updates. These services provide a robust, unidirectional channel from the server to the client—offering automatic reconnection, error handling, and type-safe messaging. +This directory contains services that handle real-time data streaming using Server-Sent Events (SSE). SSE provides a more efficient, unidirectional communication channel compared to WebSocket connections, with built-in reconnection handling and better compatibility with modern load balancers. -## Folder Structure +## Contract SSE Service -``` -src/services/api/sse/ -├── base/ -│ ├── service.ts # Core service functionality for establishing SSE connections -│ ├── public.ts # Service for unauthenticated (public) SSE endpoints -│ ├── protected.ts # Service for authenticated (protected) SSE endpoints -│ └── types.ts # Shared type definitions for SSE messages and events -├── market/ -│ └── service.ts # SSE service for streaming market data -└── contract/ - └── service.ts # SSE service for streaming contract pricing data -``` - -## Overview - -The SSE services enable efficient, real-time streaming by: -- Maintaining persistent HTTP connections with automatic reconnection logic. -- Supporting both public and protected endpoints for secure data streaming. -- Providing type-safe communication through clearly defined message types. -- Offering a modular design to easily integrate with various parts of the application. +The ContractSSEService handles real-time contract price updates through SSE connections. -## Configuration +### Features -SSE endpoint URLs and other relevant settings are configured in the main configuration file ([src/config/api.ts](../../config/api.ts)). Ensure that your environment variables (such as `RSBUILD_SSE_PUBLIC_PATH` and `RSBUILD_SSE_PROTECTED_PATH`) are set correctly for proper operation. +- Automatic reconnection with exponential backoff +- Maintains active contract subscriptions across reconnections +- Robust error handling and parsing +- Auth token management +- Event-based architecture for price updates -## Usage Example +### Message Format -Each SSE service requires an `action` parameter that determines the type of data stream: - -### Market SSE Service +Contract price updates follow this format: ```typescript -import { MarketSSEService } from '@/services/api/sse/market/service'; +interface ContractPriceMessage { + action: 'contract_price'; + data: { + date_start: number; // Unix timestamp in milliseconds + date_expiry: number; // Unix timestamp in milliseconds + spot: string; // Current spot price + strike: string; // Strike price + price: string; // Contract price + trade_type: string; // e.g., 'CALL', 'PUT' + instrument: string; // e.g., 'R_100' + currency: string; // e.g., 'USD' + payout: string; // Payout amount + pricing_parameters: { + volatility: string; + duration_in_years: string; + } + } +} +``` -const marketService = new MarketSSEService(); +### Testing -// Subscribe to real-time market updates -// This will create an SSE connection with ?action=instrument_price -marketService.subscribeToPrice('R_100'); +The service is thoroughly tested with Jest: -// The service will handle the action parameter internally -marketService.on('instrument_price', (data) => { - console.log('New market data received:', data); -}); +- Connection state management +- Price update handling +- Error scenarios and recovery +- Auth token updates +- Multiple contract handling +- Reconnection behavior -// To unsubscribe or clean up -marketService.unsubscribeFromPrice('R_100'); -``` +See `__tests__/contract.test.ts` for comprehensive test examples. -### Contract SSE Service +### Usage ```typescript -import { ContractSSEService } from '@/services/api/sse/contract/service'; +import { ContractSSEService } from './contract/service'; -const contractService = new ContractSSEService('your-auth-token'); +// Initialize with auth token +const service = new ContractSSEService('your-auth-token'); -// Request contract price updates -// This will create an SSE connection with ?action=contract_price -contractService.requestPrice({ +// Subscribe to price updates +service.on('contract_price', (message) => { + console.log('Received price update:', message); +}); + +// Handle errors +service.onError((error) => { + console.error('SSE error:', error); +}); + +// Request contract price +service.requestPrice({ duration: '1m', - instrument: 'frxEURUSD', + instrument: 'R_100', trade_type: 'CALL', currency: 'USD', - payout: '100' + payout: '100', + strike: '1234.56' }); -// The service will handle the action parameter internally -contractService.on('contract_price', (data) => { - console.log('New contract price:', data); -}); - -// To cancel the subscription -contractService.cancelPrice(params); -``` +// Cancel price subscription +service.cancelPrice(request); -## Features +// Update auth token +service.updateAuthToken('new-token'); -- **Action-Based Routing:** Each SSE connection requires an action parameter: - - `instrument_price`: For market data streaming (public endpoint) - - `contract_price`: For contract price streaming (protected endpoint) -- **Automatic Reconnection:** Seamlessly re-establish connections on interruptions. -- **Secure Endpoints:** Distinguish between public and protected data streams. -- **Type Safety:** Leverage TypeScript for strongly typed event handling. -- **Modular Design:** Each service is encapsulated, making it easy to extend or update. +// Disconnect when done +service.disconnect(); +``` -## Error Handling +## Market SSE Service -Each SSE service implements robust error handling: -- Capturing network or streaming errors. -- Providing callbacks for error events. -- Allowing for retries based on configurable parameters. +[Documentation for MarketSSEService to be added...] diff --git a/src/services/api/sse/__tests__/contract.test.ts b/src/services/api/sse/__tests__/contract.test.ts index 06177d2..d2742f5 100644 --- a/src/services/api/sse/__tests__/contract.test.ts +++ b/src/services/api/sse/__tests__/contract.test.ts @@ -44,8 +44,8 @@ describe('ContractSSEService', () => { }); const createMockResponse = (request: ContractPriceRequest): ContractPriceResponse => ({ - date_start: Date.now(), - date_expiry: Date.now() + 60000, + date_start: 1738841771212, + date_expiry: 1738841831212, spot: '1234.56', strike: request.strike || '1234.56', price: '5.67', @@ -98,14 +98,17 @@ describe('ContractSSEService', () => { if (mockEventSource?.onmessage) { mockEventSource.onmessage(new MessageEvent('message', { - data: `data: ${JSON.stringify({ + data: JSON.stringify({ action: 'contract_price', data: mockResponse - })}` + }) })); } - expect(mockHandler).toHaveBeenCalledWith(mockResponse); + expect(mockHandler).toHaveBeenCalledWith({ + action: 'contract_price', + data: mockResponse + }); }); it('should handle parse errors', () => { @@ -118,7 +121,7 @@ describe('ContractSSEService', () => { if (mockEventSource?.onmessage) { mockEventSource.onmessage(new MessageEvent('message', { - data: 'data: invalid json' + data: 'invalid json' })); } diff --git a/src/services/api/sse/__tests__/market.test.ts b/src/services/api/sse/__tests__/market.test.ts index b7950dc..b759cc2 100644 --- a/src/services/api/sse/__tests__/market.test.ts +++ b/src/services/api/sse/__tests__/market.test.ts @@ -75,14 +75,17 @@ describe('MarketSSEService', () => { if (mockEventSource?.onmessage) { mockEventSource.onmessage(new MessageEvent('message', { - data: `data: ${JSON.stringify({ + data: JSON.stringify({ action: 'instrument_price', data: mockPrice - })}` + }) })); } - expect(mockHandler).toHaveBeenCalledWith(mockPrice); + expect(mockHandler).toHaveBeenCalledWith({ + action: 'instrument_price', + data: mockPrice + }); }); it('should handle parse errors', () => { @@ -95,7 +98,7 @@ describe('MarketSSEService', () => { if (mockEventSource?.onmessage) { mockEventSource.onmessage(new MessageEvent('message', { - data: 'data: invalid json' + data: 'invalid json' })); } diff --git a/src/services/api/sse/contract/service.ts b/src/services/api/sse/contract/service.ts index 1a6fe84..e18d560 100644 --- a/src/services/api/sse/contract/service.ts +++ b/src/services/api/sse/contract/service.ts @@ -49,7 +49,7 @@ export class ContractSSEService extends ProtectedSSEService { protected handleMessage(message: SSEMessage): void { const handlers = this.messageHandlers.get("contract_price"); handlers?.forEach((handler) => - handler(message.data as ContractPriceResponse) + handler(message as unknown as ContractPriceResponse) ); } diff --git a/src/services/api/sse/market/service.ts b/src/services/api/sse/market/service.ts index 1829012..8ca2dd4 100644 --- a/src/services/api/sse/market/service.ts +++ b/src/services/api/sse/market/service.ts @@ -51,7 +51,7 @@ export class MarketSSEService extends PublicSSEService { protected handleMessage(message: SSEMessage): void { if (message.action === 'instrument_price') { const handlers = this.messageHandlers.get('instrument_price'); - handlers?.forEach(handler => handler(message.data as InstrumentPriceResponse)); + handlers?.forEach(handler => handler(message as unknown as InstrumentPriceResponse)); } } diff --git a/src/stores/bottomSheetStore.ts b/src/stores/bottomSheetStore.ts index a8f3278..179118b 100644 --- a/src/stores/bottomSheetStore.ts +++ b/src/stores/bottomSheetStore.ts @@ -1,22 +1,34 @@ -import { create } from 'zustand'; +import { create } from "zustand"; -interface BottomSheetState { +export interface BottomSheetState { showBottomSheet: boolean; key: string | null; height: string; onDragDown?: () => void; - setBottomSheet: (show: boolean, key?: string, height?: string, onDragDown?: () => void) => void; + setBottomSheet: ( + show: boolean, + key?: string, + height?: string, + onDragDown?: () => void + ) => void; } export const useBottomSheetStore = create((set) => ({ showBottomSheet: false, key: null, - height: '380px', + height: "380px", onDragDown: undefined, - setBottomSheet: (show: boolean, key?: string, height?: string, onDragDown?: () => void) => set({ - showBottomSheet: show, - key: show ? key || null : null, - height: height || '380px', - onDragDown: onDragDown - }), + setBottomSheet: ( + show: boolean, + key?: string, + height?: string, + onDragDown?: () => void + ) => { + return set({ + showBottomSheet: show, + key: show ? key || null : null, + height: height || "380px", + onDragDown: onDragDown, + }); + }, })); diff --git a/src/utils/__tests__/duration.test.ts b/src/utils/__tests__/duration.test.ts new file mode 100644 index 0000000..f5bb215 --- /dev/null +++ b/src/utils/__tests__/duration.test.ts @@ -0,0 +1,73 @@ +import { formatDurationDisplay } from '../duration'; + +describe('formatDurationDisplay', () => { + describe('hour format', () => { + it('formats single hour without minutes', () => { + expect(formatDurationDisplay('1:0 hour')).toBe('1 hour'); + }); + + it('formats multiple hours without minutes', () => { + expect(formatDurationDisplay('2:0 hour')).toBe('2 hours'); + }); + + it('formats single hour with single minute', () => { + expect(formatDurationDisplay('1:1 hour')).toBe('1 hour 1 minute'); + }); + + it('formats single hour with multiple minutes', () => { + expect(formatDurationDisplay('1:30 hour')).toBe('1 hour 30 minutes'); + }); + + it('formats multiple hours with multiple minutes', () => { + expect(formatDurationDisplay('2:45 hour')).toBe('2 hours 45 minutes'); + }); + }); + + describe('tick format', () => { + it('formats single tick', () => { + expect(formatDurationDisplay('1 tick')).toBe('1 tick'); + }); + + it('formats multiple ticks', () => { + expect(formatDurationDisplay('5 tick')).toBe('5 ticks'); + }); + }); + + describe('second format', () => { + it('formats single second', () => { + expect(formatDurationDisplay('1 second')).toBe('1 second'); + }); + + it('formats multiple seconds', () => { + expect(formatDurationDisplay('30 second')).toBe('30 seconds'); + }); + }); + + describe('minute format', () => { + it('formats single minute', () => { + expect(formatDurationDisplay('1 minute')).toBe('1 minute'); + }); + + it('formats multiple minutes', () => { + expect(formatDurationDisplay('15 minute')).toBe('15 minutes'); + }); + }); + + describe('day format', () => { + it('formats day duration', () => { + expect(formatDurationDisplay('1 day')).toBe('1 day'); + }); + }); + + describe('error handling', () => { + it('returns original string for unknown duration type', () => { + const invalidDuration = '5 unknown'; + expect(formatDurationDisplay(invalidDuration)).toBe(invalidDuration); + }); + + it('handles malformed hour format gracefully', () => { + const malformedDuration = '1: hour'; + expect(formatDurationDisplay(malformedDuration)).toBe('1 hour'); + }); + }); +}); diff --git a/src/utils/duration.ts b/src/utils/duration.ts new file mode 100644 index 0000000..1096f1c --- /dev/null +++ b/src/utils/duration.ts @@ -0,0 +1,24 @@ +export const formatDurationDisplay = (duration: string): string => { + const [value, type] = duration.split(" "); + + if (type === "hour") { + const [hours, minutes] = value.split(":").map(Number); + const hourText = hours === 1 ? "hour" : "hours"; + const minuteText = minutes === 1 ? "minute" : "minutes"; + return minutes > 0 ? `${hours} ${hourText} ${minutes} ${minuteText}` : `${hours} ${hourText}`; + } + + const numValue = parseInt(value, 10); + switch (type) { + case "tick": + return `${numValue} ${numValue === 1 ? "tick" : "ticks"}`; + case "second": + return `${numValue} ${numValue === 1 ? "second" : "seconds"}`; + case "minute": + return `${numValue} ${numValue === 1 ? "minute" : "minutes"}`; + case "day": + return `${numValue} day`; + default: + return duration; + } +}; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 57a95f0..d172b2c 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -16,6 +16,15 @@ module.exports = { }, }, extend: { + fontFamily: { + 'ibm-plex': ['IBM Plex Sans', 'sans-serif'], + }, + fontSize: { + 'body': ['14px', { + lineHeight: '22px', + fontWeight: '400', + }], + }, colors: { border: "hsl(var(--border))", input: "hsl(var(--input))",