import React, { ReactNode, useEffect } from 'react' import Router from 'next/router' import { AdjustmentsIcon, PencilAltIcon, ArrowSmRightIcon, } from '@heroicons/react/solid' import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { toast, Toaster } from 'react-hot-toast' import { Dictionary } from 'lodash' 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 { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' import { useSaveReferral } from 'web/hooks/use-save-referral' import { Sort } from 'web/components/contract-search' import { Group } from 'common/group' import { SiteLink } from 'web/components/site-link' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useMemberGroupIds, useMemberGroupsSubscription, useTrendingGroups, } from 'web/hooks/use-group' import { Button } from 'web/components/button' import { Row } from 'web/components/layout/row' import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { formatMoney } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' import { calculatePortfolioProfit } from 'common/calculate-metrics' import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { PillButton } from 'web/components/buttons/pill-button' import { filterDefined } from 'common/util/array' import { updateUser } from 'web/lib/firebase/users' import { isArray, keyBy } from 'lodash' import { usePrefetch } from 'web/hooks/use-prefetch' import { Title } from 'web/components/title' import { CPMMBinaryContract } from 'common/contract' import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts' import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' export default function Home() { const user = useUser() useTracking('view home') useSaveReferral() usePrefetch(user?.id) useEffect(() => { if (user === null) { // Go to landing page if not logged in. Router.push('/') } }) const groups = useMemberGroupsSubscription(user) const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? []) useEffect(() => { if (user && !user.homeSections && sections.length > 0 && groups) { // Save initial home sections. updateUser(user.id, { homeSections: sections.map((s) => s.id) }) } }, [user, sections, groups]) const groupContracts = useContractsByDailyScoreGroups( groups?.map((g) => g.slug) ) return ( <CustomizeButton justIcon /> </Row> <DailyStats user={user} /> </Row> {!user ? ( <LoadingIndicator /> ) : ( <> {sections.map((section) => renderSection(section, user, groups, groupContracts) )} <TrendingGroupsSection user={user} /> </> )} </Col> <button type="button" className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" onClick={() => { Router.push('/create') track('mobile create button') }} > <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> </button> </Page> ) } const HOME_SECTIONS = [ { label: 'Daily movers', id: 'daily-movers' }, { label: 'Daily trending', id: 'daily-trending' }, { label: 'Trending', id: 'score' }, { label: 'New', id: 'newest' }, ] export const getHomeItems = (groups: Group[], sections: string[]) => { // Accommodate old home sections. if (!isArray(sections)) sections = [] const items: { id: string; label: string; group?: Group }[] = [ ...HOME_SECTIONS, ...groups.map((g) => ({ label: g.name, id: g.id, group: g, })), ] const itemsById = keyBy(items, 'id') const sectionItems = filterDefined(sections.map((id) => itemsById[id])) // Add new home section items to the top. sectionItems.unshift( ...HOME_SECTIONS.filter((item) => !sectionItems.includes(item)) ) // Add unmentioned items to the end. sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) return { sections: sectionItems, itemsById, } } function renderSection( section: { id: string; label: string }, user: User, groups: Group[] | undefined, groupContracts: Dictionary<CPMMBinaryContract[]> | undefined ) { const { id, label } = section if (id === 'daily-movers') { return <DailyMoversSection key={id} userId={user.id} /> } if (id === 'daily-trending') return ( <SearchSection key={id} label={label} sort={'daily-score'} pill="personal" user={user} /> ) const sort = SORTS.find((sort) => sort.value === id) if (sort) return ( <SearchSection key={id} label={label} sort={sort.value} user={user} /> ) if (groups && groupContracts) { const group = groups.find((g) => g.id === id) if (group) { const contracts = groupContracts[group.slug].filter( (c) => Math.abs(c.probChanges.day) >= 0.01 ) if (contracts.length === 0) return null return ( <GroupSection key={id} group={group} user={user} contracts={contracts} /> ) } } return null } function SectionHeader(props: { label: string href: string children?: ReactNode }) { const { label, href, children } = props return ( <Row className="mb-3 items-center justify-between"> <SiteLink className="text-xl" href={href} onClick={() => track('home click section header', { section: href })} > {label}{' '} <ArrowSmRightIcon className="mb-0.5 inline h-6 w-6 text-gray-500" aria-hidden="true" /> </SiteLink> {children} </Row> ) } function SearchSection(props: { label: string user: User sort: Sort pill?: string }) { const { label, user, sort, pill } = props return ( <Col> <SectionHeader label={label} href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`} /> <ContractSearch user={user} defaultSort={sort} defaultPill={pill} noControls maxResults={6} persistPrefix={`home-${sort}`} /> </Col> ) } function GroupSection(props: { group: Group user: User contracts: CPMMBinaryContract[] }) { const { group, user, contracts } = props return ( <Col> <SectionHeader label={group.name} href={groupPath(group.slug)}> <Button color="gray-white" onClick={() => { const homeSections = (user.homeSections ?? []).filter( (id) => id !== group.id ) updateUser(user.id, { homeSections }) toast.promise(leaveGroup(group, user.id), { loading: 'Unfollowing group...', success: `Unfollowed ${group.name}`, error: "Couldn't unfollow group, try again?", }) }} > <XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" /> </Button> </SectionHeader> <ContractsGrid contracts={contracts.slice(0, 4)} cardUIOptions={{ showProbChange: true }} /> </Col> ) } function DailyMoversSection(props: { userId: string | null | undefined }) { const { userId } = props const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter( (c) => Math.abs(c.probChanges.day) >= 0.01 ) if (changes && changes.length === 0) { return null } return ( <Col className="gap-2"> <SectionHeader label="Daily movers" href="/daily-movers" /> <ProbChangeTable changes={changes} /> </Col> ) } function DailyStats(props: { user: User | null | undefined className?: string }) { const { user, className } = props const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] const [first, last] = [metrics[0], metrics[metrics.length - 1]] const privateUser = usePrivateUser() const streaks = privateUser?.notificationPreferences?.betting_streaks ?? [] const streaksHidden = streaks.length === 0 let profit = 0 let profitPercent = 0 if (first && last) { profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) profitPercent = profit / first.investmentValue } return ( <Row className={'gap-4'}> <Col> <div className="text-gray-500">Daily profit</div> <Row className={clsx(className, 'items-center text-lg')}> <span>{formatMoney(profit)}</span>{' '} <ProfitBadge profitPercent={profitPercent * 100} /> </Row> </Col> {!streaksHidden && ( <Col> <div className="text-gray-500">Streak</div> <Row className={clsx( className, 'items-center text-lg', user && !hasCompletedStreakToday(user) && 'grayscale' )} > <span>🔥 {user?.currentBettingStreak ?? 0}</span> </Row> </Col> )} </Row> ) } export function TrendingGroupsSection(props: { user: User | null | undefined full?: boolean className?: string }) { const { user, full, className } = props const memberGroupIds = useMemberGroupIds(user) || [] const groups = useTrendingGroups().filter( (g) => !memberGroupIds.includes(g.id) ) const count = full ? 100 : 25 const chosenGroups = groups.slice(0, count) if (chosenGroups.length === 0) { return null } return ( <Col className={className}> <SectionHeader label="Trending groups" href="/explore-groups"> {!full && <CustomizeButton className="mb-1" />} </SectionHeader> <Row className="flex-wrap gap-2"> {chosenGroups.map((g) => ( <PillButton className="flex flex-row items-center gap-1" key={g.id} selected={memberGroupIds.includes(g.id)} onSelect={() => { if (!user) return if (memberGroupIds.includes(g.id)) leaveGroup(g, user?.id) else { const homeSections = (user.homeSections ?? []) .filter((id) => id !== g.id) .concat(g.id) updateUser(user.id, { homeSections }) toast.promise(joinGroup(g, user.id), { loading: 'Following group...', success: `Followed ${g.name}`, error: "Couldn't follow group, try again?", }) track('home follow group', { group: g.slug }) } }} > <PlusCircleIcon className={'h-5 w-5 flex-shrink-0 text-gray-500'} aria-hidden="true" /> {g.name} </PillButton> ))} </Row> </Col> ) } function CustomizeButton(props: { justIcon?: boolean; className?: string }) { const { justIcon, className } = props return ( <SiteLink className={clsx( className, 'flex flex-row items-center text-xl hover:no-underline' )} href="/home/edit" > <Button size="lg" color="gray" className={clsx('flex gap-2')}> <AdjustmentsIcon className={clsx('h-[24px] w-5 text-gray-500')} aria-hidden="true" /> {!justIcon && 'Customize'} </Button> </SiteLink> ) }