diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2ee959 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# API Configuration +NEXT_PUBLIC_API_URL=http://localhost:3001/api +NEXT_PUBLIC_WS_URL=ws://localhost:3001 + +# Blockchain Configuration +NEXT_PUBLIC_OPTIMISM_RPC_URL=https://mainnet.optimism.io +NEXT_PUBLIC_CHAIN_ID=10 +NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS=0x... + +# IPFS Configuration +NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.io/ipfs/ + +# Worldcoin Configuration +NEXT_PUBLIC_WORLDCOIN_APP_ID=app_... +NEXT_PUBLIC_WORLDCOIN_ACTION=verify + +# Analytics (Optional) +NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX diff --git a/app/(dashboard)/history/page.tsx b/app/(dashboard)/history/page.tsx new file mode 100644 index 0000000..96905c6 --- /dev/null +++ b/app/(dashboard)/history/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { ClaimHistoryList } from '@/components/features/ClaimHistoryList'; +import { useAuth } from '@/hooks/useAuth'; + +export default function ClaimHistoryPage() { + const { user } = useAuth(); + + if (!user?.address) { + return ( +
+
+
+

Claim History

+

Please connect your wallet to view your claim history.

+
+
+
+ ); + } + + return ( +
+
+
+

Claim History

+

View all claims you've submitted and their current status.

+
+ + +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c1b2bf2 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,73 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 84% 4.9%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 94.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8fb9fbf --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,23 @@ +import { AuthProvider } from '@/hooks/useAuth'; +import './globals.css'; + +export const metadata = { + title: 'TruthBounty - Decentralized News Verification', + description: 'Community-driven fact-checking across Ethereum and Stellar ecosystems', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/components/features/ClaimCard.tsx b/components/features/ClaimCard.tsx new file mode 100644 index 0000000..f4b82f7 --- /dev/null +++ b/components/features/ClaimCard.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link'; +import { Claim } from '@/types/claim'; +import { ClaimStatusBadge } from './ClaimStatusBadge'; + +interface ClaimCardProps { + claim: Claim; +} + +export function ClaimCard({ claim }: ClaimCardProps) { + return ( +
+
+

+ + {claim.title} + +

+ +
+ +

+ {claim.description} +

+ +
+
+ Verifications: {claim.verificationCount} + Disputes: {claim.disputeCount} +
+ + {new Date(claim.createdAt).toLocaleDateString()} + +
+
+ ); +} diff --git a/components/features/ClaimHistoryList.tsx b/components/features/ClaimHistoryList.tsx new file mode 100644 index 0000000..4788471 --- /dev/null +++ b/components/features/ClaimHistoryList.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState } from 'react'; +import { useUserClaims, useUserClaimsInfinite } from '@/lib/api/claims'; +import { ClaimCard } from './ClaimCard'; +import { Button } from '@/components/ui/Button'; + +interface ClaimHistoryListProps { + userAddress: string; + useInfiniteScroll?: boolean; +} + +export function ClaimHistoryList({ userAddress, useInfiniteScroll = true }: ClaimHistoryListProps) { + const [page, setPage] = useState(1); + + const { + data: paginatedData, + isLoading: isLoadingPaginated, + error: errorPaginated, + } = useUserClaims(userAddress, page, 10); + + const { + data: infiniteData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: isLoadingInfinite, + error: errorInfinite, + } = useUserClaimsInfinite(userAddress, 10); + + const isLoading = useInfiniteScroll ? isLoadingInfinite : isLoadingPaginated; + const error = useInfiniteScroll ? errorInfinite : errorPaginated; + + const claims = useInfiniteScroll + ? infiniteData?.pages.flatMap(page => page.claims) || [] + : paginatedData?.claims || []; + + const totalPages = paginatedData ? Math.ceil(paginatedData.total / paginatedData.pageSize) : 0; + + if (isLoading && claims.length === 0) { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+

Failed to load claims. Please try again.

+
+ ); + } + + if (claims.length === 0) { + return ( +
+

No claims found for this user.

+
+ ); + } + + return ( +
+ {claims.map((claim) => ( + + ))} + + {useInfiniteScroll ? ( + hasNextPage && ( +
+ +
+ ) + ) : ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/components/features/ClaimStatusBadge.tsx b/components/features/ClaimStatusBadge.tsx new file mode 100644 index 0000000..a6fcb59 --- /dev/null +++ b/components/features/ClaimStatusBadge.tsx @@ -0,0 +1,35 @@ +import { Badge } from '@/components/ui/Badge'; +import { ClaimStatus } from '@/types/claim'; + +interface ClaimStatusBadgeProps { + status: ClaimStatus; +} + +const statusConfig = { + pending: { + label: 'Pending', + variant: 'warning' as const, + }, + verified: { + label: 'Verified', + variant: 'success' as const, + }, + disputed: { + label: 'Disputed', + variant: 'destructive' as const, + }, + resolved: { + label: 'Resolved', + variant: 'default' as const, + }, +}; + +export function ClaimStatusBadge({ status }: ClaimStatusBadgeProps) { + const config = statusConfig[status]; + + return ( + + {config.label} + + ); +} diff --git a/components/ui/Badge.tsx b/components/ui/Badge.tsx new file mode 100644 index 0000000..bfe280f --- /dev/null +++ b/components/ui/Badge.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'; + className?: string; +} + +export function Badge({ children, variant = 'default', className }: BadgeProps) { + const baseStyles = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'; + + const variants = { + default: 'bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground border border-input bg-background hover:bg-accent hover:text-accent-foreground', + success: 'bg-green-100 text-green-800 hover:bg-green-200', + warning: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200', + }; + + return ( +
+ {children} +
+ ); +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx new file mode 100644 index 0000000..0eefc32 --- /dev/null +++ b/components/ui/Button.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/utils'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; +} + +export function Button({ className, variant = 'default', size = 'default', ...props }: ButtonProps) { + const baseStyles = 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'; + + const variants = { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }; + + const sizes = { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }; + + return ( +