diff --git a/src/pages/TokenizedAssetsPage.stories.tsx b/src/pages/TokenizedAssetsPage.stories.tsx new file mode 100644 index 0000000..5069693 --- /dev/null +++ b/src/pages/TokenizedAssetsPage.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TokenizedAssetsPage } from './TokenizedAssetsPage' + +const meta = { + title: 'Apps/Tokenized Assets', + component: TokenizedAssetsPage, + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ChartOn: Story = { + args: { chartOff: false }, +} + +export const ChartOff: Story = { + args: { chartOff: true }, +} diff --git a/src/pages/TokenizedAssetsPage.tsx b/src/pages/TokenizedAssetsPage.tsx new file mode 100644 index 0000000..d47caf3 --- /dev/null +++ b/src/pages/TokenizedAssetsPage.tsx @@ -0,0 +1,382 @@ +import { useMemo, useState } from 'react' +import styled from 'styled-components' +import { + TokenizedAssetsList, + type TokenizedAsset, +} from '../widgets/TokenizedAssetsList' +import { TokenizedAssetsChartPanel } from '../widgets/TokenizedAssetsChartPanel' +import { + TokenizedAssetsTradePanel, + type TradeMode, +} from '../widgets/TokenizedAssetsTradePanel' + +export interface TokenizedAssetsPageProps { + /** Hide the middle chart column. Default false. */ + chartOff?: boolean +} + +// ── Page-level layout shells. No widget surface styling here. ───────────── + +const Root = styled.section` + display: flex; + flex-direction: column; + min-height: 100vh; + background: ${({ theme }) => theme.colors.background}; + font-family: 'Kanit', sans-serif; + color: ${({ theme }) => theme.colors.text}; +` + +const TopBar = styled.header` + display: flex; + align-items: center; + gap: 16px; + height: 56px; + padding: 0 24px; + background: ${({ theme }) => theme.colors.card}; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + flex-shrink: 0; +` + +const Logo = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 16px; + color: ${({ theme }) => theme.colors.text}; +` + +const LogoMark = styled.span` + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: linear-gradient(135deg, #d1884f 0%, #f8c5a3 100%); + font-size: 14px; +` + +const TopBarSpacer = styled.div` + flex: 1; +` + +const WalletChip = styled.span` + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 4px 12px 4px 4px; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 999px; + font-size: 14px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; +` + +const Avatar = styled.span` + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: linear-gradient(135deg, #ff8866 0%, #ffd166 100%); + font-size: 12px; +` + +const Content = styled.div` + max-width: 1240px; + margin: 0 auto; + width: 100%; + padding: 32px 24px 48px; +` + +const PageHeader = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +` + +const PageHeaderLeft = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +` + +const Eyebrow = styled.span` + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.secondary}; +` + +const Heading = styled.h1` + margin: 0; + font-family: 'Kanit', sans-serif; + font-size: 24px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; +` + +const PageHeaderRight = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding-top: 4px; +` + +const AssetCountText = styled.span` + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: ${({ theme }) => theme.colors.textSubtle}; +` + +const Dot = styled.span` + display: inline-block; + width: 4px; + height: 4px; + border-radius: 999px; + background: ${({ theme }) => theme.colors.textSubtle}; +` + +const StatusPill = styled.span` + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 14px; + border-radius: 999px; + background: rgba(49, 208, 170, 0.10); + border: 1px solid rgba(49, 208, 170, 0.40); + color: ${({ theme }) => theme.colors.success}; + font-size: 14px; + font-weight: 600; +` + +const StatusDot = styled.span` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 999px; + background: ${({ theme }) => theme.colors.success}; + box-shadow: 0 0 0 3px rgba(49, 208, 170, 0.25); +` + +const Grid = styled.div<{ $chartOff: boolean }>` + display: grid; + grid-template-columns: ${({ $chartOff }) => + $chartOff ? '338px 480px' : '338px 468px 480px'}; + gap: 16px; + justify-content: ${({ $chartOff }) => ($chartOff ? 'center' : 'start')}; + align-items: start; +` + +// ── Sample data ──────────────────────────────────────────────────────────── + +const ASSETS: readonly TokenizedAsset[] = [ + { + id: 'nvda', + name: 'Nvidia corp', + ticker: 'NVDAx', + price: '$235.31', + changePct: 3.89, + iconColor: '#76B900', + iconInitials: 'N', + }, + { + id: 'googl', + name: 'Alphabet Inc', + ticker: 'GOOGLx', + price: '$399.88', + changePct: -0.46, + iconColor: '#4285F4', + iconInitials: 'G', + }, + { + id: 'aapl', + name: 'Apple Inc', + ticker: 'AAPLx', + price: '$298.39', + changePct: -0.37, + iconColor: '#1D1D1F', + iconInitials: '', + }, + { + id: 'msft', + name: 'Microsoft Corp', + ticker: 'MSFTx', + price: '$408.89', + changePct: 0.92, + iconColor: '#00A4EF', + iconInitials: 'M', + }, + { + id: 'amzn', + name: 'Amazon.com Inc', + ticker: 'AMZNx', + price: '$408.89', + changePct: 0.92, + iconColor: '#FF9900', + iconInitials: 'a', + }, + { + id: 'tsl', + name: 'Tesla Inc', + ticker: 'TSLx', + price: '$408.89', + changePct: 0.92, + iconColor: '#E31937', + iconInitials: 'T', + }, + { + id: 'wbtc', + name: 'WBTC', + ticker: 'WBTC', + price: '$108,408', + changePct: 0.92, + iconColor: '#F7931A', + iconInitials: '₿', + }, +] + +const FILTERS = ['Stocks', 'Crypto', 'ETFs'] as const + +export function TokenizedAssetsPage({ chartOff = false }: TokenizedAssetsPageProps = {}) { + const [selectedId, setSelectedId] = useState('nvda') + const [favorites, setFavorites] = useState([]) + const [search, setSearch] = useState('') + const [activeFilters, setActiveFilters] = useState(['Stocks']) + const [mode, setMode] = useState('swap') + const [payAmount, setPayAmount] = useState('') + const [receiveAmount, setReceiveAmount] = useState('') + const [timeframe, setTimeframe] = useState('5m') + + const selected = useMemo( + () => ASSETS.find((a) => a.id === selectedId) ?? ASSETS[0], + [selectedId], + ) + + const visibleAssets = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return ASSETS + return ASSETS.filter( + (a) => + a.name.toLowerCase().includes(q) || + a.ticker.toLowerCase().includes(q), + ) + }, [search]) + + const toggleFavorite = (id: string) => + setFavorites((curr) => + curr.includes(id) ? curr.filter((x) => x !== id) : [...curr, id], + ) + + const toggleFilter = (f: string) => + setActiveFilters((curr) => + curr.includes(f) ? curr.filter((x) => x !== f) : [...curr, f], + ) + + return ( + + + + 🐰 + PancakeSwap + + + + 🦊 + $1,098.99 + + + 🦊 + 0x40cf…5461 + + + + + + + Tokenized assets + Trade real-world assets on-chain + + + + {ASSETS.length} assets + + BNB Chain + + + + Markets open + + + + + + + + {!chartOff && ( + = 0 ? '+$8.82' : '-$3.21'} + priceDeltaPct={`(${selected.changePct >= 0 ? '+' : ''}${selected.changePct.toFixed(2)}%)`} + isPositive={selected.changePct >= 0} + timeframe={timeframe} + onTimeframeChange={setTimeframe} + /> + )} + + + + + + ) +} diff --git a/src/widgets/TokenizedAssetsChartPanel.stories.tsx b/src/widgets/TokenizedAssetsChartPanel.stories.tsx new file mode 100644 index 0000000..15430c3 --- /dev/null +++ b/src/widgets/TokenizedAssetsChartPanel.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { fn } from 'storybook/test' +import { TokenizedAssetsChartPanel } from './TokenizedAssetsChartPanel' + +const meta = { + title: 'Widgets/Tokenized Assets/Chart Panel', + component: TokenizedAssetsChartPanel, + parameters: { layout: 'centered' }, + args: { + name: 'NVIDIA CORP', + ticker: 'NVDAx', + metaLabel: '$5.7T MC', + iconColor: '#76B900', + iconInitials: 'N', + price: '$235.31', + priceDelta: '+$8.82', + priceDeltaPct: '(+3.89%)', + isPositive: true, + timeframe: '5m', + timeframes: ['5m', '1h', 'D'], + onTimeframeChange: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Positive: Story = {} + +export const Negative: Story = { + args: { + name: 'ALPHABET INC', + ticker: 'GOOGLx', + metaLabel: '$2.1T MC', + iconColor: '#4285F4', + iconInitials: 'G', + price: '$399.88', + priceDelta: '-$1.84', + priceDeltaPct: '(-0.46%)', + isPositive: false, + }, +} diff --git a/src/widgets/TokenizedAssetsChartPanel.tsx b/src/widgets/TokenizedAssetsChartPanel.tsx new file mode 100644 index 0000000..531157e --- /dev/null +++ b/src/widgets/TokenizedAssetsChartPanel.tsx @@ -0,0 +1,450 @@ +import React from 'react' +import styled from 'styled-components' +import { CandleGraphIcon, ChartIcon, ChevronDownIcon, SearchIcon } from '../primitives/Icons' + +export interface TokenizedAssetsChartPanelProps { + /** e.g. "NVIDIA CORP" */ + name: string + /** e.g. "NVDAx" */ + ticker: string + /** e.g. "$5.7T MC" */ + metaLabel: string + /** Background color of the asset's logo circle. */ + iconColor: string + /** Initials inside the logo circle — defaults to first letter of ticker. */ + iconInitials?: string + + price: string + /** Absolute price delta string (with sign), e.g. "+$8.82". */ + priceDelta: string + /** Percentage delta string (with sign + parens), e.g. "(+3.89%)". */ + priceDeltaPct: string + isPositive: boolean + + /** Currently selected timeframe key. */ + timeframe?: string + /** Available timeframes. */ + timeframes?: readonly string[] + onTimeframeChange?: (next: string) => void +} + +const Root = styled.section` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 468px; + height: 472px; + max-width: 1024px; + flex-shrink: 0; + background: ${({ theme }) => theme.colors.card}; + border-top: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-right: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-bottom: 2px solid ${({ theme }) => theme.colors.cardBorder}; + border-left: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 24px; + overflow: hidden; +` + +const Header = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + align-self: stretch; + gap: 16px; + padding: 12px 24px; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; +` + +const HeaderLeft = styled.div` + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +` + +const TokenCircle = styled.span<{ $color: string }>` + display: inline-flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: ${({ $color }) => $color}; + color: #fff; + font-weight: 700; + font-size: 14px; + flex-shrink: 0; +` + +const NameBlock = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +` + +const Name = styled.span` + font-family: 'Kanit', sans-serif; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 150%; + letter-spacing: -0.2px; + color: ${({ theme }) => theme.colors.text}; + white-space: nowrap; + font-feature-settings: 'liga' off; +` + +const SubLabel = styled.span` + font-family: 'Kanit', sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 150%; + color: ${({ theme }) => theme.colors.textSubtle}; + font-feature-settings: 'liga' off; +` + +const HeaderRight = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +` + +const Price = styled.span` + font-family: 'Kanit', sans-serif; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 150%; + letter-spacing: -0.2px; + text-align: right; + color: ${({ theme }) => theme.colors.text}; + font-variant-numeric: tabular-nums; + font-feature-settings: 'liga' off; +` + +const DeltaRow = styled.span<{ $positive: boolean }>` + font-size: 12px; + font-weight: 600; + color: ${({ $positive, theme }) => + $positive ? theme.colors.success : theme.colors.failure}; + font-variant-numeric: tabular-nums; +` + +const Toolbar = styled.div` + display: flex; + align-items: center; + align-self: stretch; + gap: 4px; + padding: 6px 12px; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; +` + +const Tf = styled.button<{ $active?: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: ${({ $active, theme }) => + $active ? theme.colors.input : 'transparent'}; + color: ${({ $active, theme }) => + $active ? theme.colors.text : theme.colors.textSubtle}; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.colors.text}; + } +` + +const ToolbarDivider = styled.span` + display: inline-block; + width: 1px; + height: 18px; + background: ${({ theme }) => theme.colors.cardBorder}; + margin: 0 4px; +` + +const ToolbarBtn = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 26px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: ${({ theme }) => theme.colors.textSubtle}; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.colors.text}; + background: ${({ theme }) => theme.colors.input}; + } +` + +const ToolbarSpacer = styled.span` + flex: 1; +` + +const ChartArea = styled.div` + position: relative; + flex: 1; + align-self: stretch; + min-height: 0; + background: ${({ theme }) => theme.colors.background}; + overflow: hidden; +` + +const YAxis = styled.div` + position: absolute; + inset: 0 0 24px auto; + width: 56px; + border-left: 1px solid ${({ theme }) => theme.colors.cardBorder}; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 8px 0 8px 8px; +` + +const YTick = styled.span` + font-size: 11px; + color: ${({ theme }) => theme.colors.textSubtle}; + font-variant-numeric: tabular-nums; +` + +const XAxis = styled.div` + position: absolute; + inset: auto 56px 0 0; + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + border-top: 1px solid ${({ theme }) => theme.colors.cardBorder}; +` + +const XTick = styled.span` + font-size: 11px; + color: ${({ theme }) => theme.colors.textSubtle}; +` + +const CandleArea = styled.div` + position: absolute; + inset: 0 56px 24px 0; + display: flex; + align-items: stretch; + gap: 2px; + padding: 16px 12px; +` + +type Candle = { + open: number + close: number + high: number + low: number +} + +const CandleColumn = styled.div` + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + position: relative; +` + +const Wick = styled.span<{ $top: number; $height: number; $positive: boolean }>` + position: absolute; + top: ${({ $top }) => $top}%; + height: ${({ $height }) => $height}%; + width: 1px; + background: ${({ $positive, theme }) => + $positive ? theme.colors.success : theme.colors.failure}; +` + +const Body = styled.span<{ $top: number; $height: number; $positive: boolean }>` + position: absolute; + top: ${({ $top }) => $top}%; + height: ${({ $height }) => Math.max(0.6, $height)}%; + width: 6px; + background: ${({ $positive, theme }) => + $positive ? theme.colors.success : theme.colors.failure}; + border-radius: 1px; +` + +const CurrentPriceLine = styled.div<{ $top: number }>` + position: absolute; + left: 0; + right: 0; + top: ${({ $top }) => $top}%; + border-top: 1px dashed ${({ theme }) => theme.colors.secondary}; + pointer-events: none; +` + +const PricePill = styled.span` + position: absolute; + right: -56px; + top: -10px; + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + background: ${({ theme }) => theme.colors.secondary}; + color: ${({ theme }) => theme.colors.invertedContrast}; + min-width: 48px; + justify-content: center; +` + +const DEFAULT_TIMEFRAMES = ['5m', '1h', 'D'] as const + +const Y_TICKS = ['240', '238', '236', '234', '232', '230', '228', 'USD'] +const X_TICKS = ['09:30', '11:00', '12:30', '14:00', '15:30'] + +/** Synthetic candle series — visual only, no live data. */ +const SAMPLE: Candle[] = (() => { + const seed = [232, 234, 233, 236, 234, 235, 237, 235, 233, 234, 235] + let last = 232 + const out: Candle[] = [] + for (let i = 0; i < 40; i += 1) { + const base = seed[i % seed.length] + Math.sin(i * 0.6) * 1.2 + const open = last + const close = base + (i % 5 === 0 ? -1 : 0.3) + const high = Math.max(open, close) + 0.6 + const low = Math.min(open, close) - 0.6 + out.push({ open, close, high, low }) + last = close + } + return out +})() + +export const TokenizedAssetsChartPanel: React.FC = ({ + name, + ticker, + metaLabel, + iconColor, + iconInitials, + price, + priceDelta, + priceDeltaPct, + isPositive, + timeframe = '5m', + timeframes = DEFAULT_TIMEFRAMES, + onTimeframeChange, +}) => { + // Map candle prices into the chart area's 0–100% coordinate space. + const max = Math.max(...SAMPLE.map((c) => c.high)) + const min = Math.min(...SAMPLE.map((c) => c.low)) + const range = max - min || 1 + const toPct = (v: number) => 100 - ((v - min) / range) * 100 + + const lastClose = SAMPLE[SAMPLE.length - 1].close + const lastPricePct = toPct(lastClose) + + return ( + +
+ + + {iconInitials ?? ticker.replace(/x$/i, '').slice(0, 1)} + + + {name} + + {ticker} | {metaLabel} + + + + + {price} + + {priceDelta} {priceDeltaPct} + + +
+ + + {timeframes.map((tf) => ( + onTimeframeChange?.(tf)} + > + {tf} + + ))} + + + + + + + + Indicators + + + + + + + + + + + + + + + + {SAMPLE.map((c, i) => { + const positive = c.close >= c.open + const wickTop = toPct(c.high) + const wickBottom = toPct(c.low) + const bodyTop = toPct(Math.max(c.open, c.close)) + const bodyBottom = toPct(Math.min(c.open, c.close)) + return ( + + + + + ) + })} + + {lastClose.toFixed(2)} + + + + {Y_TICKS.map((t) => ( + {t} + ))} + + + {X_TICKS.map((t) => ( + {t} + ))} + + +
+ ) +} diff --git a/src/widgets/TokenizedAssetsList.stories.tsx b/src/widgets/TokenizedAssetsList.stories.tsx new file mode 100644 index 0000000..3bfaa80 --- /dev/null +++ b/src/widgets/TokenizedAssetsList.stories.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent } from 'storybook/test' +import { + TokenizedAssetsList, + type TokenizedAsset, +} from './TokenizedAssetsList' + +const SAMPLE_ASSETS: readonly TokenizedAsset[] = [ + { id: 'nvda', name: 'Nvidia corp', ticker: 'NVDAx', price: '$235.31', changePct: 3.89, iconColor: '#76B900', iconInitials: 'N' }, + { id: 'googl', name: 'Alphabet Inc', ticker: 'GOOGLx', price: '$399.88', changePct: -0.46, iconColor: '#4285F4', iconInitials: 'G' }, + { id: 'aapl', name: 'Apple Inc', ticker: 'AAPLx', price: '$298.39', changePct: -0.37, iconColor: '#1D1D1F' }, + { id: 'msft', name: 'Microsoft Corp', ticker: 'MSFTx', price: '$408.89', changePct: 0.92, iconColor: '#00A4EF', iconInitials: 'M' }, + { id: 'amzn', name: 'Amazon.com Inc', ticker: 'AMZNx', price: '$408.89', changePct: 0.92, iconColor: '#FF9900', iconInitials: 'a' }, + { id: 'tsl', name: 'Tesla Inc', ticker: 'TSLx', price: '$408.89', changePct: 0.92, iconColor: '#E31937', iconInitials: 'T' }, + { id: 'wbtc', name: 'WBTC', ticker: 'WBTC', price: '$108,408', changePct: 0.92, iconColor: '#F7931A', iconInitials: '₿' }, +] + +const meta = { + title: 'Widgets/Tokenized Assets/List', + component: TokenizedAssetsList, + parameters: { layout: 'centered' }, + args: { + assets: SAMPLE_ASSETS, + selectedAssetId: 'nvda', + filters: ['Stocks', 'Crypto', 'ETFs'], + activeFilters: ['Stocks'], + favorites: ['msft'], + onAssetSelect: fn(), + onToggleFavorite: fn(), + onSearchChange: fn(), + onFilterToggle: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const NoFilters: Story = { + args: { filters: [] as string[], activeFilters: [] as string[] }, +} + +/** A controlled-state variant so the search/select interactions actually update. */ +export const Interactive: Story = { + render: (args) => { + const Wrapped = () => { + const [selectedId, setSelectedId] = useState(args.selectedAssetId ?? 'nvda') + const [search, setSearch] = useState('') + const [favorites, setFavorites] = useState(['msft']) + const visible = SAMPLE_ASSETS.filter( + (a) => + !search || + a.name.toLowerCase().includes(search.toLowerCase()) || + a.ticker.toLowerCase().includes(search.toLowerCase()), + ) + return ( + { + args.onAssetSelect?.(id) + setSelectedId(id) + }} + searchQuery={search} + onSearchChange={(s) => { + args.onSearchChange?.(s) + setSearch(s) + }} + favorites={favorites} + onToggleFavorite={(id) => { + args.onToggleFavorite?.(id) + setFavorites((curr) => + curr.includes(id) ? curr.filter((x) => x !== id) : [...curr, id], + ) + }} + /> + ) + } + return + }, + play: async ({ canvas, args }) => { + const search = canvas.getByLabelText('Search tokens or stocks') + await userEvent.type(search, 'apple') + await expect(args.onSearchChange).toHaveBeenCalled() + await expect(canvas.getByText('Apple Inc')).toBeInTheDocument() + // Clear and select a different asset row. + await userEvent.clear(search) + await userEvent.click(canvas.getByText('Microsoft Corp')) + await expect(args.onAssetSelect).toHaveBeenCalledWith('msft') + }, +} diff --git a/src/widgets/TokenizedAssetsList.tsx b/src/widgets/TokenizedAssetsList.tsx new file mode 100644 index 0000000..65ac8e6 --- /dev/null +++ b/src/widgets/TokenizedAssetsList.tsx @@ -0,0 +1,356 @@ +import React from 'react' +import styled, { css } from 'styled-components' +import { SearchIcon, StarFillIcon, StarLineIcon } from '../primitives/Icons' + +export interface TokenizedAsset { + id: string + name: string + ticker: string + price: string + changePct: number + /** Token logo background color (used for the circular fallback). */ + iconColor: string + /** Optional initial(s) drawn inside the circle. Defaults to first letter. */ + iconInitials?: string +} + +export interface TokenizedAssetsListProps { + assets: readonly TokenizedAsset[] + selectedAssetId?: string | null + onAssetSelect?: (id: string) => void + + favorites?: readonly string[] + onToggleFavorite?: (id: string) => void + + searchQuery?: string + onSearchChange?: (next: string) => void + + filters?: readonly string[] + activeFilters?: readonly string[] + onFilterToggle?: (filter: string) => void +} + +const Root = styled.section` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 338px; + height: 470px; + flex-shrink: 0; + background: ${({ theme }) => theme.colors.card}; + border-top: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-right: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-bottom: 2px solid ${({ theme }) => theme.colors.cardBorder}; + border-left: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 24px; + overflow: hidden; +` + +const Header = styled.div` + display: flex; + flex-direction: column; + align-self: stretch; + gap: 16px; + padding: 24px 24px 16px; +` + +const SearchRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +const SearchField = styled.label` + display: flex; + align-items: center; + gap: 8px; + flex: 1; + padding: 10px 12px; + border-radius: 16px; + background: ${({ theme }) => theme.colors.input}; + color: ${({ theme }) => theme.colors.textSubtle}; +` + +const SearchInput = styled.input` + flex: 1; + min-width: 0; + border: 0; + background: transparent; + outline: none; + font-family: inherit; + font-size: 14px; + color: ${({ theme }) => theme.colors.text}; + &::placeholder { + color: ${({ theme }) => theme.colors.textSubtle}; + } +` + +const FilterButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 0; + border-radius: 12px; + background: ${({ theme }) => theme.colors.input}; + color: ${({ theme }) => theme.colors.textSubtle}; + cursor: pointer; + flex-shrink: 0; + &:hover { + color: ${({ theme }) => theme.colors.text}; + } +` + +const TagRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +` + +const Chip = styled.button<{ $active?: boolean }>` + display: inline-flex; + align-items: center; + height: 25px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid + ${({ $active, theme }) => ($active ? theme.colors.secondary : theme.colors.cardBorder)}; + background: ${({ $active, theme }) => + $active ? 'rgba(118, 69, 217, 0.10)' : 'transparent'}; + color: ${({ $active, theme }) => + $active ? theme.colors.secondary : theme.colors.textSubtle}; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + &:hover { + color: ${({ theme }) => theme.colors.secondary}; + border-color: ${({ theme }) => theme.colors.secondary}; + } +` + +const RowsScroll = styled.div` + display: flex; + flex-direction: column; + align-self: stretch; + padding: 4px 12px 16px; + overflow-y: auto; + flex: 1; + min-height: 0; +` + +const Row = styled.button<{ $selected?: boolean }>` + display: grid; + grid-template-columns: 16px 32px 1fr auto; + align-items: center; + gap: 8px; + padding: 14px 12px; + border: 0; + border-radius: 16px; + background: ${({ $selected, theme }) => + $selected ? theme.colors.background : 'transparent'}; + cursor: pointer; + text-align: left; + font-family: inherit; + + &:hover { + border-radius: 16px; + background: ${({ theme }) => theme.colors.background}; + } + + ${({ $selected }) => + $selected && + css` + box-shadow: inset 0 0 0 1px var(--pcs-colors-card-border, currentColor); + `} +` + +const FavBtn = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: ${({ theme }) => theme.colors.textSubtle}; + cursor: pointer; + + &:hover { + color: ${({ theme }) => theme.colors.warning}; + } +` + +const TokenCircle = styled.span<{ $color: string }>` + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: ${({ $color }) => $color}; + color: #fff; + font-weight: 700; + font-size: 12px; + letter-spacing: 0.02em; +` + +const NameBlock = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +` + +const Name = styled.span` + font-size: 16px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const Ticker = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.colors.textSubtle}; + letter-spacing: 0.02em; +` + +const PriceBlock = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +` + +const Price = styled.span` + font-size: 16px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + font-variant-numeric: tabular-nums; +` + +const Change = styled.span<{ $positive: boolean }>` + font-size: 12px; + font-weight: 600; + color: ${({ $positive, theme }) => + $positive ? theme.colors.success : theme.colors.failure}; + font-variant-numeric: tabular-nums; +` + +function formatChange(pct: number): string { + const sign = pct >= 0 ? '+' : '' + return `${sign}${pct.toFixed(2)}%` +} + +export const TokenizedAssetsList: React.FC = ({ + assets, + selectedAssetId, + onAssetSelect, + favorites = [], + onToggleFavorite, + searchQuery = '', + onSearchChange, + filters = [], + activeFilters = [], + onFilterToggle, +}) => { + const favSet = new Set(favorites) + + return ( + +
+ + + + onSearchChange?.(e.target.value)} + aria-label="Search tokens or stocks" + /> + + + + + + + + + {filters.length > 0 && ( + + {filters.map((f) => { + const active = activeFilters.includes(f) + return ( + onFilterToggle?.(f)} + > + {f} + + ) + })} + + )} +
+ + + {assets.map((a) => { + const isFav = favSet.has(a.id) + const isSelected = selectedAssetId === a.id + const positive = a.changePct >= 0 + return ( + onAssetSelect?.(a.id)} + aria-current={isSelected ? 'true' : undefined} + > + { + e.stopPropagation() + onToggleFavorite?.(a.id) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onToggleFavorite?.(a.id) + } + }} + > + {isFav ? ( + + ) : ( + + )} + + + {a.iconInitials ?? a.ticker.replace(/x$/i, '').slice(0, 1)} + + + {a.name} + {a.ticker} + + + {a.price} + {formatChange(a.changePct)} + + + ) + })} + +
+ ) +} diff --git a/src/widgets/TokenizedAssetsTradePanel.stories.tsx b/src/widgets/TokenizedAssetsTradePanel.stories.tsx new file mode 100644 index 0000000..bbdb7b3 --- /dev/null +++ b/src/widgets/TokenizedAssetsTradePanel.stories.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent } from 'storybook/test' +import { + TokenizedAssetsTradePanel, + type TradeMode, +} from './TokenizedAssetsTradePanel' + +const meta = { + title: 'Widgets/Tokenized Assets/Trade Panel', + component: TokenizedAssetsTradePanel, + parameters: { layout: 'centered' }, + args: { + mode: 'swap' as TradeMode, + pay: { + symbol: 'BNB', + iconColor: '#F0B90B', + balance: '0.00', + usdValue: '$0.00', + }, + payAmount: '', + receive: { + symbol: 'NVIDIAx', + iconColor: '#76B900', + iconInitials: 'N', + balance: '0.00', + usdValue: '$0.00', + }, + receiveAmount: '', + slippage: '0.5', + rateLabel: '1 BNB = 326.01 NVIDIAx', + offHoursWarning: true, + ctaLabel: 'Connect Wallet', + onModeChange: fn(), + onPayAmountChange: fn(), + onReceiveAmountChange: fn(), + onSlippageClick: fn(), + onRefreshRate: fn(), + onCtaClick: fn(), + onSwapDirections: fn(), + onSettingsClick: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ConnectWallet: Story = {} + +export const NoOffHoursWarning: Story = { + args: { offHoursWarning: false }, +} + +export const LimitMode: Story = { + args: { mode: 'limit' as TradeMode }, +} + +/** Drives mode and amount state so a play function can assert the callbacks fire. */ +export const Interactive: Story = { + render: (args) => { + const Wrapped = () => { + const [mode, setMode] = useState(args.mode ?? 'swap') + const [payAmount, setPayAmount] = useState(args.payAmount ?? '') + return ( + { + args.onModeChange?.(next) + setMode(next) + }} + payAmount={payAmount} + onPayAmountChange={(next) => { + args.onPayAmountChange?.(next) + setPayAmount(next) + }} + /> + ) + } + return + }, + play: async ({ canvas, args }) => { + await userEvent.click(canvas.getByRole('tab', { name: 'Limit' })) + await expect(args.onModeChange).toHaveBeenCalledWith('limit') + + const input = canvas.getByLabelText('Pay amount in BNB') + await userEvent.type(input, '1.25') + await expect(args.onPayAmountChange).toHaveBeenCalled() + + await userEvent.click(canvas.getByRole('button', { name: 'Connect Wallet' })) + await expect(args.onCtaClick).toHaveBeenCalled() + }, +} diff --git a/src/widgets/TokenizedAssetsTradePanel.tsx b/src/widgets/TokenizedAssetsTradePanel.tsx new file mode 100644 index 0000000..2f50843 --- /dev/null +++ b/src/widgets/TokenizedAssetsTradePanel.tsx @@ -0,0 +1,545 @@ +import React from 'react' +import styled, { css } from 'styled-components' +import { Button } from '../primitives/Button' +import { + ChevronDownIcon, + CogIcon, + InfoIcon, + RefreshIcon, + SwapVertIcon, +} from '../primitives/Icons' + +export type TradeMode = 'swap' | 'twap' | 'limit' + +export interface TradePanelTokenSide { + /** Display symbol (e.g. "BNB", "NVIDIAx"). */ + symbol: string + /** Logo circle background. */ + iconColor: string + /** Optional initials override; defaults to first letter. */ + iconInitials?: string + /** Wallet balance as already-formatted string. */ + balance: string + /** Quote (USD) value for the entered amount. */ + usdValue?: string +} + +export interface TokenizedAssetsTradePanelProps { + mode?: TradeMode + onModeChange?: (next: TradeMode) => void + + pay: TradePanelTokenSide + /** Pay-side input value. Empty string ⇒ placeholder shown. */ + payAmount: string + onPayAmountChange?: (next: string) => void + onSelectPayToken?: () => void + + receive: TradePanelTokenSide + receiveAmount: string + onReceiveAmountChange?: (next: string) => void + onSelectReceiveToken?: () => void + + /** Slippage tolerance % as string (e.g. "0.5"). */ + slippage: string + onSlippageClick?: () => void + + /** Conversion rate footer text (e.g. "1 BNB = 326.01 NVIDIAx"). */ + rateLabel: string + onRefreshRate?: () => void + + /** Show the "Trade off-hours" notice card above the CTA. */ + offHoursWarning?: boolean + + /** CTA label (e.g. "Connect Wallet", "Swap"). */ + ctaLabel: string + ctaDisabled?: boolean + onCtaClick?: () => void + + onSwapDirections?: () => void + onSettingsClick?: () => void +} + +/** + * Outer wrap is just layout — the visible "card" treatment lives on each + * sub-section (TabsCard, PayCard, ReceiveCard, InfoCard) so each section can + * have its own border + bottom-heavy stroke per the Figma spec. + */ +const Root = styled.section` + display: flex; + flex-direction: column; + align-items: stretch; + width: 480px; + flex-shrink: 0; + gap: 8px; +` + +const Card = styled.div` + background: ${({ theme }) => theme.colors.card}; + border-top: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-right: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-bottom: 2px solid ${({ theme }) => theme.colors.cardBorder}; + border-left: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 24px; +` + +const TabsCard = styled(Card)` + display: flex; + height: 72px; + padding: 16px; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; +` + +const InfoCard = styled(Card)` + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; +` + +const SideCard = styled(Card)` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; + align-self: stretch; +` + +const TabBar = styled.div` + display: inline-flex; + align-items: stretch; + flex: 1; + background: ${({ theme }) => theme.colors.input}; + border-radius: 16px; + padding: 4px; + gap: 4px; +` + +const Tab = styled.button<{ $active?: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + height: 32px; + border: 0; + border-radius: 12px; + background: ${({ $active, theme }) => + $active ? theme.colors.card : 'transparent'}; + color: ${({ $active, theme }) => + $active ? theme.colors.secondary : theme.colors.textSubtle}; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + ${({ $active }) => + $active && + css` + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + `} + &:hover { + color: ${({ theme }) => theme.colors.secondary}; + } +` + +const HeaderActions = styled.div` + display: inline-flex; + align-items: center; + gap: 4px; +` + +const IconBtn = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: transparent; + color: ${({ theme }) => theme.colors.textSubtle}; + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.colors.text}; + background: ${({ theme }) => theme.colors.input}; + } +` + +/** + * Pay + Receive sit in their own cards, separated by 8px gap, with the + * swap-direction toggle absolutely positioned over the gap. + */ +const SidesWrap = styled.div` + position: relative; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 8px; +` + +const SideTopRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +` + +const SideLabel = styled.span` + font-size: 12px; + font-weight: 600; + color: ${({ theme }) => theme.colors.textSubtle}; + letter-spacing: 0.04em; + text-transform: uppercase; +` + +const BalanceText = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.colors.textSubtle}; + font-variant-numeric: tabular-nums; +` + +const SideBottomRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +` + +const AmountInput = styled.input` + flex: 1; + min-width: 0; + border: 0; + background: transparent; + outline: none; + font-family: inherit; + font-size: 28px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + font-variant-numeric: tabular-nums; + padding: 0; + &::placeholder { + color: ${({ theme }) => theme.colors.textDisabled}; + } + -moz-appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +` + +const TokenChip = styled.button` + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 4px 12px 4px 4px; + border: 0; + border-radius: 999px; + background: ${({ theme }) => theme.colors.input}; + color: ${({ theme }) => theme.colors.text}; + font-family: inherit; + font-size: 16px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background: ${({ theme }) => theme.colors.background}; + } +` + +const ChipCircle = styled.span<{ $color: string }>` + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: ${({ $color }) => $color}; + color: #fff; + font-weight: 700; + font-size: 12px; +` + +const UsdHint = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.colors.textSubtle}; + font-variant-numeric: tabular-nums; +` + +const SwapToggle = styled.button` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + border-radius: 999px; + border: 4px solid ${({ theme }) => theme.colors.card}; + background: ${({ theme }) => theme.colors.input}; + color: ${({ theme }) => theme.colors.secondary}; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; + &:hover { + color: ${({ theme }) => theme.colors.text}; + } +` + +const SlippageRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; + gap: 12px; +` + +const SlippageLabel = styled.span` + font-size: 14px; + color: ${({ theme }) => theme.colors.textSubtle}; + display: inline-flex; + align-items: center; + gap: 6px; +` + +const SlippagePill = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 999px; + background: transparent; + color: ${({ theme }) => theme.colors.text}; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.colors.secondary}; + border-color: ${({ theme }) => theme.colors.secondary}; + } +` + +const OffHoursCard = styled.div` + display: flex; + align-items: flex-start; + align-self: stretch; + gap: 10px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 178, 55, 0.10); + border: 1px solid rgba(255, 178, 55, 0.40); + color: ${({ theme }) => theme.colors.warning}; +` + +const OffHoursBody = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +` + +const OffHoursTitle = styled.span` + font-size: 13px; + font-weight: 600; +` + +const OffHoursText = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.colors.textSubtle}; + line-height: 1.45; +` + +const RateRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; + gap: 8px; + padding: 0 4px; +` + +const RateText = styled.span` + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + font-variant-numeric: tabular-nums; +` + +const RefreshBtn = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 999px; + background: transparent; + color: ${({ theme }) => theme.colors.textSubtle}; + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.colors.text}; + } +` + +const CtaButton = styled(Button)` + width: 100%; + height: 48px; + font-size: 16px; +` + +const MODES: { key: TradeMode; label: string }[] = [ + { key: 'swap', label: 'Swap' }, + { key: 'twap', label: 'TWAP' }, + { key: 'limit', label: 'Limit' }, +] + +export const TokenizedAssetsTradePanel: React.FC = ({ + mode = 'swap', + onModeChange, + pay, + payAmount, + onPayAmountChange, + onSelectPayToken, + receive, + receiveAmount, + onReceiveAmountChange, + onSelectReceiveToken, + slippage, + onSlippageClick, + rateLabel, + onRefreshRate, + offHoursWarning = false, + ctaLabel, + ctaDisabled = false, + onCtaClick, + onSwapDirections, + onSettingsClick, +}) => { + return ( + + + + {MODES.map((m) => ( + onModeChange?.(m.key)} + > + {m.label} + + ))} + + + + + + + + + + + + Pay + Bal: {pay.balance} + + + onPayAmountChange?.(e.target.value)} + aria-label={`Pay amount in ${pay.symbol}`} + /> + + + {pay.iconInitials ?? pay.symbol.slice(0, 1)} + + {pay.symbol} + + + + {pay.usdValue && ≈ {pay.usdValue}} + + + + + + + + + Receive + Bal: {receive.balance} + + + onReceiveAmountChange?.(e.target.value)} + aria-label={`Receive amount in ${receive.symbol}`} + /> + + + {receive.iconInitials ?? receive.symbol.replace(/x$/i, '').slice(0, 1)} + + {receive.symbol} + + + + {receive.usdValue && ≈ {receive.usdValue}} + + + + + + + Slippage Tolerance + + + + {slippage}% + + + + + {offHoursWarning && ( + + + + Trading outside market hours + + Tokenized stock orders placed off-hours will be settled when the + underlying market re-opens. + + + + )} + + + {ctaLabel} + + + + {rateLabel} + + + + + + + ) +}