From 50842baa7a6b8633f7b826e243587a1c38c78679 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sat, 28 Mar 2026 18:16:36 +0100 Subject: [PATCH] feat: add badge/chip component system and timeline component Badge component: - Support variant types: default, status, outline, pill - Color semantics: default, cyan, purple, success, warning, error, info - Size options: compact and default - Icon support Timeline component: - Chronological event rendering with date grouping - Support for event icons, labels, and metadata - Loading and empty states - Type-based color coding Migration: - Update PageHeader to use Badge component - Update TransactionHistory type tags to use Badge Translations: - Add timeline i18n strings for en/es --- frontend/src/components/Badge.tsx | 123 +++++++++ frontend/src/components/PageHeader.tsx | 39 +-- frontend/src/components/Timeline.tsx | 309 ++++++++++++++++++++++ frontend/src/i18n/locales/en.ts | 7 + frontend/src/i18n/locales/es.ts | 33 ++- frontend/src/pages/TransactionHistory.tsx | 10 +- 6 files changed, 478 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/Badge.tsx create mode 100644 frontend/src/components/Timeline.tsx diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx new file mode 100644 index 0000000..529bac8 --- /dev/null +++ b/frontend/src/components/Badge.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +export type BadgeVariant = 'default' | 'status' | 'outline' | 'pill'; +export type BadgeColor = 'default' | 'cyan' | 'purple' | 'success' | 'warning' | 'error' | 'info'; +export type BadgeSize = 'compact' | 'default'; + +export interface BadgeProps { + children: React.ReactNode; + variant?: BadgeVariant; + color?: BadgeColor; + size?: BadgeSize; + icon?: React.ReactNode; + className?: string; + style?: React.CSSProperties; +} + +const colorStyles: Record = { + default: { + bg: 'rgba(148, 163, 184, 0.1)', + text: 'var(--text-secondary)', + border: 'rgba(148, 163, 184, 0.3)', + }, + cyan: { + bg: 'var(--accent-cyan-dim)', + text: 'var(--accent-cyan)', + border: 'rgba(0, 240, 255, 0.3)', + }, + purple: { + bg: 'rgba(112, 0, 255, 0.1)', + text: '#a855f7', + border: 'rgba(112, 0, 255, 0.3)', + }, + success: { + bg: 'rgba(34, 197, 94, 0.1)', + text: '#22c55e', + border: 'rgba(34, 197, 94, 0.3)', + }, + warning: { + bg: 'rgba(245, 158, 11, 0.1)', + text: '#f59e0b', + border: 'rgba(245, 158, 11, 0.3)', + }, + error: { + bg: 'var(--bg-error)', + text: 'var(--text-error)', + border: 'var(--border-error)', + }, + info: { + bg: 'rgba(59, 130, 246, 0.1)', + text: '#3b82f6', + border: 'rgba(59, 130, 246, 0.3)', + }, +}; + +const Badge: React.FC = ({ + children, + variant = 'default', + color = 'default', + size = 'default', + icon, + className = '', + style, +}) => { + const colors = colorStyles[color]; + const isCompact = size === 'compact'; + + const baseStyles: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: isCompact ? '4px' : '6px', + padding: isCompact ? '2px 6px' : '4px 10px', + fontSize: isCompact ? 'var(--text-xs)' : 'var(--text-sm)', + fontWeight: 600, + letterSpacing: '0.02em', + lineHeight: 1.4, + }; + + const variantStyles: Record = { + default: { + background: colors.bg, + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: '6px', + }, + status: { + background: colors.bg, + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: '6px', + textTransform: 'uppercase', + letterSpacing: '0.05em', + fontSize: 'var(--text-xs)', + }, + outline: { + background: 'transparent', + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: '6px', + }, + pill: { + background: colors.bg, + color: colors.text, + border: `1px solid ${colors.border}`, + borderRadius: '9999px', + }, + }; + + return ( + + {icon && {icon}} + {children} + + ); +}; + +export default Badge; diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index c3badb6..d6d6508 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Link } from "react-router-dom"; import { ChevronRight } from "./icons"; +import Badge, { BadgeColor } from "./Badge"; export interface Breadcrumb { label: string; @@ -29,6 +30,15 @@ export interface PageHeaderProps { centered?: boolean; } +const variantToColor: Record = { + default: "default", + cyan: "cyan", + purple: "purple", + success: "success", + warning: "warning", + error: "error", +}; + const PageHeader: React.FC = ({ title, description, @@ -150,34 +160,13 @@ const PageHeader: React.FC = ({ aria-live="polite" > {statusChips.map((chip, index) => ( - {chip.label} - + ))} )} diff --git a/frontend/src/components/Timeline.tsx b/frontend/src/components/Timeline.tsx new file mode 100644 index 0000000..8cad142 --- /dev/null +++ b/frontend/src/components/Timeline.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { Activity } from './icons'; +import { useTranslation } from '../i18n'; + +export interface TimelineEvent { + id: string; + timestamp: Date | string; + title: string; + description?: string; + icon?: React.ReactNode; + metadata?: Record; + type?: 'default' | 'success' | 'warning' | 'error' | 'info'; +} + +export interface TimelineProps { + events: TimelineEvent[]; + isLoading?: boolean; + emptyMessage?: string; + groupByDate?: boolean; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); +} + +function getDateKey(date: Date): string { + return date.toISOString().split('T')[0]; +} + +function isToday(date: Date): boolean { + const today = new Date(); + return getDateKey(date) === getDateKey(today); +} + +function isYesterday(date: Date): boolean { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return getDateKey(date) === getDateKey(yesterday); +} + +function getDateLabel(date: Date, t: (key: string) => string): string { + if (isToday(date)) return t('timeline.today'); + if (isYesterday(date)) return t('timeline.yesterday'); + return formatDate(date); +} + +const typeColors: Record = { + default: { line: 'var(--border-glass)', dot: 'var(--text-secondary)' }, + success: { line: 'rgba(34, 197, 94, 0.3)', dot: '#22c55e' }, + warning: { line: 'rgba(245, 158, 11, 0.3)', dot: '#f59e0b' }, + error: { line: 'rgba(239, 68, 68, 0.3)', dot: '#ef4444' }, + info: { line: 'rgba(59, 130, 246, 0.3)', dot: '#3b82f6' }, +}; + +const TimelineItem: React.FC<{ + event: TimelineEvent; + isLast: boolean; +}> = ({ event, isLast }) => { + const eventDate = typeof event.timestamp === 'string' + ? new Date(event.timestamp) + : event.timestamp; + const colors = typeColors[event.type || 'default']; + + return ( +
+
+
+ {event.icon || } +
+ {!isLast && ( +
+ )} +
+ +
+
+ + {event.title} + + + {formatTime(eventDate)} + +
+ + {event.description && ( +

+ {event.description} +

+ )} + + {event.metadata && Object.keys(event.metadata).length > 0 && ( +
+ {Object.entries(event.metadata).map(([key, value]) => ( + + {key}: + {value} + + ))} +
+ )} +
+
+ ); +}; + +const Timeline: React.FC = ({ + events, + isLoading = false, + emptyMessage, + groupByDate = true, +}) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+
+ {t('timeline.loading')} +
+ ); + } + + if (events.length === 0) { + return ( +
+ +

{emptyMessage || t('timeline.empty')}

+
+ ); + } + + const sortedEvents = [...events].sort((a, b) => { + const dateA = typeof a.timestamp === 'string' ? new Date(a.timestamp) : a.timestamp; + const dateB = typeof b.timestamp === 'string' ? new Date(b.timestamp) : b.timestamp; + return dateB.getTime() - dateA.getTime(); + }); + + if (!groupByDate) { + return ( +
+ {sortedEvents.map((event, index) => ( + + ))} +
+ ); + } + + const groupedEvents: Record = {}; + for (const event of sortedEvents) { + const eventDate = typeof event.timestamp === 'string' + ? new Date(event.timestamp) + : event.timestamp; + const key = getDateKey(eventDate); + if (!groupedEvents[key]) groupedEvents[key] = []; + groupedEvents[key].push(event); + } + + return ( +
+ {Object.entries(groupedEvents).map(([dateKey, dateEvents]) => { + const date = new Date(dateKey); + return ( +
+

+ {getDateLabel(date, t)} +

+ {dateEvents.map((event, index) => ( + + ))} +
+ ); + })} +
+ ); +}; + +export default Timeline; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 166fbbf..55074a8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -67,6 +67,7 @@ export const en = { title: "Keyboard Shortcuts", close: "Close", hint: "Press Esc to close this dialog", + }, refresh: { live: "Live", stopped: "Stopped", @@ -81,4 +82,10 @@ export const en = { pausedOffline: "Paused (offline)", pausedManual: "Paused", }, + timeline: { + loading: "Loading activity...", + empty: "No activity to display", + today: "Today", + yesterday: "Yesterday", + }, } as const; diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 0416690..50ae339 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -5,7 +5,7 @@ export const es = { app: { loading: { title: "Cargando...", - subtitle: "Asegurando la conexión RWA", + subtitle: "Asegurando la conexinnn RWA", }, errorBoundary: "Se produjo un error. Nuestro equipo ha sido notificado.", @@ -15,9 +15,9 @@ export const es = { primary: "YieldVault", accent: "RWA", }, - vaults: "Bóvedas", + vaults: "Bvvvedas", portfolio: "Portafolio", - analytics: "Analítica", + analytics: "Analica", }, theme: { toggleToDark: "Cambiar al modo oscuro", @@ -35,29 +35,29 @@ export const es = { walletConnected: { title: "Billetera conectada", description: - "Freighter está conectado a tu sesión de YieldVault.", + "Freighter est conectado a tu sesinnn de YieldVault.", }, walletPermissionRequired: { title: "Permiso de billetera requerido", description: - "Freighter no devolvió una clave pública para esta sesión.", + "Freighter no devolvi una clave pblica para esta sesinnn.", }, walletConnectionFailed: { - title: "Falló la conexión de la billetera", + title: "Fall la conexinnn de la billetera", description: - "Asegúrate de que Freighter esté instalado, desbloqueado y aprobado para este sitio.", + "Asegrate de que Freighter est instalado, desbloqueado y aprobado para este sitio.", }, walletDisconnected: { title: "Billetera desconectada", description: - "Puedes volver a conectar en cualquier momento para seguir gestionando posiciones en la bóveda.", + "Puedes volver a conectar en cualquier momento para seguir gestionando posiciones en la bvvveda.", }, }, apiBanner: { title: "Datos no disponibles", }, dataTable: { - pageLabel: "Página", + pageLabel: "PasswordService" gina", pageOf: "de", previous: "Anterior", next: "Siguiente", @@ -66,7 +66,8 @@ export const es = { shortcuts: { title: "Atajos de teclado", close: "Cerrar", - hint: "Presiona Esc para cerrar este diálogo", + hint: "Presiona Esc para cerrar este dilogo", + }, refresh: { live: "En vivo", stopped: "Detenido", @@ -76,9 +77,15 @@ export const es = { refreshing: "Actualizando...", justNow: "Ahora", oneMinuteAgo: "Hace 1 min", - minutesAgo: "min atrás", - pausedHidden: "Pausado (pestaña oculta)", - pausedOffline: "Pausado (sin conexión)", + minutesAgo: "min atrs", + pausedHidden: "Pausado (pestaa oculta)", + pausedOffline: "Pausado (sin conexinnn)", pausedManual: "Pausado", }, + timeline: { + loading: "Cargando actividad...", + empty: "No hay actividad para mostrar", + today: "Hoy", + yesterday: "Ayer", + }, } as const; diff --git a/frontend/src/pages/TransactionHistory.tsx b/frontend/src/pages/TransactionHistory.tsx index 9c28d69..76ef41b 100644 --- a/frontend/src/pages/TransactionHistory.tsx +++ b/frontend/src/pages/TransactionHistory.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useSearchParams } from "react-router-dom"; import ApiStatusBanner from "../components/ApiStatusBanner"; +import Badge from "../components/Badge"; import { DataTable, type DataTableColumn } from "../components/DataTable"; import PageHeader from "../components/PageHeader"; import { normalizeApiError, type ApiError } from "../lib/api"; @@ -21,19 +22,18 @@ interface TransactionHistoryProps { type TxTypeFilter = "all" | "deposit" | "withdrawal"; -// Task 4.2: DataTable column config for transactions const columns: DataTableColumn[] = [ { id: "type", header: "Type", sortable: true, cell: (row) => ( - {row.type} - + ), }, {