diff --git a/apps/api/app/api/v1/markets/route.ts b/apps/api/app/api/v1/markets/route.ts index b0fe565a..b8223db4 100644 --- a/apps/api/app/api/v1/markets/route.ts +++ b/apps/api/app/api/v1/markets/route.ts @@ -29,6 +29,8 @@ export async function GET(req: Request): Promise> { try { const userId = await getAuthUser(req) diff --git a/packages/api-helpers/client/index.ts b/packages/api-helpers/client/index.ts index baa7c80d..30b20c2e 100644 --- a/packages/api-helpers/client/index.ts +++ b/packages/api-helpers/client/index.ts @@ -250,6 +250,13 @@ export async function updateMarket({ marketId, body }: { marketId: string; body: }) } +export async function updateList({ listId, body }: { listId: string; body: Record }) { + return apiHandler<{ data: Market }>(`${process.env.NEXT_PUBLIC_API_URL}/v1/lists/${listId}`, { + method: 'PATCH', + body: body, + }) +} + export async function updateMarketOption({ marketId, optionId, diff --git a/packages/lists/components/EditListDialog.tsx b/packages/lists/components/EditListDialog.tsx new file mode 100644 index 00000000..95906ad4 --- /dev/null +++ b/packages/lists/components/EditListDialog.tsx @@ -0,0 +1,154 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import React from 'react' +import { useForm } from 'react-hook-form' +import z from 'zod' +import { updateList } from '@play-money/api-helpers/client' +import { List, ListSchema } from '@play-money/database' +import { Button } from '@play-money/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@play-money/ui/dialog' +import { Editor } from '@play-money/ui/editor' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@play-money/ui/form' +import { Input } from '@play-money/ui/input' +import { MultiSelect } from '@play-money/ui/multi-select' +import { toast } from '@play-money/ui/use-toast' + +const FormSchema = ListSchema.pick({ title: true, description: true, tags: true }) + +type FormData = z.infer + +// Copied from https://github.com/orgs/react-hook-form/discussions/1991 +function getDirtyValues, Values extends Record>( + dirtyFields: DirtyFields, + values: Values +): Partial { + const dirtyValues = Object.keys(dirtyFields).reduce((prev, key) => { + if (!dirtyFields[key]) return prev + + return { + ...prev, + [key]: + typeof dirtyFields[key] === 'object' + ? getDirtyValues(dirtyFields[key] as DirtyFields, values[key] as Values) + : values[key], + } + }, {}) + + return dirtyValues +} + +export const EditListDialog = ({ + open, + onClose, + onSuccess, + list, +}: { + open: boolean + onClose: () => void + onSuccess?: () => void + list: List +}) => { + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + title: list.title, + description: list.description ?? '

', + tags: list.tags, + }, + }) + + const { + formState: { isSubmitting, isDirty, isValid }, + } = form + + const onSubmit = async (data: FormData) => { + try { + const changedData = getDirtyValues(form.formState.dirtyFields, data) + await updateList({ listId: list.id, body: changedData }) + toast({ title: 'List edited successfully' }) + form.reset() + onSuccess?.() + onClose() + } catch (error: any) { + console.error('Failed to edit list:', error) + toast({ + title: 'There was an issue editing the list', + description: error.message ?? 'Please try again later', + variant: 'destructive', + }) + } + } + + return ( + + + + Edit List + + +
+ + ( + + {/* Question */} + + + + + + )} + /> + + ( + + Resolution criteria + +
+ +
+
+ +
+ )} + /> + + ( + + Tags + + ({ value: v, label: v }))} + onChange={(values) => onChange(values?.map((v) => v.value))} + hideClearAllButton + {...field} + /> + + + + )} + /> + + + + +
+
+ ) +} diff --git a/packages/lists/components/ListPage.tsx b/packages/lists/components/ListPage.tsx index 6214afa5..e96959b7 100644 --- a/packages/lists/components/ListPage.tsx +++ b/packages/lists/components/ListPage.tsx @@ -17,9 +17,10 @@ import { UserLink } from '@play-money/users/components/UserLink' import { useUser } from '@play-money/users/context/UserContext' import { useSelectedItems } from '../../ui/src/contexts/SelectedItemContext' import { useSearchParam } from '../../ui/src/hooks/useSearchParam' -import { canAddToList } from '../rules' +import { canAddToList, canModifyList } from '../rules' import { ExtendedList } from '../types' import { AddMoreListDialog } from './AddMoreListDialog' +import { EditListDialog } from './EditListDialog' import { ListMarketRow } from './ListMarketRow' import { ListToolbar } from './ListToolbar' @@ -36,6 +37,7 @@ export function ListPage({ const { triggerEffect } = useSidebar() const { selected, setSelected } = useSelectedItems() const [isAddMore, setIsAddMore] = useSearchParam('addMore') + const [isEditing, setIsEditing] = useSearchParam('edit') const handleRevalidateBalance = async () => { void onRevalidate?.() @@ -45,7 +47,11 @@ export function ListPage({ return ( - + setIsEditing('true')} + /> {list.title} @@ -125,6 +131,14 @@ export function ListPage({
Comments
{renderComments} + setIsEditing(undefined)} + onSuccess={onRevalidate} + /> + void +}) { const { user } = useUser() const handleCopyLink = async () => { @@ -32,6 +46,12 @@ export function ListToolbar({ list }: { list: ExtendedList }) { return (
+ {canEdit ? ( + + ) : null}
diff --git a/packages/lists/lib/createList.ts b/packages/lists/lib/createList.ts index f672548c..52ffd55a 100644 --- a/packages/lists/lib/createList.ts +++ b/packages/lists/lib/createList.ts @@ -1,12 +1,27 @@ import Decimal from 'decimal.js' +import _ from 'lodash' import db, { Market } from '@play-money/database' import { QuestionContributionPolicyType } from '@play-money/database/zod/inputTypeSchemas/QuestionContributionPolicySchema' import { getBalance } from '@play-money/finance/lib/getBalances' import { createMarket } from '@play-money/markets/lib/createMarket' +import { getMarketTagsLLM } from '@play-money/markets/lib/getMarketTagsLLM' import { slugifyTitle } from '@play-money/markets/lib/helpers' import { getUserPrimaryAccount } from '@play-money/users/lib/getUserPrimaryAccount' import { calculateTotalCost } from './helpers' +const COLORS = [ + '#f44336', + '#9c27b0', + '#3f51b5', + '#2196f3', + '#009688', + '#8bc34a', + '#ffc107', + '#ff9800', + '#795548', + '#607d8b', +] + export async function createList({ title, description, @@ -27,6 +42,7 @@ export async function createList({ const slug = slugifyTitle(title) const totalCost = calculateTotalCost(markets.length) const costPerMarket = new Decimal(totalCost).div(markets.length) + const SHUFFLED_COLORS = _.shuffle(COLORS) const userAccount = await getUserPrimaryAccount({ userId: ownerId }) const userPrimaryBalance = await getBalance({ @@ -39,15 +55,36 @@ export async function createList({ throw new Error('User does not have enough balance to create list') } + const generatedTags = tags ?? (await getMarketTagsLLM({ question: title })) + + const list = await db.list.create({ + data: { + title, + description, + ownerId, + slug, + tags: generatedTags.map((tag) => slugifyTitle(tag)), + contributionPolicy, + }, + include: { + markets: { + include: { + market: true, + }, + }, + }, + }) + const createdMarkets: Array = [] for (const market of markets) { const createdMarket = await createMarket({ + parentListId: list.id, question: market.name, description: description ?? '', options: [ { name: 'Yes', - color: market.color ?? '#3B82F6', + color: market.color ?? SHUFFLED_COLORS[createdMarkets.length % SHUFFLED_COLORS.length], }, { name: 'No', @@ -61,53 +98,16 @@ export async function createList({ createdMarkets.push(createdMarket) } - const createdList = await db.$transaction( - async (tx) => { - const list = await tx.list.create({ - data: { - title, - description, - ownerId, - slug, - tags: tags?.map((tag) => slugifyTitle(tag)), - markets: { - createMany: { - data: createdMarkets.map((market) => ({ - marketId: market.id, - })), - }, - }, - contributionPolicy, - }, + return db.list.findFirstOrThrow({ + where: { + id: list.id, + }, + include: { + markets: { include: { - markets: { - include: { - market: true, - }, - }, + market: true, }, - }) - - await Promise.all( - createdMarkets.map((market) => { - return tx.market.update({ - where: { - id: market.id, - }, - data: { - parentListId: list.id, - }, - }) - }) - ) - - return list + }, }, - { - maxWait: 5000, - timeout: 10000, - } - ) - - return createdList + }) } diff --git a/packages/markets/components/MarketOverviewPage.tsx b/packages/markets/components/MarketOverviewPage.tsx index a7caf0c0..0593d4a8 100644 --- a/packages/markets/components/MarketOverviewPage.tsx +++ b/packages/markets/components/MarketOverviewPage.tsx @@ -2,7 +2,7 @@ import { format, isPast } from 'date-fns' import _ from 'lodash' -import { CircleCheckBig, ChevronDown, Link2Icon } from 'lucide-react' +import { CircleCheckBig, ChevronDown, Link2Icon, ArrowRight } from 'lucide-react' import Link from 'next/link' import React from 'react' import { mutate } from 'swr' @@ -28,7 +28,7 @@ import { UserLink } from '@play-money/users/components/UserLink' import { useUser } from '@play-money/users/context/UserContext' import { useSelectedItems } from '../../ui/src/contexts/SelectedItemContext' import { useSearchParam } from '../../ui/src/hooks/useSearchParam' -import { canModifyMarket } from '../rules' +import { canModifyMarket, isMarketClosed } from '../rules' import { ExtendedMarket } from '../types' import { EditMarketDialog } from './EditMarketDialog' import { EditMarketOptionDialog } from './EditMarketOptionDialog' @@ -65,6 +65,7 @@ export function MarketOverviewPage({ const [isEditing, setIsEditing] = useSearchParam('edit') const [isEditOption, setIsEditOption] = useSearchParam('editOption') const [isBoosting, setIsBoosting] = useSearchParam('boost') + const [, setResolving] = useSearchParam('resolve') const isCreator = user?.id === market.createdBy const probabilities = marketOptionBalancesToProbabilities(balance?.amm) @@ -94,194 +95,208 @@ export function MarketOverviewPage({ } return ( - - setIsEditing('true')} - onInitiateBoost={() => setIsBoosting('true')} - onRevalidate={handleRevalidateBalance} - /> + <> + {isMarketClosed({ market }) ? ( +
setResolving('true')} + > + This question has ended, please resolve it now. + +
+ ) : null} - - {market.parentList ? ( - - - {market.parentList.title} - - ) : null} - {market.question} -
- {market.canceledAt ? ( -
- Canceled -
- ) : null} - {!market.marketResolution && !market.canceledAt ? ( -
- {Math.round(mostLikelyOption.probability || 0)}% {_.truncate(mostLikelyOption.name, { length: 30 })} -
- ) : null} - {market.liquidityCount ? ( -
- Vol. -
- ) : null} + + setIsEditing('true')} + onInitiateBoost={() => setIsBoosting('true')} + onRevalidate={handleRevalidateBalance} + /> - {market.closeDate ? ( -
- {isPast(market.closeDate) ? 'Ended' : 'Ending'} {format(market.closeDate, 'MMM d, yyyy')} -
+ + {market.parentList ? ( + + + {market.parentList.title} + ) : null} - {market.user ? ( -
- - -
- ) : null} - {/*
15 Traders
+ {market.question} +
+ {market.canceledAt ? ( +
+ Canceled +
+ ) : null} + {!market.marketResolution && !market.canceledAt ? ( +
+ {Math.round(mostLikelyOption.probability || 0)}% {_.truncate(mostLikelyOption.name, { length: 30 })} +
+ ) : null} + {market.liquidityCount ? ( +
+ Vol. +
+ ) : null} + + {market.closeDate ? ( +
+ {isPast(market.closeDate) ? 'Ended' : 'Ending'} {format(market.closeDate, 'MMM d, yyyy')} +
+ ) : null} + {market.user ? ( +
+ + +
+ ) : null} + {/*
15 Traders
$650 Volume
*/} -
-
- - - +
+
+ + + - - {market.marketResolution ? ( - <> - - - - {market.marketResolution.resolution.name} - - Resolved - - - - By on{' '} - {format(market.marketResolution.updatedAt, 'MMM d, yyyy')} - - {market.marketResolution.supportingLink ? ( - - + {market.marketResolution ? ( + <> + + + + {market.marketResolution.resolution.name} + - {market.marketResolution.supportingLink} - + Resolved + + + + By on{' '} + {format(market.marketResolution.updatedAt, 'MMM d, yyyy')} + {market.marketResolution.supportingLink ? ( + + + {market.marketResolution.supportingLink} + + + ) : null} + + {orderedMarketOptions.length ? ( + + + + + + + {orderedMarketOptions.map((option, i) => ( + 0 ? 'border-t' : ''} + canEdit={user ? canModifyMarket({ market, user }) : false} + onEdit={() => setIsEditOption(option.id)} + onSelect={() => { + setSelected([option.id]) + triggerEffect() + }} + /> + ))} + + + ) : null} - - {orderedMarketOptions.length ? ( - - - - - - - {orderedMarketOptions.map((option, i) => ( - 0 ? 'border-t' : ''} - canEdit={user ? canModifyMarket({ market, user }) : false} - onEdit={() => setIsEditOption(option.id)} - onSelect={() => { - setSelected([option.id]) - triggerEffect() - }} - /> - ))} - - - - ) : null} - - ) : orderedMarketOptions.length ? ( - - {orderedMarketOptions.map((option, i) => ( - 0 ? 'border-t' : ''} - canEdit={user ? canModifyMarket({ market, user }) : false} - onEdit={() => setIsEditOption(option.id)} - onSelect={() => { - setSelected([option.id]) - triggerEffect() - }} - /> - ))} - - ) : null} - - - - {market.description ? : null} + + ) : orderedMarketOptions.length ? ( + + {orderedMarketOptions.map((option, i) => ( + 0 ? 'border-t' : ''} + canEdit={user ? canModifyMarket({ market, user }) : false} + onEdit={() => setIsEditOption(option.id)} + onSelect={() => { + setSelected([option.id]) + triggerEffect() + }} + /> + ))} + + ) : null} + - {market.tags.length ? ( -
- {market.tags.map((tag) => ( - - {tag} - - ))} -
- ) : null} - + + {market.description ? : null} - {!market.resolvedAt && !market.canceledAt ? ( - - setIsBoosting('true')} /> + {market.tags.length ? ( +
+ {market.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null}
- ) : null} -
-
Activity
-
+ {!market.resolvedAt && !market.canceledAt ? ( + + setIsBoosting('true')} /> + + ) : null} -
- -
- {renderActivitiy} +
+
Activity
+
+ +
+ +
+ {renderActivitiy} - setIsEditing(undefined)} - onSuccess={onRevalidate} - /> - setIsEditOption(undefined)} - onSuccess={onRevalidate} - /> - setIsBoosting(undefined)} - onSuccess={handleRevalidateBalance} - /> -
+ setIsEditing(undefined)} + onSuccess={onRevalidate} + /> + setIsEditOption(undefined)} + onSuccess={onRevalidate} + /> + setIsBoosting(undefined)} + onSuccess={handleRevalidateBalance} + /> +
+ ) } diff --git a/packages/markets/lib/createMarket.ts b/packages/markets/lib/createMarket.ts index 95c03162..577f7733 100644 --- a/packages/markets/lib/createMarket.ts +++ b/packages/markets/lib/createMarket.ts @@ -7,6 +7,7 @@ import { createDailyMarketBonusTransaction } from '@play-money/quests/lib/create import { hasCreatedMarketToday } from '@play-money/quests/lib/helpers' import { getUserPrimaryAccount } from '@play-money/users/lib/getUserPrimaryAccount' import { createMarketLiquidityTransaction } from './createMarketLiquidityTransaction' +import { getMarketTagsLLM } from './getMarketTagsLLM' import { slugifyTitle } from './helpers' type PartialOptions = Pick @@ -60,6 +61,8 @@ export async function createMarket({ throw new Error('User does not have enough balance to create market') } + const generatedTags = tags ?? (await getMarketTagsLLM({ question })) + const now = new Date() const createdMarket = await db.market.create({ data: { @@ -67,7 +70,7 @@ export async function createMarket({ description, closeDate, slug, - tags: tags?.map((tag) => slugifyTitle(tag)), + tags: generatedTags.map((tag) => slugifyTitle(tag)), options: { createMany: { data: parsedOptions.map((option, i) => ({ @@ -86,6 +89,11 @@ export async function createMarket({ id: parentListId, }, } as unknown as undefined, + lists: { + create: { + listId: parentListId, + }, + }, } : {}), diff --git a/packages/markets/lib/getMarketQuote.ts b/packages/markets/lib/getMarketQuote.ts index 4fcccd19..aa891aa4 100644 --- a/packages/markets/lib/getMarketQuote.ts +++ b/packages/markets/lib/getMarketQuote.ts @@ -22,15 +22,19 @@ export async function getMarketQuote({ const optionBalances = ammBalances.filter(({ assetType }) => assetType === 'MARKET_OPTION') const optionsShares = optionBalances.map(({ total }) => total) + if (!targetBalance) { + throw new Error('Target balance not found') + } + // TODO: Change to multi-step quote to account for limit orders let { newProbabilities, shares } = await quote({ amount, probability: isBuy ? new Decimal(0.99) : new Decimal(0.01), - targetShare: targetBalance!.total, + targetShare: targetBalance.total, shares: optionsShares, }) - const shareIndex = findShareIndex(optionsShares, targetBalance!.total) + const shareIndex = findShareIndex(optionsShares, targetBalance.total) const distributed = distributeRemainder(newProbabilities) const tax = calculateRealizedGainsTax({ cost: amount, salePrice: shares }) diff --git a/packages/markets/rules.ts b/packages/markets/rules.ts index cc10adac..0f87deff 100644 --- a/packages/markets/rules.ts +++ b/packages/markets/rules.ts @@ -19,6 +19,10 @@ export function isMarketTradable({ market }: { market: Market }): boolean { return !market.closeDate || new Date(market.closeDate) > now } +export function isMarketClosed({ market }: { market: Market }): boolean { + return !isMarketTradable({ market }) && !isMarketResolved({ market }) && !isMarketCanceled({ market }) +} + export function isMarketResolved({ market }: { market: Market }): boolean { return Boolean(market.resolvedAt) } diff --git a/packages/ui/src/components/ui/alert.tsx b/packages/ui/src/components/ui/alert.tsx index 9ec40670..d2669f39 100644 --- a/packages/ui/src/components/ui/alert.tsx +++ b/packages/ui/src/components/ui/alert.tsx @@ -8,6 +8,7 @@ const alertVariants = cva( variants: { variant: { default: 'bg-background text-foreground', + primary: 'bg-primary/10 border-primary text-foreground', muted: 'bg-muted text-muted-foreground [&>svg]:text-muted-foreground', destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', },