Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/frontend/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";

export { Button, buttonVariants };
export { Button, buttonVariants };
2 changes: 1 addition & 1 deletion packages/frontend/components/ui/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
);
CardFooter.displayName = "CardFooter";

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
1 change: 1 addition & 0 deletions packages/frontend/hooks/useComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "@/src/hooks/useComments";
3 changes: 3 additions & 0 deletions packages/frontend/lib/chain-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"use client";

export * from "@/src/lib/chain-context";
1 change: 1 addition & 0 deletions packages/frontend/lib/chart-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "@/src/lib/chart-utils";
1 change: 1 addition & 0 deletions packages/frontend/lib/verify-eip712.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "@/src/lib/verify-eip712";
45 changes: 45 additions & 0 deletions packages/frontend/src/components/ChainSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { cn } from "@/lib/utils";
import { useChain, type ChainType } from "@/lib/chain-context";

interface ChainSelectorProps {
className?: string;
}

export function ChainSelector({ className }: ChainSelectorProps) {
const { chain, setChain } = useChain();

const chains: Array<{ value: ChainType; label: string; icon: string }> = [
{ value: "base", label: "Base", icon: "🔵" },
{ value: "stellar", label: "Stellar", icon: "⭐" },
];

return (
<div
className={cn("flex gap-1 p-1 rounded-xl bg-secondary/50 border border-border", className)}
data-testid="chain-selector"
role="radiogroup"
aria-label="Select blockchain"
>
{chains.map((c) => (
<button
key={c.value}
onClick={() => setChain(c.value)}
role="radio"
aria-checked={chain === c.value}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
chain === c.value
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
data-testid={`chain-option-${c.value}`}
>
<span>{c.icon}</span>
<span className="hidden sm:inline">{c.label}</span>
</button>
))}
</div>
);
}
187 changes: 187 additions & 0 deletions packages/frontend/src/components/Comments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"use client";

import { useState } from "react";
import { MessageSquare, Reply, Edit3, Trash2, Send } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { useComments, type Comment } from "@/hooks/useComments";

interface CommentsProps {
callId: string;
}

export function Comments({ callId }: CommentsProps) {
const { comments, addComment, editComment, deleteComment, MAX_DEPTH, MAX_CHARS } = useComments(callId);
const [newComment, setNewComment] = useState("");

const handleSubmit = () => {
if (!newComment.trim()) return;
addComment(newComment.trim());
setNewComment("");
};

return (
<div className="space-y-4" data-testid="comments-section">
<h3 className="font-bold flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
Discussion
</h3>

{/* New Comment Input */}
<div className="flex gap-2">
<textarea
placeholder="Share your thoughts..."
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, MAX_CHARS))}
className="flex-1 bg-secondary/50 border border-border rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary/50 min-h-[80px] resize-none text-sm"
data-testid="new-comment-input"
/>
<div className="flex flex-col gap-2">
<Button onClick={handleSubmit} disabled={!newComment.trim()} size="sm">
<Send className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground text-center">{newComment.length}/{MAX_CHARS}</span>
</div>
</div>

{/* Comments List */}
<div className="space-y-3">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
maxDepth={MAX_DEPTH}
onReply={(content, parentId) => addComment(content, parentId)}
onEdit={editComment}
onDelete={deleteComment}
/>
))}
</div>
</div>
);
}

interface CommentItemProps {
comment: Comment;
maxDepth: number;
onReply: (content: string, parentId: string) => void;
onEdit: (id: string, content: string) => void;
onDelete: (id: string) => void;
}

function CommentItem({ comment, maxDepth, onReply, onEdit, onDelete }: CommentItemProps) {
const [isReplying, setIsReplying] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [replyContent, setReplyContent] = useState("");
const [editContent, setEditContent] = useState(comment.content);

const handleReply = () => {
if (!replyContent.trim()) return;
onReply(replyContent.trim(), comment.id);
setReplyContent("");
setIsReplying(false);
};

const handleEdit = () => {
if (!editContent.trim()) return;
onEdit(comment.id, editContent.trim());
setIsEditing(false);
};

const relativeTime = (dateStr: string) => {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};

return (
<div
className={`${comment.depth > 0 ? "ml-4 pl-4 border-l-2 border-border" : ""}`}
data-testid={`comment-${comment.id}`}
>
<div className="bg-secondary/30 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-[10px] font-bold text-primary">
{comment.authorName[0]}
</div>
<span className="font-medium">{comment.authorName}</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>

{isEditing ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full bg-secondary/50 border border-border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
rows={2}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancel</Button>
</div>
</div>
) : (
<p className="text-sm">{comment.content}</p>
)}

<div className="flex items-center gap-2">
{comment.depth < maxDepth && (
<button
onClick={() => setIsReplying(!isReplying)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Reply className="h-3 w-3" /> Reply
</button>
)}
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" /> Edit
</button>
<button
onClick={() => onDelete(comment.id)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-red-500"
>
<Trash2 className="h-3 w-3" /> Delete
</button>
</div>

{isReplying && (
<div className="flex gap-2 mt-2">
<input
type="text"
placeholder="Write a reply..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
className="flex-1 bg-secondary/50 border border-border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<Button size="sm" onClick={handleReply} disabled={!replyContent.trim()}>
<Send className="h-3 w-3" />
</Button>
</div>
)}
</div>

{/* Render Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="space-y-2 mt-2">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
maxDepth={maxDepth}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
}
103 changes: 103 additions & 0 deletions packages/frontend/src/components/EvidencePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { ExternalLink, Shield, ShieldCheck, FileText, Link as LinkIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { type ProvenanceData, formatPrice, getExplorerUrl, getIPFSUrl } from "@/lib/verify-eip712";

interface EvidencePanelProps {
provenance: ProvenanceData;
}

export function EvidencePanel({ provenance }: EvidencePanelProps) {
return (
<Card data-testid="evidence-panel">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
Outcome Provenance
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Final Price & Outcome */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-secondary/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">Final Price</p>
<p className="text-lg font-bold">{formatPrice(provenance.finalPrice)}</p>
</div>
<div className="bg-secondary/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">Outcome</p>
<p className="text-lg font-bold">{provenance.outcome}</p>
</div>
</div>

{/* Oracle Address */}
<div className="flex items-center justify-between bg-secondary/50 rounded-lg p-3">
<div>
<p className="text-xs text-muted-foreground mb-1">Oracle Address</p>
<p className="font-mono text-sm break-all">{provenance.oracleAddress}</p>
</div>
<a
href={getExplorerUrl(provenance.chain, provenance.oracleAddress)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline shrink-0 ml-2"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>

{/* Signature Info */}
{provenance.chain === "base" && provenance.eip712Digest && (
<div className="bg-secondary/50 rounded-lg p-3 space-y-2">
<p className="text-xs text-muted-foreground">EIP-712 Digest</p>
<div className="text-xs font-mono space-y-1">
<p>Domain: {provenance.eip712Digest.domain.name} v{provenance.eip712Digest.domain.version}</p>
<p>Chain ID: {provenance.eip712Digest.domain.chainId}</p>
<p className="break-all">Contract: {provenance.eip712Digest.domain.verifyingContract}</p>
</div>
</div>
)}

{provenance.chain === "stellar" && provenance.stellarEvidence && (
<div className="bg-secondary/50 rounded-lg p-3 space-y-2">
<p className="text-xs text-muted-foreground">Ed25519 Signature</p>
<div className="text-xs font-mono space-y-1">
<p className="break-all">Pubkey: {provenance.stellarEvidence.pubkey}</p>
<p className="break-all">Signature: {provenance.stellarEvidence.ed25519Signature.slice(0, 40)}...</p>
</div>
</div>
)}

{/* IPFS Evidence */}
{provenance.evidenceCID && (
<a
href={getIPFSUrl(provenance.evidenceCID)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<FileText className="h-4 w-4" />
View Evidence on IPFS
<span className="text-xs text-muted-foreground">({provenance.evidenceCID.slice(0, 12)}...)</span>
</a>
)}

{/* Verification Badge */}
<div className="flex items-center gap-2 pt-2 border-t border-border">
{provenance.verified ? (
<>
<Badge tone="green">
<ShieldCheck className="h-3 w-3 mr-1" />
Verified
</Badge>
<span className="text-xs text-muted-foreground">Signature verified on-chain</span>
</>
) : (
<Badge tone="yellow">Unverified</Badge>
)}
</div>
</CardContent>
</Card>
);
}
Loading
Loading