@@ -767,8 +500,8 @@ export default function SettingsPage() {
-
@@ -869,31 +602,7 @@ function PreferenceSwitch({
)
}
-function QuietHoursTimeField({
- id,
- label,
- value,
- onChange,
-}: {
- id: string
- label: string
- value: string
- onChange: (value: string) => void
-}) {
- return (
-
-
- onChange(event.target.value)}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
- />
-
- )
-}
-
+import { usePrivacy } from '@/context/PrivacyContext';
function PreferenceSelect({
id,
label,
diff --git a/components/disputes/DisputeEvidencePreview.tsx b/components/disputes/DisputeEvidencePreview.tsx
new file mode 100644
index 00000000..03b40b48
--- /dev/null
+++ b/components/disputes/DisputeEvidencePreview.tsx
@@ -0,0 +1,41 @@
+import { ExternalLink, ShieldAlert } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { normalizeDisputeEvidence, type DisputeEvidenceInput } from '@/lib/dispute-evidence';
+
+interface DisputeEvidencePreviewProps {
+ evidence?: DisputeEvidenceInput;
+ fallbackMessage?: string;
+}
+
+export function DisputeEvidencePreview({
+ evidence,
+ fallbackMessage = 'No verified evidence link available.',
+}: DisputeEvidencePreviewProps) {
+ const items = normalizeDisputeEvidence(evidence);
+
+ if (!items.length) {
+ return
{fallbackMessage}
;
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/components/disputes/states/ExecutedState.tsx b/components/disputes/states/ExecutedState.tsx
index 4bf602e0..74b6c111 100644
--- a/components/disputes/states/ExecutedState.tsx
+++ b/components/disputes/states/ExecutedState.tsx
@@ -1,8 +1,9 @@
+import { ExternalLink } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
import { TallyBar } from '@/components/disputes/shared/TallyBar';
import { DetailsAccordion } from '@/components/disputes/shared/DetailsAccordion';
-import { OutcomeChip } from '@/components/ui/OutcomeChip';
import type { DisputeData, DisputeState } from '@/types/disputes';
-import { ExternalLink } from '@/components/ExternalLink';
+import { normalizeDisputeEvidence } from '@/lib/dispute-evidence';
interface ExecutedStateProps {
data: DisputeData;
@@ -16,9 +17,9 @@ export function ExecutedState({ data }: ExecutedStateProps) {
{data.outcome && (
Final outcome:
-
+
{data.outcome}
-
+
)}
@@ -40,15 +41,17 @@ export function ExecutedState({ data }: ExecutedStateProps) {
Audit references
diff --git a/components/events/events-table.tsx b/components/events/events-table.tsx
index 726b55a7..f20b3a02 100644
--- a/components/events/events-table.tsx
+++ b/components/events/events-table.tsx
@@ -3,8 +3,7 @@
import * as React from "react"
import Link from "next/link"
/* NEW: Added lucide icons for row actions and compare */
-import { Edit, MoreHorizontal, Trash2, Users, Calendar, Trophy, Building2, CircleDollarSign, LineChart, TrendingUp, GitCompareArrows, ShieldCheck, Clock, AlertTriangle } from "lucide-react"
-import { HoverTooltip } from "@/components/HoverTooltip"
+import { Edit, MoreHorizontal, Trash2, Users, Calendar, Trophy, Building2, CircleDollarSign, LineChart, TrendingUp, GitCompareArrows } from "lucide-react"
import { cn } from "@/lib/utils"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
@@ -31,11 +30,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EventsTableSkeleton } from "./events-table-skeleton"
-import { NoMatchEmptyState } from "./NoMatchEmptyState"
-/* NEW: GrantFox FWC26 / Stellar Wave themed empty state for the "no events at
- * all" scenario (distinct from NoMatchEmptyState which handles active-filter
- * zero-result cases). */
-import { EventsEmptyState } from "./EventsEmptyState"
import { useEventsStore, formatTimeRemaining, getTimeRemainingColor } from "@/lib/events-store"
import { useCompareStore, MAX_COMPARE } from "@/lib/compare-store"
import { Checkbox } from "@/components/ui/checkbox"
@@ -94,19 +88,10 @@ function TimeRemainingProgress({ event }: { event: Event }) {
return () => clearInterval(interval)
}, [])
- if (typeof event.timeRemainingMs !== "number" || !Number.isFinite(event.timeRemainingMs)) {
+ if (!event.timeRemainingMs) {
return
-
}
- if (event.timeRemainingMs <= 0) {
- return (
-
-
- Ended
-
- )
- }
-
const color = getTimeRemainingColor(event.timeRemainingMs)
const timeString = formatTimeRemaining(event.timeRemainingMs)
@@ -115,43 +100,27 @@ function TimeRemainingProgress({ event }: { event: Event }) {
const currentDays = event.timeRemainingMs / (24 * 60 * 60 * 1000)
const progressValue = Math.max(0, Math.min(100, (currentDays / maxDays) * 100))
- const urgencyLabels: Record
= {
- green: "Low urgency",
- orange: "Medium urgency",
- red: "High urgency",
- }
- const urgencyLabel = urgencyLabels[color] ?? "Unknown urgency"
- const urgencyIcons: Record = {
- green: ,
- orange: ,
- red: ,
- }
+ const urgencyLabel = { green: "Low urgency", orange: "Medium urgency", red: "High urgency" }[color]
- const progressColorClasses: Record = {
+ const progressColorClass = {
green: "bg-[#16DB30]",
orange: "bg-[#FFBB00]",
red: "bg-[#FF5858]",
- }
- const progressColorClass = progressColorClasses[color] ?? "bg-gray-200"
+ }[color]
- const textColorClasses: Record = {
+ const textColorClass = {
green: "text-[#16DB30]",
orange: "text-[#FFBB00]",
red: "text-[#FF5858]",
- }
- const textColorClass = textColorClasses[color] ?? "text-muted-foreground"
+ }[color]
const progressValueRounded = Math.round(progressValue)
return (
-
+
{timeString}
- {/* Visible urgency icon and text keep status independent of color. */}
-
- {urgencyIcons[color]}
- {urgencyLabel}
-
+ — {urgencyLabel}
>
- selectedIds: string[]
- toggle: (id: string) => void
- setDeleteTarget: (event: Event) => void
-}
-
-function EventRow({
- event,
- index,
- isLast,
- animationReady,
- prefersReduced,
- seenIds,
- selectedIds,
- toggle,
- setDeleteTarget,
-}: EventRowProps) {
- // Mark row as seen after initial render (valid hook placement inside a component)
- React.useEffect(() => {
- seenIds.current.add(event.id)
- }, [event.id, seenIds])
-
- const isSeen = seenIds.current.has(event.id)
-
- return (
-
- {/* Compare checkbox */}
-
- toggle(event.id)}
- disabled={!selectedIds.includes(event.id) && selectedIds.length >= MAX_COMPARE}
- aria-label={`Select ${event.title} for comparison`}
- className="border-primary data-[state=checked]:border-primary data-[state=checked]:bg-primary"
- />
-
-
- {/* Event title cell with hover-delayed tooltip showing key data */}
-
-
- Event Details
-
-
Category: {event.category}
-
Odds: {event.odds}
-
Participants: {event.participants.toLocaleString()}
-
Ends: {formatDate(new Date(event.endDate))}
-
-
- }
- >
-
-
{event.title}
-
#{event.txHash}
-
-
-
-
-
-
- {getCategoryIcon(event.category)}
- {event.category}
-
-
-
-
- {event.odds}
-
-
-
-
-
-
{formatDate(new Date(event.startDate))}
-
{formatDate(new Date(event.endDate))}
-
-
- {formatDate(new Date(event.startDate))} - {formatDate(new Date(event.endDate))}
-
-
-
-
-
- Time remaining
-
-
-
- {/* Participants */}
-
-
-
- {event.participants.toLocaleString()}
-
-
-
- {/* Actions */}
-
-
-
-
-
- Open actions menu
-
-
-
- Actions
-
-
-
-
- Edit Event
-
-
- setDeleteTarget(event)}
- >
-
- Delete Event
-
-
-
-
-
- )
-}
-
export function EventsTable({ className }: EventsTableProps) {
/* MODIFIED: Added deleteEvent from store */
- const {
- filteredEvents,
- loading,
- lastFetchTime,
- pagination,
- deleteEvent,
- filters,
- setFilters,
- setSearch,
- } = useEventsStore()
+ const { filteredEvents, loading, pagination, deleteEvent } = useEventsStore()
/* Compare store */
const { selectedIds, toggle } = useCompareStore()
@@ -337,7 +151,7 @@ export function EventsTable({ className }: EventsTableProps) {
// Track rows that have already animated in
const seenIds = React.useRef(new Set
())
const [animationReady, setAnimationReady] = React.useState(false)
- const prefersReduced = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
+ const prefersReduced = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
React.useEffect(() => {
setAnimationReady(true)
@@ -348,55 +162,22 @@ export function EventsTable({ className }: EventsTableProps) {
const endIndex = startIndex + pagination.pageSize
const paginatedEvents = filteredEvents.slice(startIndex, endIndex)
- // During a retry, preserve the last good page instead of replacing it with a
- // skeleton. This avoids losing the user's position while live data is stale.
- if (loading && (filteredEvents.length === 0 || lastFetchTime === null)) {
+ if (loading) {
return
}
- /*
- * MODIFIED: Split empty-state handling into two branches:
- *
- * 1. "True empty" — no events exist for the current status tab and no
- * filters are active. Render the GrantFox FWC26 / Stellar Wave branded
- * EventsEmptyState with a "Create Your First Event" CTA.
- *
- * 2. "Filtered empty" — the user has active search/category/date filters
- * that produced zero results. Render NoMatchEmptyState (existing) so the
- * user knows to adjust or clear their filters.
- *
- * The distinction matters: in case 1 we want to drive the user toward
- * creating content; in case 2 we want to help them find existing content.
- */
+ {/* NEW: Enhanced empty state with icon illustration and contextual messaging */}
if (filteredEvents.length === 0) {
- /** True when the user has at least one active filter in play */
- const hasActiveFilters =
- !!filters.search ||
- filters.category.length > 0 ||
- !!(filters.dateRange.from || filters.dateRange.to)
-
- if (!hasActiveFilters) {
- // No events and no filters → show the campaign-branded empty state
- return
- }
-
- // Filters are active but matched nothing → help the user clear them
- const handleClearFilters = () => {
- setSearch("")
- setFilters({
- category: [],
- oddsRange: [0, 10],
- dateRange: { from: null, to: null },
- })
- }
-
return (
- 0}
- hasDateRange={!!(filters.dateRange.from || filters.dateRange.to)}
- onClearFilters={handleClearFilters}
- />
+
+
+
+
+
No events found
+
+ {"There are no prediction events matching your current filters. Try adjusting your search or filter criteria."}
+
+
)
}
@@ -430,63 +211,143 @@ export function EventsTable({ className }: EventsTableProps) {
-
- {/* Rows become readable cards below lg without duplicating accessible content. */}
-
-
-
-
+
+ {/* Responsive table container with horizontal scroll */}
+
+
+
+
{/* Compare select column */}
-
+
Compare
-
+
Event Title
-
+
Category
-
+
Odds
-
+
End Date
-
+
Time Remaining
{/* NEW: Participants column header */}
-
+
Participants
{/* NEW: Actions column header */}
-
+
Actions
-
- {paginatedEvents.map((event, index) => (
-
- ))}
+
+ {paginatedEvents.map((event, index) => {
+ React.useEffect(() => {
+ seenIds.current.add(event.id)
+ }, [event.id])
+
+ return (
+
+ {/* Compare checkbox */}
+
+ toggle(event.id)}
+ disabled={
+ !selectedIds.includes(event.id) &&
+ selectedIds.length >= MAX_COMPARE
+ }
+ aria-label={`Select ${event.title} for comparison`}
+ className="border-[#540D8D] data-[state=checked]:bg-[#540D8D] data-[state=checked]:border-[#540D8D]"
+ />
+
+
+
+
{event.title}
+
#{event.txHash}
+
+
+
+
+ {getCategoryIcon(event.category)}
+ {event.category}
+
+
+
+ {event.odds}
+
+
+
+
+ {/* Mobile: Stack dates vertically */}
+
{formatDate(new Date(event.startDate))}
+
{formatDate(new Date(event.endDate))}
+
+
+ {/* Desktop: Show dates inline with dash */}
+ {formatDate(new Date(event.startDate))} - {formatDate(new Date(event.endDate))}
+
+
+
+
+
+
+ {/* NEW: Participants cell showing formatted participant count */}
+
+
+
+ {event.participants.toLocaleString()}
+
+
+ {/* NEW: Actions cell with dropdown menu for Edit/Delete */}
+
+
+
+
+
+ Open actions menu
+
+
+
+ Actions
+
+
+
+
+ Edit Event
+
+
+ setDeleteTarget(event)}
+ >
+
+ Delete Event
+
+
+
+
+
+ )
+ })}
)
-}
+}
\ No newline at end of file
diff --git a/components/typography-example.tsx b/components/typography-example.tsx
index 803bf889..2b739ccf 100644
--- a/components/typography-example.tsx
+++ b/components/typography-example.tsx
@@ -258,7 +258,7 @@ export function TypographyExample() {
Possible Outcomes
- Yes - SPY > $450
+ {'Yes - SPY > $450'}
72%
diff --git a/lib/__tests__/dispute-evidence.test.ts b/lib/__tests__/dispute-evidence.test.ts
new file mode 100644
index 00000000..777810bd
--- /dev/null
+++ b/lib/__tests__/dispute-evidence.test.ts
@@ -0,0 +1,45 @@
+import {
+ getEvidencePreviewLabel,
+ normalizeDisputeEvidence,
+} from '@/lib/dispute-evidence';
+
+describe('dispute evidence normalization', () => {
+ it('accepts valid https evidence and ignores unsafe values', () => {
+ const result = normalizeDisputeEvidence([
+ { label: 'Court ruling', url: 'https://example.com/ruling.pdf' },
+ { label: 'Private memo', url: 'javascript:alert(1)', isPrivate: true },
+ 'https://example.com/duplicate.pdf',
+ ]);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toMatchObject({
+ label: 'Court ruling',
+ url: 'https://example.com/ruling.pdf',
+ isValid: true,
+ });
+ expect(result[1]).toMatchObject({
+ label: 'Evidence preview',
+ url: 'https://example.com/duplicate.pdf',
+ isValid: true,
+ });
+ });
+
+ it('deduplicates repeated evidence entries and preserves public previews', () => {
+ const result = normalizeDisputeEvidence([
+ 'https://example.com/report.pdf',
+ 'https://example.com/report.pdf',
+ { label: 'Official results', url: 'https://example.com/report.pdf' },
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].url).toBe('https://example.com/report.pdf');
+ expect(result[0].label).toBe('Evidence preview');
+ });
+
+ it('rejects malformed or non-http(s) evidence and falls back to a safe preview label', () => {
+ const invalid = normalizeDisputeEvidence(['javascript:alert(1)', 'ftp://example.com/file.txt', 'not-a-url']);
+
+ expect(invalid).toEqual([]);
+ expect(getEvidencePreviewLabel('ftp://example.com/file.txt')).toBe('Evidence preview');
+ });
+});
diff --git a/lib/dispute-evidence.ts b/lib/dispute-evidence.ts
new file mode 100644
index 00000000..0fc007d5
--- /dev/null
+++ b/lib/dispute-evidence.ts
@@ -0,0 +1,103 @@
+export interface DisputeEvidenceCandidate {
+ label?: string;
+ url: string;
+ isPrivate?: boolean;
+ preview?: string;
+}
+
+export type DisputeEvidenceInput =
+ | string
+ | DisputeEvidenceCandidate
+ | Array;
+
+export interface NormalizedDisputeEvidence {
+ id: string;
+ label: string;
+ url: string;
+ isPrivate: boolean;
+ isValid: boolean;
+ preview: string;
+}
+
+const MAX_EVIDENCE_URL_LENGTH = 2048;
+
+function isLocalhostHostname(hostname: string): boolean {
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
+}
+
+export function isSafeEvidenceUrl(value: string): boolean {
+ if (typeof value !== 'string') return false;
+
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > MAX_EVIDENCE_URL_LENGTH) return false;
+
+ try {
+ const parsed = new URL(trimmed);
+ const allowedProtocols = new Set(['https:', 'http:']);
+ const protocol = parsed.protocol.toLowerCase();
+
+ if (!allowedProtocols.has(protocol)) return false;
+ if (parsed.username || parsed.password) return false;
+ if (!parsed.hostname) return false;
+ if (protocol === 'http:' && !isLocalhostHostname(parsed.hostname)) {
+ return false;
+ }
+
+ const unsafeProtocols = ['javascript:', 'data:', 'file:', 'blob:'];
+ return !unsafeProtocols.some((unsafe) => trimmed.toLowerCase().startsWith(unsafe));
+ } catch {
+ return false;
+ }
+}
+
+export function getEvidencePreviewLabel(value?: string): string {
+ if (typeof value !== 'string' || !value.trim()) return 'Evidence preview';
+
+ if (!isSafeEvidenceUrl(value)) return 'Evidence preview';
+
+ return 'Evidence preview';
+}
+
+export function normalizeDisputeEvidence(
+ evidence?: DisputeEvidenceInput
+): NormalizedDisputeEvidence[] {
+ const entries = Array.isArray(evidence) ? evidence : evidence == null ? [] : [evidence];
+ if (!entries.length) {
+ return [];
+ }
+
+ const seen = new Set();
+
+ return entries.reduce((items, entry) => {
+ const candidate = typeof entry === 'string' ? { url: entry } : entry;
+
+ if (!candidate || typeof candidate.url !== 'string') {
+ return items;
+ }
+
+ const normalizedUrl = candidate.url.trim();
+ if (!isSafeEvidenceUrl(normalizedUrl)) {
+ return items;
+ }
+
+ const dedupeKey = normalizedUrl.toLowerCase();
+ if (seen.has(dedupeKey)) {
+ return items;
+ }
+ seen.add(dedupeKey);
+
+ const label = (candidate.label && candidate.label.trim()) || getEvidencePreviewLabel(normalizedUrl);
+ const preview = (candidate.preview && candidate.preview.trim()) || label;
+
+ items.push({
+ id: `${label}-${dedupeKey}`,
+ label,
+ url: normalizedUrl,
+ isPrivate: Boolean(candidate.isPrivate),
+ isValid: true,
+ preview,
+ });
+
+ return items;
+ }, []);
+}