diff --git a/app/(dashboard)/events/event-page/EventDetailsClient.tsx b/app/(dashboard)/events/event-page/EventDetailsClient.tsx index 841063d6..68e9ee94 100644 --- a/app/(dashboard)/events/event-page/EventDetailsClient.tsx +++ b/app/(dashboard)/events/event-page/EventDetailsClient.tsx @@ -22,6 +22,7 @@ import { Separator } from "@/components/ui/separator"; import { Clock, DollarSign, Users, BarChart2, Loader2, Share2 } from "lucide-react"; import { formatDistanceToNowStrict, parseISO, isValid } from "date-fns"; import { MarketDetailTabs } from "@/components/market/MarketDetailTabs"; +import { MarketTimeline } from "@/components/market/MarketTimeline"; import { ShareSheet } from "@/app/components/ShareSheet"; import { useMediaQuery } from "@/hooks/use-media-query"; import { @@ -497,6 +498,7 @@ export default function EventDetailsClient() { overview={overviewTab} activity={activityTab} resolution={resolutionTab} + timeline={} /> diff --git a/components/market/MarketDetailTabs.tsx b/components/market/MarketDetailTabs.tsx index 2758c974..e7f8be89 100644 --- a/components/market/MarketDetailTabs.tsx +++ b/components/market/MarketDetailTabs.tsx @@ -3,13 +3,14 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -const TAB_VALUES = ["overview", "activity", "resolution"] as const; +const TAB_VALUES = ["overview", "activity", "resolution", "timeline"] as const; type TabValue = (typeof TAB_VALUES)[number]; interface MarketDetailTabsProps { overview: React.ReactNode; activity: React.ReactNode; resolution: React.ReactNode; + timeline: React.ReactNode; defaultValue?: TabValue; } @@ -17,6 +18,7 @@ export function MarketDetailTabs({ overview, activity, resolution, + timeline, defaultValue = "overview", }: MarketDetailTabsProps) { const searchParams = useSearchParams(); @@ -40,11 +42,13 @@ export function MarketDetailTabs({ Overview Activity Resolution + Timeline {overview} {activity} {resolution} + {timeline} ); } diff --git a/components/market/MarketTimeline.tsx b/components/market/MarketTimeline.tsx new file mode 100644 index 00000000..278bfada --- /dev/null +++ b/components/market/MarketTimeline.tsx @@ -0,0 +1,309 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + MarketEvent, + MarketEventType, + getMarketEventIcon, + getMarketEventColor, + getMarketEventLabel, +} from "@/types/market-timeline"; +import { generateMockMarketEvents, formatMarketTimestamp, formatMarketTime, groupMarketEventsByDate } from "@/lib/market-timeline"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AlertCircle, ChevronDown } from "lucide-react"; + +const EVENT_ICONS: Record = { + "plus-circle": ( + + ), + unlock: ( + + ), + target: ( + + ), + "trending-up": ( + + ), + droplet: ( + + ), + lock: ( + + ), + "alert-circle": ( + + ), + "check-square": ( + + ), + "check-circle": ( + + ), + gift: ( + + ), + circle: ( + + ), +}; + +interface MarketTimelineProps { + className?: string; + events?: MarketEvent[]; + isLoading?: boolean; + error?: string | null; + onLoadMore?: () => void; + hasMore?: boolean; +} + +export function MarketTimeline({ + className, + events, + isLoading = false, + error = null, + onLoadMore, + hasMore = false, +}: MarketTimelineProps) { + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const eventData = events ?? generateMockMarketEvents(); + + const groups = useMemo(() => groupMarketEventsByDate(eventData), [eventData]); + + const toggleGroup = (date: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(date)) { + next.delete(date); + } else { + next.add(date); + } + return next; + }); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (eventData.length === 0) { + return ; + } + + return ( + + {groups.map((group) => { + const isExpanded = expandedGroups.has(group.date); + const showCollapse = group.events.length > 3; + + return ( + + + + {group.label} + + + ({group.events.length} event{group.events.length !== 1 ? "s" : ""}) + + {showCollapse && ( + toggleGroup(group.date)} + className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1" + aria-label={isExpanded ? "Collapse events" : "Expand events"} + > + {isExpanded ? "Show less" : `Show all ${group.events.length}`} + + + )} + + + + + + 3 && "overflow-hidden")}> + {(isExpanded || !showCollapse + ? group.events + : group.events.slice(0, 3) + ).map((event) => ( + + ))} + + + {!isExpanded && showCollapse && ( + + toggleGroup(group.date)} + className="text-xs text-primary hover:text-primary/80 transition-colors font-medium" + > + +{group.events.length - 3} more event{group.events.length - 3 !== 1 ? "s" : ""} + + + )} + + + ); + })} + + {hasMore && onLoadMore && ( + + + Load Older Events + + + )} + + ); +} + +function MarketTimelineItem({ event }: { event: MarketEvent }) { + const color = getMarketEventColor(event.eventType); + const icon = getMarketEventIcon(event.eventType); + + return ( + + + + {EVENT_ICONS[icon] ?? EVENT_ICONS.circle} + + + + + + + {event.title} + {event.description && ( + + {event.description} + + )} + + + {formatMarketTimestamp(event.timestamp)} + + + + {event.amount && event.currency && ( + + + {event.amount.toLocaleString()} {event.currency} + + + )} + + {event.user && ( + + by {event.user} + + )} + + + ); +} + +function MarketTimelineEmpty({ className }: { className?: string }) { + return ( + + + + + + + + + No timeline events yet + + + Market events will appear here as the market progresses through its + lifecycle. + + + ); +} + +function MarketTimelineError({ + error, + className, +}: { + error: string; + className?: string; +}) { + return ( + + + + + + Failed to load timeline + + {error} + + ); +} + +function MarketTimelineSkeleton({ className }: { className?: string }) { + return ( + + {[1, 2, 3].map((group) => ( + + + + {[1, 2].map((item) => ( + + + + + + + + + ))} + + + ))} + + ); +} diff --git a/components/market/__tests__/MarketTimeline.test.tsx b/components/market/__tests__/MarketTimeline.test.tsx new file mode 100644 index 00000000..fe226700 --- /dev/null +++ b/components/market/__tests__/MarketTimeline.test.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MarketTimeline } from "../MarketTimeline"; +import { MarketEvent } from "@/types/market-timeline"; + +const createMockEvent = (overrides: Partial = {}): MarketEvent => ({ + id: "test-1", + eventType: "market_created", + timestamp: new Date(), + title: "Test Market Event", + description: "A test event description", + ...overrides, +}); + +describe("MarketTimeline", () => { + describe("Rendering", () => { + it("renders with default mock data", () => { + render(); + expect(screen.getByText(/market created/i)).toBeInTheDocument(); + }); + + it("renders with provided events", () => { + const events = [createMockEvent({ id: "1", title: "Custom Event" })]; + render(); + expect(screen.getByText("Custom Event")).toBeInTheDocument(); + }); + + it("renders empty state when no events", () => { + render(); + expect( + screen.getByText(/no timeline events yet/i) + ).toBeInTheDocument(); + }); + + it("renders loading state", () => { + const { container } = render(); + const skeleton = container.querySelector(".animate-pulse"); + expect(skeleton).toBeInTheDocument(); + }); + + it("renders error state with message", () => { + render(); + expect(screen.getByText(/failed to load timeline/i)).toBeInTheDocument(); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + }); + + describe("Event Display", () => { + it("displays event title and description", () => { + const events = [ + createMockEvent({ + id: "1", + title: "Market Opened", + description: "Market is now live", + }), + ]; + render(); + expect(screen.getByText("Market Opened")).toBeInTheDocument(); + expect(screen.getByText("Market is now live")).toBeInTheDocument(); + }); + + it("displays amount and currency for financial events", () => { + const events = [ + createMockEvent({ + id: "1", + eventType: "liquidity_added", + title: "Liquidity Added", + amount: 10000, + currency: "USDC", + }), + ]; + render(); + expect(screen.getByText("10,000 USDC")).toBeInTheDocument(); + }); + + it("displays user address when present", () => { + const events = [ + createMockEvent({ + id: "1", + title: "Prediction Placed", + user: "0xabc...def", + }), + ]; + render(); + expect(screen.getByText(/0xabc...def/)).toBeInTheDocument(); + }); + + it("formats relative timestamps", () => { + const events = [ + createMockEvent({ + id: "1", + timestamp: new Date(), + }), + ]; + render(); + expect(screen.getByText(/just now/i)).toBeInTheDocument(); + }); + + it("shows event type icon for each event", () => { + const events = [ + createMockEvent({ + id: "1", + eventType: "market_created", + title: "Market Created", + }), + createMockEvent({ + id: "2", + eventType: "market_opened", + title: "Market Opened", + }), + createMockEvent({ + id: "3", + eventType: "market_closed", + title: "Market Closed", + }), + ]; + const { container } = render(); + const circles = container.querySelectorAll(".rounded-full"); + expect(circles.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("Date Grouping", () => { + it("groups events by date", () => { + const events = [ + createMockEvent({ + id: "1", + title: "Recent Event", + timestamp: new Date(), + }), + createMockEvent({ + id: "2", + title: "Older Event", + timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + }), + ]; + render(); + expect(screen.getByText(/today/i)).toBeInTheDocument(); + }); + + it("shows collapsed state for groups with many events", () => { + const events = Array.from({ length: 6 }, (_, i) => + createMockEvent({ + id: `event-${i}`, + title: `Event ${i + 1}`, + timestamp: new Date(), + }) + ); + render(); + expect(screen.getByText(/show all 6/i)).toBeInTheDocument(); + }); + + it("expands collapsed group on click", () => { + const events = Array.from({ length: 5 }, (_, i) => + createMockEvent({ + id: `event-${i}`, + title: `Event ${i + 1}`, + timestamp: new Date(), + }) + ); + render(); + const showAllButton = screen.getByText(/show all 5/i); + fireEvent.click(showAllButton); + expect(screen.getByText(/show less/i)).toBeInTheDocument(); + }); + }); + + describe("Load More", () => { + it("shows load more button when hasMore is true", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /load older events/i }) + ).toBeInTheDocument(); + }); + + it("calls onLoadMore when clicked", () => { + const onLoadMore = jest.fn(); + render( + + ); + fireEvent.click( + screen.getByRole("button", { name: /load older events/i }) + ); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it("does not show load more when hasMore is false", () => { + render( + + ); + expect( + screen.queryByRole("button", { name: /load older events/i }) + ).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("handles missing description gracefully", () => { + const events = [ + createMockEvent({ + id: "1", + title: "Event without description", + description: undefined, + }), + ]; + render(); + expect( + screen.getByText("Event without description") + ).toBeInTheDocument(); + }); + + it("handles missing amount gracefully", () => { + const events = [ + createMockEvent({ + id: "1", + eventType: "market_resolved", + title: "Market Resolved", + amount: undefined, + currency: undefined, + }), + ]; + render(); + expect(screen.getByText("Market Resolved")).toBeInTheDocument(); + }); + + it("handles empty event array gracefully", () => { + render(); + expect( + screen.getByText(/no timeline events yet/i) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/components/market/__tests__/market-detail-tabs.test.tsx b/components/market/__tests__/market-detail-tabs.test.tsx index e5de089f..f2575fd6 100644 --- a/components/market/__tests__/market-detail-tabs.test.tsx +++ b/components/market/__tests__/market-detail-tabs.test.tsx @@ -19,23 +19,25 @@ beforeEach(() => { ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()) }) -function renderTabs(props?: { defaultValue?: "overview" | "activity" | "resolution" }) { +function renderTabs(props?: { defaultValue?: "overview" | "activity" | "resolution" | "timeline" }) { return render( Overview Content} activity={Activity Content} resolution={Resolution Content} + timeline={Timeline Content} {...props} /> ) } describe("MarketDetailTabs", () => { - it("renders three tab triggers", () => { + it("renders four tab triggers", () => { renderTabs() expect(screen.getByRole("tab", { name: /overview/i })).toBeInTheDocument() expect(screen.getByRole("tab", { name: /activity/i })).toBeInTheDocument() expect(screen.getByRole("tab", { name: /resolution/i })).toBeInTheDocument() + expect(screen.getByRole("tab", { name: /timeline/i })).toBeInTheDocument() }) it("defaults to Overview tab when no URL param", () => { @@ -84,4 +86,32 @@ describe("MarketDetailTabs", () => { expect(screen.getByText("Resolution Content")).toBeInTheDocument() expect(screen.queryByText("Overview Content")).not.toBeInTheDocument() }) + + it("switches to timeline tab on click", async () => { + const user = userEvent.setup() + renderTabs() + await user.click(screen.getByRole("tab", { name: /timeline/i })) + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + expect.stringContaining("tab=timeline"), + { scroll: false } + ) + }) + }) + + it("renders timeline tab from URL param", () => { + ;(useSearchParams as jest.Mock).mockReturnValue( + new URLSearchParams("tab=timeline") + ) + renderTabs() + expect(screen.getByText("Timeline Content")).toBeInTheDocument() + expect(screen.queryByText("Overview Content")).not.toBeInTheDocument() + }) + + it("defaults to timeline tab when specified", () => { + renderTabs({ defaultValue: "timeline" }) + const timelineTab = screen.getByRole("tab", { name: /timeline/i }) + expect(timelineTab).toHaveAttribute("data-state", "active") + expect(screen.getByText("Timeline Content")).toBeInTheDocument() + }) }) diff --git a/lib/market-timeline.ts b/lib/market-timeline.ts new file mode 100644 index 00000000..c2142d25 --- /dev/null +++ b/lib/market-timeline.ts @@ -0,0 +1,239 @@ +import { + MarketEvent, + MarketEventType, +} from "@/types/market-timeline"; + +export function generateMockMarketEvents(eventTitle?: string): MarketEvent[] { + const now = new Date(); + const title = eventTitle ?? "Super Bowl Winner 2025"; + + const events: MarketEvent[] = [ + { + id: "mt-1", + eventType: "market_created", + timestamp: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + title: `"${title}" market created`, + description: "Market was created by the platform administrator", + user: "admin", + }, + { + id: "mt-2", + eventType: "market_opened", + timestamp: new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000), + title: `"${title}" opened for predictions`, + description: "Market is now accepting predictions from all users", + }, + { + id: "mt-3", + eventType: "liquidity_added", + timestamp: new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000 + 3600000), + title: `Liquidity added to "${title}"`, + description: "Initial liquidity pool funded with 10,000 USDC", + amount: 10000, + currency: "USDC", + user: "0x1a2b...3c4d", + }, + { + id: "mt-4", + eventType: "prediction_placed", + timestamp: new Date(now.getTime() - 21 * 24 * 60 * 60 * 1000), + title: "Prediction placed on Kansas City Chiefs", + description: "User predicted Kansas City Chiefs would win", + amount: 500, + currency: "USDC", + user: "0x5e6f...7g8h", + }, + { + id: "mt-5", + eventType: "prediction_placed", + timestamp: new Date(now.getTime() - 18 * 24 * 60 * 60 * 1000), + title: "Prediction placed on San Francisco 49ers", + description: "User predicted San Francisco 49ers would win", + amount: 350, + currency: "USDC", + user: "0x9i0j...1k2l", + }, + { + id: "mt-6", + eventType: "odds_updated", + timestamp: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000), + title: "Odds updated for Kansas City Chiefs", + description: "Odds moved from 2.5x to 2.2x following increased volume", + metadata: { + before: "2.5x", + after: "2.2x", + }, + }, + { + id: "mt-7", + eventType: "prediction_placed", + timestamp: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + title: "Prediction placed on Detroit Lions", + description: "User predicted Detroit Lions would win", + amount: 1000, + currency: "USDC", + user: "0x3m4n...5o6p", + }, + { + id: "mt-8", + eventType: "odds_updated", + timestamp: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + title: "Odds updated for Detroit Lions", + description: "Odds moved from 5.0x to 4.5x following a large prediction", + metadata: { + before: "5.0x", + after: "4.5x", + }, + }, + { + id: "mt-9", + eventType: "prediction_placed", + timestamp: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + title: "Prediction placed on Kansas City Chiefs", + description: "User predicted Kansas City Chiefs would win", + amount: 750, + currency: "USDC", + user: "0x7q8r...9s0t", + }, + { + id: "mt-10", + eventType: "dispute_filed", + timestamp: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), + title: "Dispute filed on market resolution", + description: "User challenged the oracle source used for resolution", + user: "0x1u2v...3w4x", + }, + { + id: "mt-11", + eventType: "dispute_resolved", + timestamp: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), + title: "Dispute resolved in favor of original outcome", + description: "Review confirmed the oracle data was accurate", + }, + { + id: "mt-12", + eventType: "market_closed", + timestamp: new Date(now.getTime() - 24 * 60 * 60 * 1000), + title: `"${title}" closed for new predictions`, + description: "The betting window has ended. No further predictions accepted.", + }, + { + id: "mt-13", + eventType: "market_resolved", + timestamp: new Date(now.getTime() - 12 * 60 * 60 * 1000), + title: `"${title}" resolved`, + description: "Market resolved with outcome: Kansas City Chiefs", + }, + { + id: "mt-14", + eventType: "payouts_distributed", + timestamp: new Date(now.getTime() - 6 * 60 * 60 * 1000), + title: `Payouts distributed for "${title}"`, + description: "All winning predictions have been paid out automatically", + amount: 8750, + currency: "USDC", + }, + ]; + + return events; +} + +export function formatMarketTimestamp(date: Date): string { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const diffInMinutes = Math.floor(diff / (1000 * 60)); + const diffInHours = Math.floor(diffInMinutes / 60); + const diffInDays = Math.floor(diffInHours / 24); + + if (diffInMinutes < 1) { + return "Just now"; + } + if (diffInMinutes < 60) { + return `${diffInMinutes}m ago`; + } + if (diffInHours < 24) { + return `${diffInHours}h ago`; + } + if (diffInDays === 1) { + return "Yesterday"; + } + if (diffInDays < 7) { + return `${diffInDays}d ago`; + } + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); +} + +export function formatMarketTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +export function groupMarketEventsByDate( + events: MarketEvent[] +): { date: string; label: string; events: MarketEvent[] }[] { + const sorted = [...events].sort( + (a, b) => b.timestamp.getTime() - a.timestamp.getTime() + ); + + const groups = new Map(); + + sorted.forEach((event) => { + const now = new Date(); + const eventDate = event.timestamp; + const diff = now.getTime() - eventDate.getTime(); + const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24)); + + let key: string; + let label: string; + + if (diffInDays === 0) { + key = "today"; + label = "Today"; + } else if (diffInDays === 1) { + key = "yesterday"; + label = "Yesterday"; + } else if (diffInDays < 7) { + key = "this-week"; + label = "This Week"; + } else if (diffInDays < 30) { + key = "this-month"; + label = "This Month"; + } else { + const month = eventDate.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }); + key = month; + label = month; + } + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(event); + }); + + const order = ["today", "yesterday", "this-week", "this-month"]; + const result: { date: string; label: string; events: MarketEvent[] }[] = []; + + order.forEach((key) => { + if (groups.has(key)) { + result.push({ date: key, label: key === "this-month" ? "This Month" : key === "this-week" ? "This Week" : key === "today" ? "Today" : "Yesterday", events: groups.get(key)! }); + groups.delete(key); + } + }); + + groups.forEach((events, key) => { + result.push({ date: key, label: key, events }); + }); + + return result; +} diff --git a/types/index.ts b/types/index.ts index e4f89345..0c4af2e2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3,6 +3,9 @@ import type { LucideIcon } from "lucide-react"; export type { ActivityEvent, ActivityEventType, ActivityGroupType, GroupedActivity, ActivityTimelineState } from "./activity"; export { ACTIVITY_GROUPING_RULES, ACTIVITY_GROUP_CONFIG, ACTIVITY_EVENT_ICONS, GROUPING_STRATEGY, DEFAULT_ACTIVITY_TIMEFRAME } from "./activity"; +export type { MarketEvent, MarketEventType, MarketEventGroup } from "./market-timeline"; +export { MARKET_EVENT_CONFIG, getMarketEventIcon, getMarketEventColor, getMarketEventLabel } from "./market-timeline"; + export interface Feature { icon: LucideIcon; title: string; diff --git a/types/market-timeline.ts b/types/market-timeline.ts new file mode 100644 index 00000000..187a451c --- /dev/null +++ b/types/market-timeline.ts @@ -0,0 +1,102 @@ +export type MarketEventType = + | "market_created" + | "market_opened" + | "prediction_placed" + | "odds_updated" + | "liquidity_added" + | "market_closed" + | "dispute_filed" + | "dispute_resolved" + | "market_resolved" + | "payouts_distributed"; + +export interface MarketEvent { + id: string; + eventType: MarketEventType; + timestamp: Date; + title: string; + description?: string; + amount?: number; + currency?: string; + user?: string; + relatedEntityId?: string; + metadata?: Record; +} + +export interface MarketEventGroup { + date: string; + label: string; + events: MarketEvent[]; +} + +export const MARKET_EVENT_CONFIG: Record< + MarketEventType, + { + label: string; + icon: string; + color: string; + } +> = { + market_created: { + label: "Market Created", + icon: "plus-circle", + color: "#8B5CF6", + }, + market_opened: { + label: "Market Opened", + icon: "unlock", + color: "#06B6D4", + }, + prediction_placed: { + label: "Prediction Placed", + icon: "target", + color: "#10B981", + }, + odds_updated: { + label: "Odds Updated", + icon: "trending-up", + color: "#F59E0B", + }, + liquidity_added: { + label: "Liquidity Added", + icon: "droplet", + color: "#3B82F6", + }, + market_closed: { + label: "Market Closed", + icon: "lock", + color: "#EF4444", + }, + dispute_filed: { + label: "Dispute Filed", + icon: "alert-circle", + color: "#F97316", + }, + dispute_resolved: { + label: "Dispute Resolved", + icon: "check-square", + color: "#10B981", + }, + market_resolved: { + label: "Market Resolved", + icon: "check-circle", + color: "#22C55E", + }, + payouts_distributed: { + label: "Payouts Distributed", + icon: "gift", + color: "#8B5CF6", + }, +}; + +export function getMarketEventIcon(eventType: MarketEventType): string { + return MARKET_EVENT_CONFIG[eventType]?.icon ?? "circle"; +} + +export function getMarketEventColor(eventType: MarketEventType): string { + return MARKET_EVENT_CONFIG[eventType]?.color ?? "#6B7280"; +} + +export function getMarketEventLabel(eventType: MarketEventType): string { + return MARKET_EVENT_CONFIG[eventType]?.label ?? eventType; +}
{event.title}
+ {event.description} +
+ by {event.user} +
+ Market events will appear here as the market progresses through its + lifecycle. +
{error}