A Prediction Market on the AT Protocol

feat(web): more mpv ui, add buy buttons

Ciaran 7591382a 7610a131

+185 -48
+6
bun.lock
··· 27 27 "drizzle-orm": "^0.45.1", 28 28 "elysia": "^1.4.27", 29 29 "lucide-react": "^0.577.0", 30 + "next-themes": "^0.4.6", 30 31 "pg": "^8.19.0", 31 32 "radix-ui": "^1.4.3", 32 33 "recharts": "2.15.4", 34 + "sonner": "^2.0.7", 33 35 "tailwind-merge": "^3.5.0", 34 36 "tailwindcss": "^4.2.1", 35 37 "usehooks-ts": "^3.1.1", ··· 1132 1134 1133 1135 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], 1134 1136 1137 + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], 1138 + 1135 1139 "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 1136 1140 1137 1141 "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], ··· 1323 1327 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 1324 1328 1325 1329 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], 1330 + 1331 + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], 1326 1332 1327 1333 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1328 1334
+2
package.json
··· 64 64 "drizzle-orm": "^0.45.1", 65 65 "elysia": "^1.4.27", 66 66 "lucide-react": "^0.577.0", 67 + "next-themes": "^0.4.6", 67 68 "pg": "^8.19.0", 68 69 "radix-ui": "^1.4.3", 69 70 "recharts": "2.15.4", 71 + "sonner": "^2.0.7", 70 72 "tailwind-merge": "^3.5.0", 71 73 "tailwindcss": "^4.2.1", 72 74 "usehooks-ts": "^3.1.1"
+63 -12
src/web/app.tsx
··· 4 4 import { LineChart, Line, Tooltip } from "recharts"; 5 5 import { ChartContainer } from "./components/ui/chart"; 6 6 import { noPrice, yesPrice } from "./lib/lmsr"; 7 + import { Button } from "./components/ui/button"; 8 + import type { Market } from "./providers/cumulus-provider"; 9 + import { useState } from "react"; 10 + import { createBet } from "@/core"; 11 + import { useAuth } from "./providers/useAuth"; 12 + import type { ResourceUri } from "@atcute/lexicons"; 13 + import { toast } from "sonner"; 14 + 15 + function parseMarket(market: Market) { 16 + let [yes, no] = [0, 0]; 17 + 18 + const mappedBets = market.bets 19 + ?.sort((a, b) => a.createdAt > b.createdAt ? 1 : 0) 20 + .map(bet => { 21 + bet.position === "yes" ? yes++ : no++; 22 + return { ...bet, yes, no, } 23 + }) 24 + 25 + const yesprice = yesPrice(yes, no, market.liquidity) 26 + const noprice = noPrice(yes, no, market.liquidity) 27 + const positions = market.bets?.length ?? 0; 28 + const closesAt = formatDistance(new Date(market.closesAt), new Date(), { addSuffix: true }) 29 + 30 + return { 31 + yes, no, mappedBets, yesprice, noprice, positions, closesAt 32 + } 33 + } 7 34 8 35 export default function App() { 36 + const { profile, client } = useAuth(); 9 37 const { markets } = useCumulus(); 10 38 39 + const [loading, setLoading] = useState<string | boolean>(false); 40 + 11 41 if (markets.isLoading) return <Spinner className='m-auto' /> 12 42 13 43 return <div className="grid md:grid-cols-2 gap-2"> 14 44 {markets.data?.map(market => { 15 - let [yes, no] = [0, 0]; 16 - let mappedBets = market.bets 17 - ?.sort((a, b) => a.createdAt > b.createdAt ? 1 : 0) 18 - .map(bet => { 19 - if (bet.position === "yes") yes++; 20 - if (bet.position === "no") no++; 21 - return { ...bet, yes, no, } 22 - }) 45 + 46 + const { yesprice, noprice, closesAt, mappedBets, positions } = parseMarket(market) 47 + 48 + async function handleBuy(position: "yes" | "no") { 49 + setLoading(market.cid) 50 + try { 51 + const res = await createBet({ 52 + uri: market.uri as ResourceUri, 53 + cid: market.cid, 54 + }, position, profile.did, client) 55 + if (res.uri) { 56 + toast(`Placed "${position.toUpperCase()}" bet (${res.uri}) at market ${market.uri}`); 57 + toast(<div> 58 + <p>Placed bet: <a href={`https://pdsls.dev/${res.uri}`}>{position.toUpperCase()}</a></p> 59 + <p>At market: <a href={`https://pdsls.dev/${market.uri}`}>{market.rkey}</a></p> 60 + </div>) 61 + } 62 + } catch (e) { 63 + toast(e as any) 64 + } 65 + setLoading(false); 66 + } 67 + 23 68 return <div key={market.cid} className="relative uppercase bg-radial-[at_80%_200%] from-coral-500 via-coral-50"> 69 + 24 70 <div className="absolute inset-0 p-2"> 25 71 <h2 className="text-xl font-bold flex gap-1 items-center">{market.question}</h2> 26 - <p>Closes: {formatDistance(new Date(market.closesAt), new Date(), { addSuffix: true })}</p> 27 - <p>Positions: {market.bets?.length}</p> 28 - <p>Yes Price: {yesPrice(yes, no, market.liquidity)}</p> 29 - <p>No Price: {noPrice(yes, no, market.liquidity)}</p> 72 + <p>Closes: {closesAt}</p> 73 + <p>Positions: {positions}</p> 30 74 </div> 75 + 31 76 <ChartContainer 32 77 config={{ yes: { label: "Yes" }, no: { label: "No" } }}> 33 78 <LineChart data={mappedBets}> ··· 36 81 <Line dataKey="no" stroke="var(--color-coral-600)" /> 37 82 </LineChart> 38 83 </ChartContainer> 84 + 85 + <div className="absolute bottom-0 right-0 p-2 flex gap-2"> 86 + <Button onClick={() => handleBuy("yes")} disabled={loading === market.cid}>YES {yesprice}</Button> 87 + <Button onClick={() => handleBuy("no")} variant="secondary" disabled={loading === market.cid}>NO {noprice}</Button> 88 + </div> 89 + 39 90 </div> 40 91 })} 41 92 </div>
+38
src/web/components/ui/sonner.tsx
··· 1 + import { 2 + CircleCheckIcon, 3 + InfoIcon, 4 + Loader2Icon, 5 + OctagonXIcon, 6 + TriangleAlertIcon, 7 + } from "lucide-react" 8 + import { useTheme } from "next-themes" 9 + import { Toaster as Sonner, type ToasterProps } from "sonner" 10 + 11 + const Toaster = ({ ...props }: ToasterProps) => { 12 + const { theme = "system" } = useTheme() 13 + 14 + return ( 15 + <Sonner 16 + theme={theme as ToasterProps["theme"]} 17 + className="toaster group" 18 + icons={{ 19 + success: <CircleCheckIcon className="size-4" />, 20 + info: <InfoIcon className="size-4" />, 21 + warning: <TriangleAlertIcon className="size-4" />, 22 + error: <OctagonXIcon className="size-4" />, 23 + loading: <Loader2Icon className="size-4 animate-spin" />, 24 + }} 25 + style={ 26 + { 27 + "--normal-bg": "var(--popover)", 28 + "--normal-text": "var(--popover-foreground)", 29 + "--normal-border": "var(--border)", 30 + "--border-radius": "var(--radius)", 31 + } as React.CSSProperties 32 + } 33 + {...props} 34 + /> 35 + ) 36 + } 37 + 38 + export { Toaster }
+72 -35
src/web/index.css
··· 82 82 83 83 :root { 84 84 --radius: 0.625rem; 85 - --background: oklch(0.97 0.01 240); /* shell-50 #F5F7F9 */ 86 - --foreground: oklch(0.17 0.01 240); /* shell-900 #101C26 */ 87 - --card: oklch(0.95 0.01 240); /* shell-100 #EBEFF2 */ 85 + --background: oklch(0.97 0.01 240); 86 + /* shell-50 #F5F7F9 */ 87 + --foreground: oklch(0.17 0.01 240); 88 + /* shell-900 #101C26 */ 89 + --card: oklch(0.95 0.01 240); 90 + /* shell-100 #EBEFF2 */ 88 91 --card-foreground: oklch(0.17 0.01 240); 89 - --popover: oklch(0.95 0.01 240); /* shell-100 */ 92 + --popover: oklch(0.95 0.01 240); 93 + /* shell-100 */ 90 94 --popover-foreground: oklch(0.17 0.01 240); 91 - --primary: oklch(0.65 0.18 350); /* coral-500 #F67280 */ 95 + --primary: oklch(0.65 0.18 350); 96 + /* coral-500 #F67280 */ 92 97 --primary-foreground: oklch(0.97 0.01 240); 93 - --secondary: oklch(0.96 0.02 350); /* lipstick-100 #F9F0F3 */ 94 - --secondary-foreground: oklch(0.42 0.12 350); /* lipstick-700 #73414F */ 95 - --muted: oklch(0.95 0.01 240); /* shell-100 */ 96 - --muted-foreground: oklch(0.45 0.12 230); /* shell-500 #355C7D */ 97 - --accent: oklch(0.92 0.04 350); /* lipstick-200 #EFDAE0 */ 98 - --accent-foreground: oklch(0.25 0.06 350); /* lipstick-800 #56313B */ 99 - --destructive: oklch(0.55 0.15 355); /* coral-600 #DD6773 */ 100 - --border: oklch(0.88 0.03 230); /* shell-200 #CDD6DF */ 98 + --secondary: oklch(0.96 0.02 350); 99 + /* lipstick-100 #F9F0F3 */ 100 + --secondary-foreground: oklch(0.42 0.12 350); 101 + /* lipstick-700 #73414F */ 102 + --muted: oklch(0.95 0.01 240); 103 + /* shell-100 */ 104 + --muted-foreground: oklch(0.45 0.12 230); 105 + /* shell-500 #355C7D */ 106 + --accent: oklch(0.92 0.04 350); 107 + /* lipstick-200 #EFDAE0 */ 108 + --accent-foreground: oklch(0.25 0.06 350); 109 + /* lipstick-800 #56313B */ 110 + --destructive: oklch(0.55 0.15 355); 111 + /* coral-600 #DD6773 */ 112 + --border: oklch(0.88 0.03 230); 113 + /* shell-200 #CDD6DF */ 101 114 --input: oklch(0.88 0.03 230); 102 - --ring: oklch(0.72 0.18 350); /* coral-400 #F99CA6 */ 103 - --chart-1: oklch(0.65 0.18 350); /* coral */ 104 - --chart-2: oklch(0.55 0.15 350); /* lipstick */ 105 - --chart-3: oklch(0.45 0.12 230); /* shell */ 106 - --chart-4: oklch(0.55 0.12 230); /* shell */ 107 - --chart-5: oklch(0.7 0.14 350); /* coral */ 115 + --ring: oklch(0.72 0.18 350); 116 + /* coral-400 #F99CA6 */ 117 + --chart-1: oklch(0.65 0.18 350); 118 + /* coral */ 119 + --chart-2: oklch(0.55 0.15 350); 120 + /* lipstick */ 121 + --chart-3: oklch(0.45 0.12 230); 122 + /* shell */ 123 + --chart-4: oklch(0.55 0.12 230); 124 + /* shell */ 125 + --chart-5: oklch(0.7 0.14 350); 126 + /* coral */ 108 127 --sidebar: oklch(0.95 0.01 240); 109 128 --sidebar-foreground: oklch(0.17 0.01 240); 110 129 --sidebar-primary: oklch(0.65 0.18 350); ··· 116 135 } 117 136 118 137 .dark { 119 - --background: oklch(0.17 0.01 240); /* shell-900 #101C26 */ 120 - --foreground: oklch(0.97 0.01 240); /* shell-50 #F5F7F9 */ 121 - --card: oklch(0.22 0.02 240); /* shell-800 #182938 */ 138 + --background: oklch(0.17 0.01 240); 139 + /* shell-900 #101C26 */ 140 + --foreground: oklch(0.97 0.01 240); 141 + /* shell-50 #F5F7F9 */ 142 + --card: oklch(0.22 0.02 240); 143 + /* shell-800 #182938 */ 122 144 --card-foreground: oklch(0.97 0.01 240); 123 - --popover: oklch(0.22 0.02 240); /* shell-800 */ 145 + --popover: oklch(0.22 0.02 240); 146 + /* shell-800 */ 124 147 --popover-foreground: oklch(0.97 0.01 240); 125 - --primary: oklch(0.72 0.18 350); /* coral-400 #F99CA6 */ 148 + --primary: oklch(0.72 0.18 350); 149 + /* coral-400 #F99CA6 */ 126 150 --primary-foreground: oklch(0.17 0.01 240); 127 - --secondary: oklch(0.27 0.03 240); /* shell-700 #20374B */ 151 + --secondary: oklch(0.27 0.03 240); 152 + /* shell-700 #20374B */ 128 153 --secondary-foreground: oklch(0.95 0.01 240); 129 154 --muted: oklch(0.22 0.02 240); 130 - --muted-foreground: oklch(0.55 0.1 230); /* shell-400 #728DA4 */ 131 - --accent: oklch(0.42 0.12 350); /* lipstick-700 #73414F */ 132 - --accent-foreground: oklch(0.96 0.02 350); /* lipstick-100 */ 133 - --destructive: oklch(0.65 0.18 350); /* coral-500 #F67280 */ 134 - --border: oklch(0.27 0.03 240); /* shell-700 */ 155 + --muted-foreground: oklch(0.55 0.1 230); 156 + /* shell-400 #728DA4 */ 157 + --accent: oklch(0.42 0.12 350); 158 + /* lipstick-700 #73414F */ 159 + --accent-foreground: oklch(0.96 0.02 350); 160 + /* lipstick-100 */ 161 + --destructive: oklch(0.65 0.18 350); 162 + /* coral-500 #F67280 */ 163 + --border: oklch(0.27 0.03 240); 164 + /* shell-700 */ 135 165 --input: oklch(0.27 0.03 240); 136 - --ring: oklch(0.65 0.18 350); /* coral-500 */ 166 + --ring: oklch(0.65 0.18 350); 167 + /* coral-500 */ 137 168 --chart-1: oklch(0.72 0.18 350); 138 169 --chart-2: oklch(0.42 0.12 350); 139 170 --chart-3: oklch(0.27 0.03 240); ··· 150 181 } 151 182 152 183 @layer base { 153 - * { 154 - @apply border-border outline-ring/50; 184 + * { 185 + @apply border-border outline-ring/50; 186 + } 187 + 188 + body { 189 + @apply bg-background text-foreground; 155 190 } 156 - body { 157 - @apply bg-background text-foreground; 191 + 192 + a { 193 + @apply border-b border-coral-500/50; 158 194 } 195 + 159 196 }
+2
src/web/main.tsx
··· 5 5 import './index.css' 6 6 import App from './app.tsx' 7 7 import Cumulus from './providers/cumulus-provider.tsx'; 8 + import { Toaster } from './components/ui/sonner.tsx'; 8 9 9 10 const queryClient = new QueryClient(); 10 11 ··· 17 18 </Cumulus> 18 19 </Auth> 19 20 </QueryClientProvider> 21 + <Toaster /> 20 22 </StrictMode> 21 23 )
+2 -1
src/web/providers/cumulus-provider.tsx
··· 30 30 const { data, error } = await server.api.markets.get() 31 31 if (error) throw error; 32 32 return data as unknown as Market[]; 33 - } 33 + }, 34 + refetchInterval: 30 * 1000, 34 35 }); 35 36 36 37 return <CumulusContext.Provider value={{ markets }}>{children}</ CumulusContext.Provider>