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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Built with Next.js 15, React 19, TypeScript, and the Stellar Wallets Kit, Predic
## Features

- **Decentralized & Transparent**: All predictions are recorded on-chain with complete transparency. No central authority controls the outcomes.
- **Shareable Prediction Receipts**: Completed predictions can now be shared with a polished receipt summary for campaigns such as GrantFox FWC26.
- **Instant Payouts**: Smart contracts automatically distribute winnings immediately after event resolution. No waiting periods.
- **Multi-Wallet Support**: Connect with your preferred Stellar wallet (Freighter, LOBSTR, XBull, Albedo, Rabet).
- **Real-Time Markets**: Access live prediction markets with real-time updates on odds, stakes, and participant activity.
Expand Down
157 changes: 157 additions & 0 deletions app/components/ReceiptShare.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client"

import { useMemo, useState } from "react"
import { Check, Copy, Download, ExternalLink, Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"

interface ReceiptShareProps {
receiptId: string
marketTitle: string
outcome: string
amount: string
timestamp: string
campaign?: string
className?: string
}

function formatTimestamp(timestamp: string) {
const date = new Date(timestamp)

if (Number.isNaN(date.getTime())) {
return "Pending confirmation"
}

return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
}

export function ReceiptShare({
receiptId,
marketTitle,
outcome,
amount,
timestamp,
campaign = "GrantFox FWC26",
className,
}: ReceiptShareProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)

const shareUrl = useMemo(
() => `https://predictify.app/receipts/${receiptId}`,
[receiptId],
)

const handleCopy = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return
}

await navigator.clipboard.writeText(shareUrl)
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
}

const handleDownload = () => {
const payload = [
`Receipt: ${receiptId}`,
`Market: ${marketTitle}`,
`Outcome: ${outcome}`,
`Amount: ${amount}`,
`Confirmed: ${formatTimestamp(timestamp)}`,
`Campaign: ${campaign}`,
].join("\n")

const blob = new Blob([payload], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `receipt-${receiptId}.txt`
link.click()
URL.revokeObjectURL(url)
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={cn("gap-2", className)}>
<Share2 className="h-4 w-4" />
Share receipt
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Share your prediction receipt</DialogTitle>
<DialogDescription>
Share a polished receipt card for this completed prediction with your audience.
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
<div className="rounded-3xl border border-border/60 bg-gradient-to-br from-primary/10 via-background to-emerald-500/10 p-4 sm:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<div className="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-emerald-600 dark:text-emerald-400">
{campaign}
</div>
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.25em] text-muted-foreground">
Prediction confirmed
</p>
<h3 className="text-2xl font-semibold text-foreground">{marketTitle}</h3>
</div>
<div className="flex flex-wrap gap-3">
<div className="rounded-2xl border border-border/60 bg-background/80 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Outcome</p>
<p className="mt-1 text-lg font-semibold text-foreground">{outcome}</p>
</div>
<div className="rounded-2xl border border-border/60 bg-background/80 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Amount</p>
<p className="mt-1 text-lg font-semibold text-foreground">{amount}</p>
</div>
</div>
</div>

<div className="rounded-3xl border border-border/60 bg-background/80 p-5 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Receipt ID</p>
<p className="mt-2 text-lg font-semibold text-foreground">{receiptId}</p>
<p className="mt-4 text-sm text-muted-foreground">{formatTimestamp(timestamp)}</p>
</div>
</div>
</div>

<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={handleCopy} className="gap-2">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied ? "Copied" : "Copy Link"}
</Button>
<Button type="button" variant="outline" className="gap-2" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download summary
</Button>
<Button type="button" variant="ghost" className="gap-2" asChild>
<a href={shareUrl} target="_blank" rel="noreferrer noopener">
<ExternalLink className="h-4 w-4" />
Open link
</a>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
44 changes: 44 additions & 0 deletions app/components/__tests__/ReceiptShare.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { ReceiptShare } from "../ReceiptShare"

const defaultProps = {
receiptId: "RCP-2026-001",
marketTitle: "Will the GrantFox campaign hit 10k signups?",
outcome: "Yes",
amount: "12.50 XLM",
timestamp: "2026-07-23T17:30:00.000Z",
campaign: "GrantFox FWC26",
}

describe("ReceiptShare", () => {
it("opens the share preview and renders the receipt details", async () => {
render(<ReceiptShare {...defaultProps} />)

fireEvent.click(screen.getByRole("button", { name: /share receipt/i }))

expect(await screen.findByRole("dialog")).toBeInTheDocument()
expect(screen.getByText(defaultProps.marketTitle)).toBeInTheDocument()
expect(screen.getByText(defaultProps.outcome)).toBeInTheDocument()
expect(screen.getByText(defaultProps.amount)).toBeInTheDocument()
expect(screen.getByText(defaultProps.receiptId)).toBeInTheDocument()
})

it("copies the share link when the copy action is selected", async () => {
const writeTextMock = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
writable: true,
})

render(<ReceiptShare {...defaultProps} />)
fireEvent.click(screen.getByRole("button", { name: /share receipt/i }))

fireEvent.click(await screen.findByRole("button", { name: /copy link/i }))

await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining("predictify.app/receipts/"))
})
})
})
19 changes: 17 additions & 2 deletions app/design/share-cards/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import MarketShareCard from "@/components/market/MarketShareCard";
import { ReceiptShare } from "@/app/components/ReceiptShare";

export default function ShareCardsPreview() {
return (
<div className="min-h-screen bg-black p-12 space-y-24 flex flex-col items-center">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-white">Market Share Cards</h1>
<p className="text-xl text-gray-400">Social preview layouts (1200x630)</p>
<h1 className="text-4xl font-bold text-white">Share Cards</h1>
<p className="text-xl text-gray-400">Social preview layouts and shareable prediction receipts (1200x630)</p>
</div>

<section className="w-full max-w-4xl rounded-3xl border border-white/10 bg-white/5 p-8 backdrop-blur">
<h2 className="text-2xl font-semibold text-white/80 border-l-4 border-emerald-500 pl-4">Prediction Receipt Share Card</h2>
<div className="mt-8 flex justify-center">
<ReceiptShare
receiptId="RCP-GRANTFOX-001"
marketTitle="Will the GrantFox campaign hit 10k signups?"
outcome="Yes"
amount="12.50 XLM"
timestamp="2026-07-23T17:30:00.000Z"
campaign="GrantFox FWC26"
/>
</div>
</section>

<section className="space-y-8">
<h2 className="text-2xl font-semibold text-white/80 border-l-4 border-primary pl-4">Active Market</h2>
<div className="scale-[0.6] origin-top border-4 border-white/5 rounded-2xl overflow-hidden shadow-2xl">
Expand Down
10 changes: 10 additions & 0 deletions components/receipts/Receipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react';
import { CheckCircle2, Download, ArrowLeft } from 'lucide-react';
import { ReceiptShare } from '@/app/components/ReceiptShare';

interface ReceiptProps {
receiptId: string;
Expand Down Expand Up @@ -129,6 +130,15 @@ export function Receipt({ receiptId, amount, partyA, partyB, timestamp, type }:
<Download className="w-4 h-4" />
Download Receipt
</button>
<ReceiptShare
receiptId={receiptId}
marketTitle={partyA}
outcome={partyB ? `${partyA} vs ${partyB}` : partyA}
amount={amount}
timestamp={timestamp}
campaign="GrantFox FWC26"
className="w-full justify-center"
/>
<button
className="w-full flex items-center justify-center gap-2 bg-transparent text-muted-foreground py-3.5 px-4 rounded-xl font-semibold hover:bg-muted/50 transition-colors"
onClick={() => window.history.back()}
Expand Down