diff --git a/common/post.ts b/common/post.ts index 262859d1..13a90821 100644 --- a/common/post.ts +++ b/common/post.ts @@ -14,6 +14,7 @@ export type DateDoc = Post & { birthday: number photoUrl: string type: 'date-doc' + contractSlug: string } export const MAX_POST_TITLE_LENGTH = 480 diff --git a/functions/src/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 113a34bd..75ecd227 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { createMarketHelper } from './create-market' +import { DAY_MS } from '../../common/util/time' const contentSchema: z.ZodType = z.lazy(() => z.intersection( @@ -35,11 +38,19 @@ const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), + bounty: z.number().optional(), + birthday: z.number().optional(), + photoUrl: z.string().optional(), + type: z.string().optional(), + question: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -51,14 +62,33 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + let contractSlug + if (question) { + const closeTime = Date.now() + DAY_MS * 30 * 3 + + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + }, + auth + ) + contractSlug = result.slug + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) if (groupId) { diff --git a/web/pages/date/create.tsx b/web/pages/date/create.tsx index f29915fa..8538bc73 100644 --- a/web/pages/date/create.tsx +++ b/web/pages/date/create.tsx @@ -1,5 +1,6 @@ import Router from 'next/router' import { useEffect, useState } from 'react' +import Textarea from 'react-expanding-textarea' import { DateDoc } from 'common/post' import { useTextEditor, TextEditor } from 'web/components/editor' @@ -15,6 +16,7 @@ import { MINUTE_MS } from 'common/util/time' import { Col } from 'web/components/layout/col' import { uploadImage } from 'web/lib/firebase/storage' import { LoadingIndicator } from 'web/components/loading-indicator' +import { MAX_QUESTION_LENGTH } from 'common/contract' export default function CreateDateDocPage() { const user = useUser() @@ -25,8 +27,11 @@ export default function CreateDateDocPage() { const title = `${user?.name}'s Date Doc` const [birthday, setBirthday] = useState(undefined) - const [photoUrl, setPhotoUrl] = useState(user?.avatarUrl ?? '') + const [photoUrl, setPhotoUrl] = useState('') const [avatarLoading, setAvatarLoading] = useState(false) + const [question, setQuestion] = useState( + 'Will I find a partner in the next 3 months?' + ) const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) @@ -37,7 +42,12 @@ export default function CreateDateDocPage() { const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined const isValid = - user && birthday && photoUrl && editor && editor.isEmpty === false + user && + birthday && + photoUrl && + editor && + editor.isEmpty === false && + question const fileHandler = async (event: any) => { if (!user) return @@ -53,22 +63,25 @@ export default function CreateDateDocPage() { }) .catch(() => { setAvatarLoading(false) - setPhotoUrl(user.avatarUrl || '') + setPhotoUrl('') }) } async function saveDateDoc() { if (!editor || !birthdayTime) return - const newPost: Omit = - { - title, - content: editor.getJSON(), - bounty: 0, - birthday: birthdayTime, - photoUrl, - type: 'date-doc', - } + const newPost: Omit< + DateDoc, + 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' + > & { question: string } = { + title, + content: editor.getJSON(), + bounty: 0, + birthday: birthdayTime, + photoUrl, + type: 'date-doc', + question, + } const result = await createPost(newPost).catch((e) => { console.log(e) @@ -83,7 +96,7 @@ export default function CreateDateDocPage() { return (
-
+
<Button @@ -100,8 +113,8 @@ export default function CreateDateDocPage() { </Button> </Row> - <Col className="gap-6"> - <Col className="max-w-[150px] justify-start gap-4"> + <Col className="gap-8"> + <Col className="max-w-[160px] justify-start gap-4"> <div className="">Birthday</div> <input type={'date'} @@ -126,7 +139,7 @@ export default function CreateDateDocPage() { src={photoUrl} width={80} height={80} - className="flex h-[80px] w-[80px] items-center justify-center rounded-full bg-gray-400 object-cover" + className="flex h-[80px] w-[80px] items-center justify-center rounded-lg bg-gray-400 object-cover" /> )} <input @@ -147,6 +160,22 @@ export default function CreateDateDocPage() { </div> <TextEditor editor={editor} upload={upload} /> </Col> + + <Col className="gap-4"> + <div className=""> + Finally, we'll create an (unlisted) prediction market! + </div> + + <Col className="gap-2"> + <Textarea + className="input input-bordered resize-none" + maxLength={MAX_QUESTION_LENGTH} + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + <div className="ml-2 text-gray-500">Cost: M$100</div> + </Col> + </Col> </Col> </div> </div> diff --git a/web/pages/date/index.tsx b/web/pages/date/index.tsx index b47d2ac8..aeaa1ea3 100644 --- a/web/pages/date/index.tsx +++ b/web/pages/date/index.tsx @@ -1,5 +1,6 @@ import { Page } from 'web/components/page' import { PlusCircleIcon } from '@heroicons/react/outline' +import dayjs from 'dayjs' import { getDateDocs } from 'web/lib/firebase/posts' import { DateDoc } from 'common/post' @@ -11,42 +12,59 @@ import { useUser } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Button } from 'web/components/button' import { SiteLink } from 'web/components/site-link' +import { getUser, User } from 'web/lib/firebase/users' +import { DOMAIN } from 'common/envs/constants' export async function getStaticProps() { const dateDocs = await getDateDocs() + const docCreators = await Promise.all( + dateDocs.map((d) => getUser(d.creatorId)) + ) return { props: { dateDocs, + docCreators, }, revalidate: 60, // regenerate after a minute } } -export default function DatePage(props: { dateDocs: DateDoc[] }) { - const { dateDocs } = props +export default function DatePage(props: { + dateDocs: DateDoc[] + docCreators: User[] +}) { + const { dateDocs, docCreators } = props const user = useUser() + const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) + return ( <Page> - <div className="mx-auto w-full max-w-3xl "> + <div className="mx-auto w-full max-w-3xl"> <Row className="items-center justify-between"> <Title className="!my-0 px-2 text-blue-500" text="Date docs" /> - <SiteLink href="/date/create" className="!no-underline"> - <Button className="flex flex-row gap-1" color="blue"> - <PlusCircleIcon - className={'h-5 w-5 flex-shrink-0 text-white'} - aria-hidden="true" - /> - New - </Button> - </SiteLink> + {!hasDoc && ( + <SiteLink href="/date/create" className="!no-underline"> + <Button className="flex flex-row gap-1" color="blue"> + <PlusCircleIcon + className={'h-5 w-5 flex-shrink-0 text-white'} + aria-hidden="true" + /> + New + </Button> + </SiteLink> + )} </Row> - <Spacer h={2} /> + <Spacer h={6} /> <Col className="gap-4"> - {dateDocs.map((dateDoc) => ( - <DateDoc key={dateDoc.id} dateDoc={dateDoc} /> + {dateDocs.map((dateDoc, i) => ( + <DateDoc + key={dateDoc.id} + dateDoc={dateDoc} + creator={docCreators[i]} + /> ))} </Col> </div> @@ -54,15 +72,42 @@ export default function DatePage(props: { dateDocs: DateDoc[] }) { ) } -function DateDoc(props: { dateDoc: DateDoc }) { - const { dateDoc } = props - const { content } = dateDoc +function DateDoc(props: { dateDoc: DateDoc; creator: User }) { + const { dateDoc, creator } = props + const { content, birthday, photoUrl, contractSlug } = dateDoc + const { name, username } = creator + + const age = dayjs().diff(birthday, 'year') + const marketUrl = `https://${DOMAIN}/${username}/${contractSlug}` return ( - <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> - <div className="form-control w-full py-2"> - <Content content={content} /> + <Col className="rounded-lg bg-white px-6 py-6"> + <Col className="gap-2 self-center"> + <Row> + <img + className="w-full max-w-lg rounded-lg object-cover" + src={photoUrl} + alt={name} + /> + </Row> + <Row className="gap-4 text-2xl"> + <div> + {name}, {age} + </div> + </Row> + </Col> + <Spacer h={6} /> + <Content content={content} /> + <Spacer h={6} /> + <div className="mt-10 w-full rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-5"> + <iframe + height="405" + src={marketUrl} + title="" + frameBorder="0" + className="w-full rounded-xl bg-white p-10" + ></iframe> </div> - </div> + </Col> ) }