Updates to experimental home (#858)

* Line clamp question in prob change table

* Tweaks

* Expand option for daily movers

* Snap scrolling for carousel

* Add arrows to section headers

* Remove carousel from experimental/home

* React querify fetching your groups

* Edit home is its own page

* Add daily profit and balance

* Merge branch 'main' into new-home

* Make experimental search by your followed groups/creators

* Just submit, allow xs on pills

* Weigh in

* Use next/future/image component to optimize avatar images

* Inga/challenge icon (#857)

* changed challenge icon to custom icon
* fixed tip button alignment

* weighing in and trading "weigh in" for "trade"

Co-authored-by: Ian Philips <iansphilips@gmail.com>
Co-authored-by: Austin Chen <akrolsmir@gmail.com>
Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com>
Co-authored-by: mantikoros <sgrugett@gmail.com>
This commit is contained in:
James Grugett 2022-09-08 01:36:34 -05:00 committed by GitHub
parent edbebb7e67
commit 54c227cf6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 265 additions and 192 deletions

View File

@ -116,12 +116,12 @@ const calculateProfitForPeriod = (
return currentProfit
}
const startingProfit = calculateTotalProfit(startingPortfolio)
const startingProfit = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
@ -129,7 +129,7 @@ export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculateTotalProfit(newPortfolio)
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp

View File

@ -12,7 +12,7 @@ import { User } from 'common/user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null
user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: {
visible: string[]
@ -30,7 +30,6 @@ export function ArrangeHome(props: {
return (
<DragDropContext
onDragEnd={(e) => {
console.log('drag end', e)
const { destination, source, draggableId } = e
if (!destination) return

View File

@ -38,7 +38,7 @@ export function Carousel(props: {
return (
<div className={clsx('relative', className)}>
<Row
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
ref={ref}
onScroll={onScroll}
>

View File

@ -69,6 +69,7 @@ type AdditionalFilter = {
excludeContractIds?: string[]
groupSlug?: string
yourBets?: boolean
followed?: boolean
}
export function ContractSearch(props: {
@ -88,6 +89,7 @@ export function ContractSearch(props: {
useQueryUrlParam?: boolean
isWholePage?: boolean
noControls?: boolean
maxResults?: number
renderContracts?: (
contracts: Contract[] | undefined,
loadMore: () => void
@ -107,6 +109,7 @@ export function ContractSearch(props: {
useQueryUrlParam,
isWholePage,
noControls,
maxResults,
renderContracts,
} = props
@ -189,7 +192,8 @@ export function ContractSearch(props: {
const contracts = state.pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = state.pages.length === 0 ? undefined : contracts
const renderedContracts =
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -292,6 +296,19 @@ function ContractSearchControls(props: {
const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
@ -304,6 +321,7 @@ function ContractSearchControls(props: {
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
...(additionalFilter?.followed ? personalFilters : []),
]
const facetFilters = query
? additionalFilters
@ -320,17 +338,7 @@ function ContractSearchControls(props: {
state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}`
: '',
state.pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
: '',
// Subtract contracts you bet on from For you.
state.pillFilter === 'personal' && user
? `uniqueBettorIds:-${user.id}`
: '',
...(state.pillFilter === 'personal' ? personalFilters : []),
state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`

View File

@ -3,52 +3,74 @@ import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { SiteLink } from '../site-link'
import { linkClass, SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { useState } from 'react'
export function ProbChangeTable(props: { userId: string | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
const [expanded, setExpanded] = useState(false)
if (!changes) {
return null
}
const { positiveChanges, negativeChanges } = changes
const count = expanded ? 16 : 4
const count = 3
const { positiveChanges, negativeChanges } = changes
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
const filteredChanges = [
...filteredPositiveChanges,
...filteredNegativeChanges,
]
return (
<Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md">
<Col className="min-w-[300px] flex-1 divide-y">
{positiveChanges.slice(0, count).map((contract) => (
<Row className="hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} />
<SiteLink
className="p-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
{contract.question}
</SiteLink>
</Row>
))}
<Col>
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="flex-1 divide-y">
{filteredChanges.slice(0, count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<Col className="flex-1 divide-y">
{filteredChanges.slice(count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
</Col>
<Col className="justify-content-stretch min-w-[300px] flex-1 divide-y">
{negativeChanges.slice(0, count).map((contract) => (
<Row className="hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} />
<SiteLink
className="p-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
{contract.question}
</SiteLink>
</Row>
))}
</Col>
</Row>
<div
className={clsx(linkClass, 'cursor-pointer self-end')}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : 'Show more'}
</div>
</Col>
)
}
@ -63,9 +85,9 @@ export function ProbChange(props: {
const color =
change > 0
? 'text-green-600'
? 'text-green-500'
: change < 0
? 'text-red-600'
? 'text-red-500'
: 'text-gray-600'
const str =

View File

@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
export function DoubleCarousel(props: {
contracts: Contract[]
seeMoreUrl?: string
showTime?: ShowTime
loadMore?: () => void
}) {
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
? range(0, Math.floor(contracts.length / 2)).map((col) => {
const i = col * 2
return (
<Col key={contracts[i].id}>
<Col className="snap-start scroll-m-4" key={contracts[i].id}>
<ContractCard
contract={contracts[i]}
className="mb-2 w-96 shrink-0"

View File

@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getMemberGroups,
GroupMemberDoc,
groupMembers,
listenForGroup,
listenForGroupContractDocs,
listenForGroups,
listenForMemberGroupIds,
listenForMemberGroups,
listenForOpenGroups,
listGroups,
} from 'web/lib/firebase/groups'
@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
import { uniq } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils'
import { useQuery } from 'react-query'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -49,12 +50,10 @@ export const useOpenGroups = () => {
}
export const useMemberGroups = (userId: string | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (userId)
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
}, [userId])
return memberGroups
const result = useQuery(['member-groups', userId ?? ''], () =>
getMemberGroups(userId ?? '')
)
return result.data
}
// Note: We cache member group ids in localstorage to speed up the initial load

View File

@ -32,7 +32,7 @@ export const groupMembers = (groupId: string) =>
export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts')
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
const memberGroupsQuery = (userId: string) =>
export const memberGroupsQuery = (userId: string) =>
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
export function groupPath(
@ -113,6 +113,15 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup)
}
export async function getMemberGroups(userId: string) {
const snapshot = await getDocs(memberGroupsQuery(userId))
const groupIds = filterDefined(
snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = await Promise.all(groupIds.map(getGroup))
return filterDefined(groups)
}
export function listenForMemberGroupIds(
userId: string,
setGroupIds: (groupIds: string[]) => void

View File

@ -0,0 +1,60 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ArrangeHome } from 'web/components/arrange-home'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
export default function Home() {
const user = useUser()
useTracking('edit home')
const [homeSections, setHomeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}>
<Title text="Edit your home page" />
<DoneButton />
</Row>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</Col>
</Page>
)
}
function DoneButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home">
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
Done
</Button>
</SiteLink>
)
}

View File

@ -1,40 +1,36 @@
import React, { useState } from 'react'
import Router from 'next/router'
import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid'
import {
PencilIcon,
PlusSmIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { GroupLinkItem } from '../../groups'
import { SiteLink } from 'web/components/site-link'
import { useUser } from 'web/hooks/use-user'
import { useMemberGroups } from 'web/hooks/use-group'
import { DoubleCarousel } from '../../../components/double-carousel'
import clsx from 'clsx'
import { Button } from 'web/components/button'
import { ArrangeHome, getHomeItems } from '../../../components/arrange-home'
import { getHomeItems } from '../../../components/arrange-home'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { formatMoney } from 'common/util/format'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = useUser() ?? props.auth?.user ?? null
const Home = () => {
const user = useUser()
useTracking('view home')
@ -42,76 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => {
const groups = useMemberGroups(user?.id) ?? []
const [homeSections, setHomeSections] = useState(
const [homeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const { visibleItems } = getHomeItems(groups, homeSections)
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
}
const [isEditing, setIsEditing] = useState(false)
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]">
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}>
<Title text={isEditing ? 'Edit your home page' : 'Home'} />
<Title className="!mb-0" text="Home" />
<EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} />
<EditButton />
</Row>
{isEditing ? (
<>
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
/>
</>
) : (
<>
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
<DailyProfitAndBalance userId={user?.id} />
{visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your trades'}
sort={'prob-change-day'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
const group = groups.find((g) => g.id === id)
if (group)
return <GroupSection key={id} group={group} user={user} />
{visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your trades'}
sort={'newest'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
return null
})}
</>
)}
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
return null
})}
</Col>
<button
type="button"
@ -129,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => {
function SearchSection(props: {
label: string
user: User | null
user: User | null | undefined
sort: Sort
yourBets?: boolean
}) {
@ -139,88 +113,91 @@ function SearchSection(props: {
return (
<Col>
<SiteLink className="mb-2 text-xl" href={href}>
{label}
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ContractSearch
user={user}
defaultSort={sort}
additionalFilter={yourBets ? { yourBets: true } : undefined}
additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
noControls
// persistPrefix={`experimental-home-${sort}`}
renderContracts={(contracts, loadMore) =>
contracts ? (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={href}
showTime={
sort === 'close-date' || sort === 'resolve-date'
? sort
: undefined
}
loadMore={loadMore}
/>
) : (
<LoadingIndicator />
)
}
maxResults={6}
persistPrefix={`experimental-home-${sort}`}
/>
</Col>
)
}
function GroupSection(props: { group: Group; user: User | null }) {
function GroupSection(props: { group: Group; user: User | null | undefined }) {
const { group, user } = props
return (
<Col>
<GroupLinkItem className="mb-2 text-xl" group={group} />
<SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
{group.name}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
noControls
// persistPrefix={`experimental-home-${group.slug}`}
renderContracts={(contracts, loadMore) =>
contracts ? (
contracts.length == 0 ? (
<div className="m-2 text-gray-500">No open markets</div>
) : (
<DoubleCarousel
contracts={contracts}
seeMoreUrl={`/group/${group.slug}`}
loadMore={loadMore}
/>
)
) : (
<LoadingIndicator />
)
}
maxResults={6}
persistPrefix={`experimental-home-${group.slug}`}
/>
</Col>
)
}
function EditDoneButton(props: {
isEditing: boolean
setIsEditing: (isEditing: boolean) => void
className?: string
}) {
const { isEditing, setIsEditing, className } = props
function EditButton(props: { className?: string }) {
const { className } = props
return (
<Button
size="lg"
color={isEditing ? 'blue' : 'gray-white'}
className={clsx(className, 'flex')}
onClick={() => {
setIsEditing(!isEditing)
}}
>
{!isEditing && (
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
)}
{isEditing ? 'Done' : 'Edit'}
</Button>
<SiteLink href="/experimental/home/edit">
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
Edit
</Button>
</SiteLink>
)
}
function DailyProfitAndBalance(props: {
userId: string | null | undefined
className?: string
}) {
const { userId, className } = props
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
if (first === undefined || last === undefined) return null
const profit =
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
const balanceChange = last.balance - first.balance
return (
<div className={clsx(className, 'text-lg')}>
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
{profit >= 0 ? '+' : '-'}
{formatMoney(profit)}
</span>{' '}
profit and{' '}
<span
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
>
{balanceChange >= 0 ? '+' : '-'}
{formatMoney(balanceChange)}
</span>{' '}
balance today
</div>
)
}

View File

@ -237,7 +237,7 @@ const MarketCarousel = (props: { slug: string }) => {
key={m.id}
contract={m}
hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0"
className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none"
questionClass="line-clamp-3"
trackingPostfix=" tournament"
/>