Allow users to create new contracts (#4)

* Add Firestore package and config

* Upload basic Firebase Auth code

* Basic ability to sign in and view profile

* Move html head content to Next's _document

* Apply dark theme to all DaisyUI components

* Add contract page

* Smaller width bet input

* Allow users to create new contracts

* Add back listenForContract

* Add some buttons

* Add Row, Col, and Spacer components

* Implement skeleton ContractPage

* Apply dark theme to all DaisyUI components

* Fix hooks lints (#3)

* Add background to bet panel

* Sort contracts by creation time

* Link to market creation from header

* List your markets on account page

* Set fullscreen black background

* Correctly set seeds on new contracts

* Code cleanups

* Gratuitously cool font

* Add creator name, fix ordering

* Use Readex Pro as body font

* Fixes according to code review

Co-authored-by: jahooma <jahooma@gmail.com>
This commit is contained in:
Austin Chen 2021-12-10 09:54:16 -08:00 committed by GitHub
parent f602561323
commit c9229ca2b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 396 additions and 119 deletions

View File

@ -47,9 +47,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
<Spacer h={4} />
<div className="p-2 font-medium">Average price</div>
<div className="px-2">
{betChoice === 'YES' ? 0.57 : 0.43} points
</div>
<div className="px-2">{betChoice === 'YES' ? 0.57 : 0.43} points</div>
<Spacer h={2} />

32
web/components/button.tsx Normal file
View File

@ -0,0 +1,32 @@
export function Button(props: {
className?: string
onClick?: () => void
color: 'green' | 'red' | 'deemphasized'
hideFocusRing?: boolean
children?: any
}) {
const { className, onClick, children, color, hideFocusRing } = props
return (
<button
type="button"
className={classNames(
'inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white',
!hideFocusRing && 'focus:outline-none focus:ring-2 focus:ring-offset-2',
color === 'green' &&
'bg-green-500 hover:bg-green-600 focus:ring-green-500',
color === 'red' && 'bg-red-500 hover:bg-red-600 focus:ring-red-500',
color === 'deemphasized' &&
'bg-transparent hover:bg-gray-500 focus:ring-gray-400',
className
)}
onClick={onClick}
>
{children}
</button>
)
}
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(' ')
}

View File

@ -0,0 +1,63 @@
import Link from 'next/link'
import { Contract, deleteContract } from '../lib/firebase/contracts'
function ContractCard(props: { contract: Contract }) {
const { contract } = props
return (
<li>
<Link href={`/contract/${contract.id}`}>
<a className="block hover:bg-gray-600">
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-indigo-300 truncate">
{contract.question}
</p>
<div className="ml-2 flex-shrink-0 flex">
<p className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{contract.outcomeType}
</p>
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<p className="flex items-center text-sm">{contract.id}</p>
<p className="mt-2 flex items-center text-sm sm:mt-0 sm:ml-6">
{contract.description}
</p>
</div>
<div className="mt-2 flex items-center text-sm sm:mt-0">
<p>
Created on{' '}
<time dateTime={`${contract.createdTime}`}>
{new Date(contract.createdTime).toLocaleString()}
</time>
</p>
<button
className="btn btn-sm btn-error ml-2"
onClick={() => {
deleteContract(contract.id)
}}
>
Delete
</button>
</div>
</div>
</div>
</a>
</Link>
</li>
)
}
export function ContractsList(props: { contracts: Contract[] }) {
const { contracts } = props
return (
<div className="bg-gray-500 shadow overflow-hidden sm:rounded-md max-w-4xl w-full">
<ul role="list" className="divide-y divide-gray-200">
{contracts.map((contract) => (
<ContractCard contract={contract} key={contract.id} />
))}
</ul>
</div>
)
}

View File

@ -9,8 +9,8 @@ const convertkitHTML = {
<div data-style="clean">
<ul class="formkit-alert formkit-alert-error" data-element="errors" data-group="alert"></ul>
<div data-element="fields" data-stacked="false" class="seva-fields formkit-fields">
<div class="formkit-field" style="max-width:400px;margin-right:12px;"><input class="formkit-input" name="email_address" aria-label="Email Address" placeholder="Email Address" required="" type="email" style="color: rgb(0, 0, 0); border-color: rgb(227, 227, 227); border-radius: 4px; font-weight: 400;"></div>
<button data-element="submit" class="formkit-submit formkit-submit" style="color: rgb(255, 255, 255); background-color: rgb(17, 185, 129); border-radius: 4px; font-weight: 400; max-width: 175px">
<div class="formkit-field" style="max-width:350px;margin-right:12px;"><input class="formkit-input" name="email_address" aria-label="Email Address" placeholder="Email Address" required="" type="email" style="color: rgb(0, 0, 0); border-color: rgb(227, 227, 227); border-radius: 4px; font-weight: 400;"></div>
<button data-element="submit" class="formkit-submit formkit-submit" style="color: rgb(255, 255, 255); background-color: rgb(17, 185, 129); border-radius: 4px; font-weight: 400; max-width: 200px">
<div class="formkit-spinner">
<div></div>
<div></div>

View File

@ -17,11 +17,19 @@ function SignInLink() {
return (
<>
{user ? (
<Link href="/account">
<a className="text-base font-medium text-green-400 hover:text-gray-300">
{user.name}
</a>
</Link>
<>
<Link href="/contract">
<a className="text-base font-medium text-white hover:text-gray-300">
Create a market
</a>
</Link>
<Link href="/account">
<a className="text-base font-medium text-green-400 hover:text-gray-300">
{user.name}
</a>
</Link>
</>
) : (
<button
className="text-base font-medium text-green-400 hover:text-gray-300"

View File

@ -11,9 +11,9 @@ export const Hero = () => {
<div className="lg:grid lg:grid-cols-2 lg:gap-8">
<div className="mx-auto max-w-md px-4 sm:max-w-2xl sm:px-6 sm:text-center lg:px-0 lg:text-left lg:flex lg:items-center">
<div className="lg:py-24">
<h1 className="mt-4 text-4xl tracking-tight font-extrabold text-white sm:mt-5 sm:text-6xl lg:mt-6 xl:text-6xl">
<h1 className="mt-4 text-4xl text-white sm:mt-5 sm:text-6xl lg:mt-6 xl:text-6xl">
<span className="block">Create your own</span>
<span className="block text-green-400">
<span className="block text-green-400 font-bold">
prediction markets
</span>
</h1>

View File

@ -1,23 +1,88 @@
import { collection, onSnapshot, doc } from '@firebase/firestore'
import { db } from './init'
import { app } from './init'
import {
getFirestore,
doc,
setDoc,
deleteDoc,
where,
collection,
query,
getDocs,
onSnapshot,
orderBy,
} from 'firebase/firestore'
export type Contract = {
id: string
id: string // Chosen by creator; must be unique
creatorId: string
creatorName: string
question: string
description: string
description: string // More info about what the contract is about
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
// outcomes: ['YES', 'NO']
seedAmounts: { YES: number; NO: number } // seedBets: [number, number]
createdTime: number // Milliseconds since epoch
lastUpdatedTime: number // If the question or description was changed
closeTime?: number // When no more trading is allowed
// isResolved: boolean
resolutionTime?: 10293849 // When the contract creator resolved the market; 0 if unresolved
resolution?: 'YES' | 'NO' | 'CANCEL' // Chosen by creator; must be one of outcomes
}
export type Bet = {
id: string
userId: string
contractId: string
size: number // Amount of USD bid
outcome: 'YES' | 'NO' // Chosen outcome
createdTime: number
dpmWeight: number // Dynamic Parimutuel weight
}
const db = getFirestore(app)
const contractCollection = collection(db, 'contracts')
// Push contract to Firestore
export async function setContract(contract: Contract) {
const docRef = doc(db, 'contracts', contract.id)
await setDoc(docRef, contract)
}
export async function deleteContract(contractId: string) {
const docRef = doc(db, 'contracts', contractId)
await deleteDoc(docRef)
}
export async function listContracts(creatorId: string): Promise<Contract[]> {
const q = query(
contractCollection,
where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc')
)
const snapshot = await getDocs(q)
const contracts: Contract[] = []
snapshot.forEach((doc) => contracts.push(doc.data() as Contract))
return contracts
}
export function listenForContract(
contractId: string,
setContract: (contract: Contract) => void
) {
const contractRef = doc(contractCollection, contractId)
return onSnapshot(contractRef, (contractSnap) => {
setContract(contractSnap.data() as Contract)
})
}
// Push bet to Firestore
// TODO: Should bets be subcollections under its contract?
export async function setBet(bet: Bet) {
const docRef = doc(db, 'bets', bet.id)
await setDoc(docRef, bet)
}

View File

@ -2,7 +2,7 @@ import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html data-theme="dark">
<Html data-theme="dark" className="h-full bg-gray-900">
<Head>
<title>Mantic Markets</title>
@ -38,12 +38,7 @@ export default function Document() {
crossOrigin="true"
/>
<link
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter&display=optional"
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap"
rel="stylesheet"
/>
@ -62,7 +57,7 @@ export default function Document() {
}}
/>
</Head>
<body>
<body className="h-full font-readex-pro">
<Main />
<NextScript />
</body>

View File

@ -2,112 +2,52 @@ import { useRouter } from 'next/router'
import { firebaseLogout } from '../lib/firebase/users'
import { Header } from '../components/header'
import { useUser } from '../hooks/use-user'
import { useState, useEffect } from 'react'
import { Contract, listContracts } from '../lib/firebase/contracts'
import { ContractsList } from '../components/contracts-list'
export default function Account() {
const user = useUser()
const router = useRouter()
const [contracts, setContracts] = useState<Contract[]>([])
useEffect(() => {
if (user?.id) {
listContracts(user?.id).then(setContracts)
}
}, [user?.id])
return (
<div className="relative overflow-hidden h-screen bg-cover bg-gray-900">
<Header />
<div className="flex items-center w-full h-max px-4 py-10 bg-cover card">
<div className="card glass lg:card-side text-neutral-content bg-gray-800 m-10 transition-all">
<figure className="p-6">
<img src={user?.avatarUrl} className="rounded-lg shadow-lg" />
</figure>
<div className="max-w-md card-body">
<h2 className="card-title">{user?.name}</h2>
<p>{user?.email}</p>
<p>${user?.balanceUsd} USD</p>
<div className="card-actions">
<button
className="btn glass rounded-full"
onClick={() => {
firebaseLogout()
router.push('/')
}}
>
Sign Out
</button>
<div className="max-w-4xl my-20 mx-auto">
<div>
<div className="card glass lg:card-side text-neutral-content bg-gray-800 transition-all max-w-sm mx-auto my-20">
<figure className="p-6">
<img src={user?.avatarUrl} className="rounded-lg shadow-lg" />
</figure>
<div className="max-w-md card-body">
<h2 className="card-title font-major-mono">{user?.name}</h2>
<p>{user?.email}</p>
<p>${user?.balanceUsd} USD</p>
<div className="card-actions">
<button
className="btn glass rounded-full"
onClick={() => {
firebaseLogout()
router.push('/')
}}
>
Sign Out
</button>
</div>
</div>
</div>
</div>
{/* Lorem ipsum table. TODO: fill in user's bets and markets */}
<h1 className="text-4xl text-neutral-content m-4">
{user?.username}'s Bets
<h1 className="text-2xl font-major-mono text-indigo-300 font-bold mt-6 mb-4">
Your markets
</h1>
<div className="overflow-x-auto">
<table className="table table-compact">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Job</th>
<th>company</th>
<th>location</th>
<th>Last Login</th>
<th>Favorite Color</th>
</tr>
</thead>
<tbody>
<tr>
<th>1</th>
<td>Cy Ganderton</td>
<td>Quality Control Specialist</td>
<td>Littel, Schaden and Vandervort</td>
<td>Canada</td>
<td>12/16/2020</td>
<td>Blue</td>
</tr>
<tr>
<th>2</th>
<td>Hart Hagerty</td>
<td>Desktop Support Technician</td>
<td>Zemlak, Daniel and Leannon</td>
<td>United States</td>
<td>12/5/2020</td>
<td>Purple</td>
</tr>
<tr>
<th>3</th>
<td>Brice Swyre</td>
<td>Tax Accountant</td>
<td>Carroll Group</td>
<td>China</td>
<td>8/15/2020</td>
<td>Red</td>
</tr>
<tr>
<th>4</th>
<td>Marjy Ferencz</td>
<td>Office Assistant I</td>
<td>Rowe-Schoen</td>
<td>Russia</td>
<td>3/25/2021</td>
<td>Crimson</td>
</tr>
<tr>
<th>5</th>
<td>Yancy Tear</td>
<td>Community Outreach Specialist</td>
<td>Wyman-Ledner</td>
<td>Brazil</td>
<td>5/22/2020</td>
<td>Indigo</td>
</tr>
<tr>
<th>6</th>
<td>Irma Vasilik</td>
<td>Editor</td>
<td>Wiza, Bins and Emard</td>
<td>Venezuela</td>
<td>12/8/2020</td>
<td>Purple</td>
</tr>
</tbody>
</table>
</div>
<ContractsList contracts={contracts} />
</div>
</div>
)

View File

@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { ContractsList } from '../../components/contracts-list'
import { Header } from '../../components/header'
import { useUser } from '../../hooks/use-user'
import {
Contract,
listContracts,
setContract as pushContract,
} from '../../lib/firebase/contracts'
// Allow user to create a new contract
// TODO: Extract to a reusable UI, for listing contracts too?
export default function NewContract() {
const creator = useUser()
const [contract, setContract] = useState<Contract>({
id: '',
creatorId: '',
question: '',
description: '',
seedAmounts: { YES: 100, NO: 100 },
// TODO: Set create time to Firestore timestamp
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
} as Contract)
const [contracts, setContracts] = useState<Contract[]>([])
useEffect(() => {
if (creator?.id) {
setContract((contract) => ({
...contract,
creatorId: creator.id,
creatorName: creator.name,
}))
listContracts(creator?.id).then(setContracts)
}
}, [creator?.id])
async function saveContract() {
await pushContract(contract)
// Update local contract list
setContracts([{ ...contract }, ...contracts])
}
function saveField(field: keyof Contract) {
return (changeEvent: React.ChangeEvent<any>) =>
setContract((c) => ({ ...c, [field]: changeEvent.target.value }))
}
const descriptionPlaceholder = `e.g. This market will resolve to “Yes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...`
return (
<div>
<Header />
<div className="max-w-4xl my-20 lg:mx-auto mx-4">
<h1 className="text-2xl font-major-mono text-indigo-300 font-bold mt-6 mb-4">
Create a new prediction market
</h1>
<div className="w-full bg-gray-500 rounded-lg shadow-xl p-6">
{/* Create a Tailwind form that takes in all the fields needed for a new contract */}
{/* When the form is submitted, create a new contract in the database */}
<form>
<div className="form-control">
<label className="label">
<span className="label-text">Contract ID</span>
</label>
<input
type="text"
placeholder="e.g. COVID-123"
className="input"
value={contract.id}
onChange={saveField('id')}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Question</span>
</label>
<input
type="text"
placeholder="e.g. Will the FDA approve Paxlovid before Jun 2nd, 2022?"
className="input"
value={contract.question}
onChange={saveField('question')}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
</label>
<textarea
className="textarea h-24 textarea-bordered"
placeholder={descriptionPlaceholder}
value={contract.description}
onChange={saveField('description')}
></textarea>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<div className="form-control">
<label className="label">
<span className="label-text">Yes Seed</span>
</label>
<input
type="number"
placeholder="100"
className="input"
value={contract.seedAmounts.YES}
onChange={(e) => {
setContract({
...contract,
seedAmounts: {
...contract.seedAmounts,
YES: parseInt(e.target.value),
},
})
}}
/>
</div>
</div>
<div className="sm:col-span-3">
<div className="form-control">
<label className="label">
<span className="label-text">No Seed</span>
</label>
<input
type="number"
placeholder="100"
className="input"
value={contract.seedAmounts.NO}
onChange={(e) => {
setContract({
...contract,
seedAmounts: {
...contract.seedAmounts,
NO: parseInt(e.target.value),
},
})
}}
/>
</div>
</div>
</div>
{/* TODO: Show a preview of the created market here? */}
<div className="flex justify-end mt-6">
<button
type="submit"
className="btn btn-primary"
onClick={(e) => {
e.preventDefault()
saveContract()
}}
>
Create market
</button>
</div>
</form>
</div>
{/* Show a separate card for each contract */}
<h1 className="text-2xl font-major-mono text-indigo-300 font-bold mt-6 mb-4">
Your markets
</h1>
<ContractsList contracts={contracts} />
</div>
</div>
)
}

View File

@ -249,7 +249,7 @@ export default function Simulator() {
}
return (
<div className="relative overflow-hidden h-screen bg-gray-900">
<div>
<Header />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 mt-8 max-w-7xl mx-auto text-center">
{/* Left column */}

View File

@ -5,6 +5,7 @@ module.exports = {
theme: {
fontFamily: {
'major-mono': ['Major Mono Display', 'monospace'],
'readex-pro': ['Readex Pro', 'sans-serif'],
},
extend: {
backgroundImage: {