diff --git a/common/calculate.ts b/common/calculate.ts index da4ce13a..e4c9ed07 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) { const { outcome, shares, amount } = bet if (floatingEqual(shares, 0)) continue + const spent = totalSpent[outcome] ?? 0 + const position = totalShares[outcome] ?? 0 + if (amount > 0) { - totalShares[outcome] = (totalShares[outcome] ?? 0) + shares - totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + amount } else if (amount < 0) { - const averagePrice = totalSpent[outcome] / totalShares[outcome] - totalShares[outcome] = totalShares[outcome] + shares - totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares + const averagePrice = position === 0 ? 0 : spent / position + totalShares[outcome] = position + shares + totalSpent[outcome] = spent + averagePrice * shares } } - return sum(Object.values(totalSpent)) + return sum([0, ...Object.values(totalSpent)]) } function getDpmInvested(yourBets: Bet[]) { diff --git a/common/notification.ts b/common/notification.ts index 804ec68e..b42df541 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -247,6 +247,8 @@ export type BetFillData = { creatorOutcome: string probability: number fillAmount: number + limitOrderTotal?: number + limitOrderRemaining?: number } export type ContractResolutionData = { diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index f585f373..3fc0fb2f 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -60,14 +60,6 @@ export const getDefaultNotificationPreferences = ( privateUser?: PrivateUser, noEmails?: boolean ) => { - const { - unsubscribedFromCommentEmails, - unsubscribedFromAnswerEmails, - unsubscribedFromResolutionEmails, - unsubscribedFromWeeklyTrendingEmails, - unsubscribedFromGenericEmails, - } = privateUser || {} - const constructPref = (browserIf: boolean, emailIf: boolean) => { const browser = browserIf ? 'browser' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined @@ -75,84 +67,48 @@ export const getDefaultNotificationPreferences = ( } return { // Watched Markets - all_comments_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_answers_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), + all_comments_on_watched_markets: constructPref(true, false), + all_answers_on_watched_markets: constructPref(true, false), // Comments - tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), - comments_by_followed_users_on_watched_markets: constructPref(true, false), - all_replies_to_my_comments_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_replies_to_my_answers_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), + tips_on_your_comments: constructPref(true, true), + comments_by_followed_users_on_watched_markets: constructPref(true, true), + all_replies_to_my_comments_on_watched_markets: constructPref(true, true), + all_replies_to_my_answers_on_watched_markets: constructPref(true, true), all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( true, - !unsubscribedFromCommentEmails + false ), // Answers - answers_by_followed_users_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), - answers_by_market_creator_on_watched_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), + answers_by_followed_users_on_watched_markets: constructPref(true, true), + answers_by_market_creator_on_watched_markets: constructPref(true, true), all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( true, - !unsubscribedFromAnswerEmails + true ), // On users' markets - your_contract_closed: constructPref( - true, - !unsubscribedFromResolutionEmails - ), // High priority - all_comments_on_my_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), - all_answers_on_my_markets: constructPref( - true, - !unsubscribedFromAnswerEmails - ), + your_contract_closed: constructPref(true, true), // High priority + all_comments_on_my_markets: constructPref(true, true), + all_answers_on_my_markets: constructPref(true, true), subsidized_your_market: constructPref(true, true), // Market updates - resolutions_on_watched_markets: constructPref( - true, - !unsubscribedFromResolutionEmails - ), + resolutions_on_watched_markets: constructPref(true, false), market_updates_on_watched_markets: constructPref(true, false), market_updates_on_watched_markets_with_shares_in: constructPref( true, false ), - resolutions_on_watched_markets_with_shares_in: constructPref( - true, - !unsubscribedFromResolutionEmails - ), + resolutions_on_watched_markets_with_shares_in: constructPref(true, true), //Balance Changes loan_income: constructPref(true, false), betting_streaks: constructPref(true, false), referral_bonuses: constructPref(true, true), unique_bettors_on_your_contract: constructPref(true, false), - tipped_comments_on_watched_markets: constructPref( - true, - !unsubscribedFromCommentEmails - ), + tipped_comments_on_watched_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true), limit_order_fills: constructPref(true, false), @@ -160,17 +116,11 @@ export const getDefaultNotificationPreferences = ( tagged_user: constructPref(true, true), on_new_follow: constructPref(true, true), contract_from_followed_user: constructPref(true, true), - trending_markets: constructPref( - false, - !unsubscribedFromWeeklyTrendingEmails - ), + trending_markets: constructPref(false, true), profit_loss_updates: constructPref(false, true), probability_updates_on_watched_markets: constructPref(true, false), - thank_you_for_purchases: constructPref( - false, - !unsubscribedFromGenericEmails - ), - onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + thank_you_for_purchases: constructPref(false, false), + onboarding_flow: constructPref(false, false), } as notification_preferences } diff --git a/common/user.ts b/common/user.ts index b490ab0c..5ab07d35 100644 --- a/common/user.ts +++ b/common/user.ts @@ -71,6 +71,7 @@ export type PrivateUser = { twitchName: string controlToken: string botEnabled?: boolean + needsRelinking?: boolean } } diff --git a/common/util/random.ts b/common/util/random.ts index c26b361b..93a574ab 100644 --- a/common/util/random.ts +++ b/common/util/random.ts @@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => { ;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] } } + +export function chooseRandomSubset(items: T[], count: number) { + const fiveMinutes = 5 * 60 * 1000 + const seed = Math.round(Date.now() / fiveMinutes).toString() + shuffle(items, createRNG(seed)) + return items.slice(0, count) +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index ebd3f26c..038e0142 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -10,7 +10,7 @@ import { User } from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' -import { groupBy, uniq } from 'lodash' +import { groupBy, sum, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' @@ -416,8 +416,9 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } - //TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they - // have enabled so they will unsubscribe from the least important notifications + //TODO: store all possible reasons why the user might be getting the notification + // and choose the most lenient that they have enabled so they will unsubscribe + // from the least important notifications await notifyRepliedUser() await notifyTaggedUsers() await notifyContractCreator() @@ -479,7 +480,7 @@ export const createBetFillNotification = async ( fromUser: User, toUser: User, bet: Bet, - userBet: LimitBet, + limitBet: LimitBet, contract: Contract, idempotencyKey: string ) => { @@ -491,8 +492,10 @@ export const createBetFillNotification = async ( ) if (!sendToBrowser) return - const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) + const fill = limitBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 + const remainingAmount = + limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount)) const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) @@ -503,7 +506,7 @@ export const createBetFillNotification = async ( reason: 'bet_fill', createdTime: Date.now(), isSeen: false, - sourceId: userBet.id, + sourceId: limitBet.id, sourceType: 'bet', sourceUpdateType: 'updated', sourceUserName: fromUser.name, @@ -516,9 +519,11 @@ export const createBetFillNotification = async ( sourceContractId: contract.id, data: { betOutcome: bet.outcome, - creatorOutcome: userBet.outcome, + creatorOutcome: limitBet.outcome, fillAmount, - probability: userBet.limitProb, + probability: limitBet.limitProb, + limitOrderTotal: limitBet.orderAmount, + limitOrderRemaining: remainingAmount, } as BetFillData, } return await notificationRef.set(removeUndefinedProps(notification)) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 2972a305..5e2a94c0 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,6 +13,10 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract + + // Resolution is handled in resolve-market.ts + if (!previousValue.isResolved && contract.isResolved) return + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 6be187f8..96fe0e6f 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -5,21 +5,15 @@ import { MenuIcon } from '@heroicons/react/solid' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' -import { useMemberGroups } from 'web/hooks/use-group' -import { filterDefined } from 'common/util/array' -import { isArray, keyBy } from 'lodash' -import { User } from 'common/user' -import { Group } from 'common/group' +import { keyBy } from 'lodash' export function ArrangeHome(props: { - user: User | null | undefined - homeSections: string[] - setHomeSections: (sections: string[]) => void + sections: { label: string; id: string }[] + setSectionIds: (sections: string[]) => void }) { - const { user, homeSections, setHomeSections } = props + const { sections, setSectionIds } = props - const groups = useMemberGroups(user?.id) ?? [] - const { itemsById, sections } = getHomeItems(groups, homeSections) + const sectionsById = keyBy(sections, 'id') return ( section.id) + const newSectionIds = sections.map((section) => section.id) - newHomeSections.splice(source.index, 1) - newHomeSections.splice(destination.index, 0, item.id) + newSectionIds.splice(source.index, 1) + newSectionIds.splice(destination.index, 0, section.id) - setHomeSections(newHomeSections) + setSectionIds(newSectionIds) }} > @@ -105,29 +99,3 @@ const SectionItem = (props: { ) } - -export const getHomeItems = (groups: Group[], sections: string[]) => { - // Accommodate old home sections. - if (!isArray(sections)) sections = [] - - const items = [ - { label: 'Trending', id: 'score' }, - { label: 'New for you', id: 'newest' }, - { label: 'Daily movers', id: 'daily-movers' }, - ...groups.map((g) => ({ - label: g.name, - id: g.id, - })), - ] - const itemsById = keyBy(items, 'id') - - const sectionItems = filterDefined(sections.map((id) => itemsById[id])) - - // Add unmentioned items to the end. - sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) - - return { - sections: sectionItems, - itemsById, - } -} diff --git a/web/components/button.tsx b/web/components/button.tsx index cb39cba8..ea9a3e88 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,6 +11,7 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' + | 'highlight-blue' export function Button(props: { className?: string @@ -56,7 +57,9 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', + 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', + color === 'highlight-blue' && + 'text-highlight-blue border-none shadow-none', className )} disabled={disabled} diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 9aa6153f..12e15c24 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -6,9 +6,10 @@ export function PillButton(props: { onSelect: () => void color?: string xs?: boolean + className?: string children: ReactNode }) { - const { children, selected, onSelect, color, xs } = props + const { children, selected, onSelect, color, xs, className } = props return ( - ) + const isMobile = useIsMobile() return ( - - - - {disabled ? ( - creatorName - ) : ( - - )} - {!disabled && } + + + +
+ +
- - {disabled ? ( - groupInfo - ) : !groupToDisplay && !user ? ( -
- ) : ( + {/* GROUPS */} + {isMobile && ( +
+ +
+ )} + + ) +} + +export function MarketSubheader(props: { + contract: Contract + disabled?: boolean +}) { + const { contract, disabled } = props + const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract + const { resolvedDate } = contractMetrics(contract) + const user = useUser() + const isCreator = user?.id === creatorId + const isMobile = useIsMobile() + return ( + + + {!disabled && ( +
+ +
+ )} + + + {disabled ? ( + creatorName + ) : ( + + )} + + + + {!isMobile && ( + + )} + + +
+ ) +} + +export function CloseOrResolveTime(props: { + contract: Contract + resolvedDate: any + isCreator: boolean +}) { + const { contract, resolvedDate, isCreator } = props + const { resolutionTime, closeTime } = contract + if (!!closeTime || !!resolvedDate) { + return ( + + {resolvedDate && resolutionTime ? ( + <> + + +
resolved 
+ {resolvedDate} +
+
+ + ) : null} + + {!resolvedDate && closeTime && ( - {groupInfo} - {user && groupToDisplay && ( - - )} + {dayjs().isBefore(closeTime) &&
closes 
} + {!dayjs().isBefore(closeTime) &&
closed 
} +
)}
+ ) + } else return <> +} + +export function MarketGroups(props: { + contract: Contract + disabled?: boolean +}) { + const [open, setOpen] = useState(false) + const user = useUser() + const { contract, disabled } = props + const groupToDisplay = getGroupLinkToDisplay(contract) + + return ( + <> + + + {!disabled && user && ( + + )} + - - {(!!closeTime || !!resolvedDate) && ( - - {resolvedDate && resolutionTime ? ( - <> - - - {resolvedDate} - - - ) : null} - - {!resolvedDate && closeTime && user && ( - <> - - - - )} - - )} - {user && ( - <> - - -
{volumeLabel}
-
- {!disabled && ( - - )} - - )} - + ) } @@ -287,12 +295,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( + Closes  - Ends ) )} @@ -312,6 +320,24 @@ export function ExtraMobileContractDetails(props: { ) } +export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { + const { groupToDisplay } = props + if (groupToDisplay) { + return ( + + + {groupToDisplay.name} + + + ) + } else + return ( +
+ No Group +
+ ) +} + function EditableCloseDate(props: { closeTime: number contract: Contract @@ -363,47 +389,59 @@ function EditableCloseDate(props: { return ( <> - {isEditingCloseTime ? ( - - e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value)} - min={Date.now()} - value={closeDate} - /> - e.stopPropagation()} - onChange={(e) => setCloseHoursMinutes(e.target.value)} - min="00:00" - value={closeHoursMinutes} - /> - - - ) : ( - Date.now() ? 'Trading ends:' : 'Trading ended:'} - time={closeTime} + + + Date.now() ? 'Trading ends:' : 'Trading ended:'} + time={closeTime} + > + isCreator && setIsEditingCloseTime(true)} > - isCreator && setIsEditingCloseTime(true)} - > - {isSameDay ? ( - {fromNow(closeTime)} - ) : isSameYear ? ( - dayJsCloseTime.format('MMM D') - ) : ( - dayJsCloseTime.format('MMM D, YYYY') - )} - - - )} + {isSameDay ? ( + {fromNow(closeTime)} + ) : isSameYear ? ( + dayJsCloseTime.format('MMM D') + ) : ( + dayJsCloseTime.format('MMM D, YYYY') + )} + + ) } diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 76a48277..5187030d 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline' import clsx from 'clsx' import dayjs from 'dayjs' import { useState } from 'react' +import { capitalize } from 'lodash' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' @@ -19,7 +20,7 @@ import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' import { BETTORS } from 'common/user' -import { capitalize } from 'lodash' +import { Button } from '../button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -39,10 +40,16 @@ export function ContractInfoDialog(props: { const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') - const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = - contract + const { + createdTime, + closeTime, + resolutionTime, + uniqueBettorCount, + mechanism, + outcomeType, + id, + } = contract - const bettorsCount = contract.uniqueBettorCount ?? 'Unknown' const typeDisplay = outcomeType === 'BINARY' ? 'YES / NO' @@ -69,19 +76,21 @@ export function ContractInfoDialog(props: { return ( <> - + - + <Title className="!mt-0 !mb-0" text="This Market" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> @@ -131,14 +140,9 @@ export function ContractInfoDialog(props: { <td>{formatMoney(contract.volume)}</td> </tr> - {/* <tr> - <td>Creator earnings</td> - <td>{formatMoney(contract.collectedFees.creatorFee)}</td> - </tr> */} - <tr> <td>{capitalize(BETTORS)}</td> - <td>{bettorsCount}</td> + <td>{uniqueBettorCount ?? '0'}</td> </tr> <tr> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 1bfe84de..bfb4829f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails, ExtraMobileContractDetails } from './contract-details' +import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> + <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-3 px-2 sm:gap-4"> + <Col className="gap-1 px-2"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -113,10 +112,6 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - forceShowVolume={true} - /> </Col> </Col> ) @@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index e4b95d97..e1ee141e 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -23,6 +23,7 @@ import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' +import { useIsMobile } from 'web/hooks/use-is-mobile' export function ContractTabs(props: { contract: Contract @@ -33,6 +34,7 @@ export function ContractTabs(props: { }) { const { contract, user, bets, tips } = props const { outcomeType } = contract + const isMobile = useIsMobile() const lps = useLiquidity(contract.id) @@ -131,7 +133,12 @@ export function ContractTabs(props: { }, ...(!user || !userBets?.length ? [] - : [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), + : [ + { + title: isMobile ? `You` : `Your ${PAST_BETS}`, + content: yourTrades, + }, + ]), ]} /> {!user ? ( diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index fcf20f02..3da9a5d5 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -110,6 +110,7 @@ export function CreatorContractsList(props: { return ( <ContractSearch + headerClassName="sticky" user={user} defaultSort="newest" defaultFilter="all" diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 5d5ee4d8..af5db9c3 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' -import { withTracking } from 'web/lib/service/analytics' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/challenge' -import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props - const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> + <Row> + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} <Button - size="lg" + size="sm" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Col className={'items-center sm:flex-row'}> - <ShareIcon - className={clsx('h-[24px] w-5 sm:mr-2')} - aria-hidden="true" - /> - <span>Share</span> - </Col> + <Row> + <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> + </Row> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - - {showChallenge && ( - <Button - size="lg" - color="gray-white" - className="max-w-xs self-center" - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <Col className="items-center sm:flex-row"> - <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> - <span>Challenge</span> - </Col> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={setOpenCreateChallengeModal} - user={user} - contract={contract} - /> - </Button> - )} - - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} - <Col className={'justify-center md:hidden'}> + <Col className={'justify-center'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index e35e3e7e..01dce32f 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,15 +38,16 @@ export function LikeMarketButton(props: { return ( <Button - size={'lg'} + size={'sm'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row'}> + <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-[24px] w-5 sm:mr-2', + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -54,7 +55,18 @@ export function LikeMarketButton(props: { : '' )} /> - Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} </Col> </Button> ) diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 49216b88..16de0d44 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -11,8 +11,9 @@ export function ProbChangeTable(props: { changes: | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } | undefined + full?: boolean }) { - const { changes } = props + const { changes, full } = props if (!changes) return <LoadingIndicator /> @@ -24,7 +25,10 @@ export function ProbChangeTable(props: { negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1 ) const maxRows = Math.min(positiveChanges.length, negativeChanges.length) - const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) + const rows = Math.min( + full ? Infinity : 3, + Math.min(maxRows, countOverThreshold) + ) const filteredPositiveChanges = positiveChanges.slice(0, rows) const filteredNegativeChanges = negativeChanges.slice(0, rows) @@ -35,40 +39,33 @@ export function ProbChangeTable(props: { <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"> {filteredPositiveChanges.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> + <ProbChangeRow key={contract.id} contract={contract} /> ))} </Col> <Col className="flex-1 divide-y"> {filteredNegativeChanges.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> + <ProbChangeRow key={contract.id} contract={contract} /> ))} </Col> </Col> ) } +function ProbChangeRow(props: { contract: CPMMContract }) { + const { contract } = props + return ( + <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> + ) +} + export function ProbChange(props: { contract: CPMMContract className?: string diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts index 79525cfc..39d024e7 100644 --- a/web/components/editor/contract-mention-suggestion.ts +++ b/web/components/editor/contract-mention-suggestion.ts @@ -1,19 +1,16 @@ import type { MentionOptions } from '@tiptap/extension-mention' -import { ReactRenderer } from '@tiptap/react' import { searchInAny } from 'common/util/parse' import { orderBy } from 'lodash' -import tippy from 'tippy.js' import { getCachedContracts } from 'web/hooks/use-contracts' import { MentionList } from './contract-mention-list' import { PluginKey } from 'prosemirror-state' +import { makeMentionRender } from './mention-suggestion' type Suggestion = MentionOptions['suggestion'] const beginsWith = (text: string, query: string) => text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) -// copied from https://tiptap.dev/api/nodes/mention#usage -// TODO: merge with mention-suggestion.ts? export const contractMentionSuggestion: Suggestion = { char: '%', allowSpaces: true, @@ -26,51 +23,5 @@ export const contractMentionSuggestion: Suggestion = { [(c) => [c.question].some((s) => beginsWith(s, query))], ['desc', 'desc'] ).slice(0, 5), - render: () => { - let component: ReactRenderer - let popup: ReturnType<typeof tippy> - return { - onStart: (props) => { - component = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }) - if (!props.clientRect) { - return - } - - popup = tippy('body', { - getReferenceClientRect: props.clientRect as any, - appendTo: () => document.body, - content: component?.element, - showOnCreate: true, - interactive: true, - trigger: 'manual', - placement: 'bottom-start', - }) - }, - onUpdate(props) { - component?.updateProps(props) - - if (!props.clientRect) { - return - } - - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect as any, - }) - }, - onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } - return (component?.ref as any)?.onKeyDown(props) - }, - onExit() { - popup?.[0].destroy() - component?.destroy() - }, - } - }, + render: makeMentionRender(MentionList), } diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx index 9e967044..ddc81bc0 100644 --- a/web/components/editor/contract-mention.tsx +++ b/web/components/editor/contract-mention.tsx @@ -31,6 +31,7 @@ const ContractMentionComponent = (props: any) => { * https://tiptap.dev/guide/node-views/react#render-a-react-component */ export const DisplayContractMention = Mention.extend({ + name: 'contract-mention', parseHTML: () => [{ tag: name }], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], addNodeView: () => diff --git a/web/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts index 9f016d47..b4eeeebe 100644 --- a/web/components/editor/mention-suggestion.ts +++ b/web/components/editor/mention-suggestion.ts @@ -5,6 +5,7 @@ import { orderBy } from 'lodash' import tippy from 'tippy.js' import { getCachedUsers } from 'web/hooks/use-users' import { MentionList } from './mention-list' +type Render = Suggestion['render'] type Suggestion = MentionOptions['suggestion'] @@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = { ], ['desc', 'desc'] ).slice(0, 5), - render: () => { + render: makeMentionRender(MentionList), +} + +export function makeMentionRender(mentionList: any): Render { + return () => { let component: ReactRenderer let popup: ReturnType<typeof tippy> return { onStart: (props) => { - component = new ReactRenderer(MentionList, { + component = new ReactRenderer(mentionList, { props, editor: props.editor, }) @@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = { }) }, onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } + if (props.event.key) + if ( + props.event.key === 'Escape' || + // Also break out of the mention if the tooltip isn't visible + (props.event.key === 'Enter' && !popup?.[0].state.isShown) + ) { + popup?.[0].destroy() + component?.destroy() + return false + } return (component?.ref as any)?.onKeyDown(props) }, onExit() { @@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = { component?.destroy() }, } - }, + } } diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 09495169..6344757d 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,4 +1,6 @@ +import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -54,18 +56,73 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const currentUser = useUser() - const following = useFollows(currentUser?.id) + const user = useUser() + const following = useFollows(user?.id) const isFollowing = following?.includes(userId) - if (!currentUser || currentUser.id === userId) return null + if (!user || user.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(currentUser.id, userId)} - onUnfollow={() => unfollow(currentUser.id, userId)} + onFollow={() => follow(user.id, userId)} + onUnfollow={() => unfollow(user.id, userId)} small={small} /> ) } + +export function MiniUserFollowButton(props: { userId: string }) { + const { userId } = props + const user = useUser() + const following = useFollows(user?.id) + const isFollowing = following?.includes(userId) + const isFirstRender = useRef(true) + const [justFollowed, setJustFollowed] = useState(false) + + useEffect(() => { + if (isFirstRender.current) { + if (isFollowing != undefined) { + isFirstRender.current = false + } + return + } + if (isFollowing) { + setJustFollowed(true) + setTimeout(() => { + setJustFollowed(false) + }, 1000) + } + }, [isFollowing]) + + if (justFollowed) { + return ( + <CheckCircleIcon + className={clsx( + 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + ) + } + if ( + !user || + user.id === userId || + isFollowing || + !user || + isFollowing === undefined + ) + return null + return ( + <> + <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> + <PlusCircleIcon + className={clsx( + 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + </button> + </> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 1dd261cb..0e65165b 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'lg'} + size={'sm'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,13 +56,19 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Unwatch + <EyeOffIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Watch + <EyeIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Watch */} </Col> )} <WatchMarketModal diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/bottom-nav-bar.tsx similarity index 90% rename from web/components/nav/nav-bar.tsx rename to web/components/nav/bottom-nav-bar.tsx index a07fa0ad..aeb5a2bc 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/bottom-nav-bar.tsx @@ -17,11 +17,14 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' +import { User } from 'common/user' + import { PAST_BETS } from 'common/user' function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, + { name: 'Search', href: '/search', icon: SearchIcon }, { name: 'Notifications', href: `/notifications`, @@ -32,9 +35,24 @@ function getNavigation() { const signedOutNavigation = [ { name: 'Home', href: '/', icon: HomeIcon }, - { name: 'Explore', href: '/home', icon: SearchIcon }, + { name: 'Explore', href: '/search', icon: SearchIcon }, ] +export const userProfileItem = (user: User) => ({ + name: formatMoney(user.balance), + trackingEventName: 'profile', + href: `/${user.username}?tab=${PAST_BETS}`, + icon: () => ( + <Avatar + className="mx-auto my-1" + size="xs" + username={user.username} + avatarUrl={user.avatarUrl} + noLink + /> + ), +}) + // From https://codepen.io/chris__sev/pen/QWGvYbL export function BottomNavBar() { const [sidebarOpen, setSidebarOpen] = useState(false) @@ -62,20 +80,7 @@ export function BottomNavBar() { <NavBarItem key={'profile'} currentPage={currentPage} - item={{ - name: formatMoney(user.balance), - trackingEventName: 'profile', - href: `/${user.username}?tab=${PAST_BETS}`, - icon: () => ( - <Avatar - className="mx-auto my-1" - size="xs" - username={user.username} - avatarUrl={user.avatarUrl} - noLink - /> - ), - }} + item={userProfileItem(user)} /> )} <div @@ -99,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) return ( - <Link href={item.href}> + <Link href={item.href ?? '#'}> <a className={clsx( 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', diff --git a/web/components/nav/group-nav-bar.tsx b/web/components/nav/group-nav-bar.tsx new file mode 100644 index 00000000..986f35a1 --- /dev/null +++ b/web/components/nav/group-nav-bar.tsx @@ -0,0 +1,94 @@ +import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline' +import { Item } from './sidebar' + +import clsx from 'clsx' +import { trackCallback } from 'web/lib/service/analytics' +import TrophyIcon from 'web/lib/icons/trophy-icon' +import { useUser } from 'web/hooks/use-user' +import NotificationsIcon from '../notifications-icon' +import router from 'next/router' +import { userProfileItem } from './bottom-nav-bar' + +const mobileGroupNavigation = [ + { name: 'Markets', key: 'markets', icon: HomeIcon }, + { name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon }, + { name: 'About', key: 'about', icon: ClipboardIcon }, +] + +const mobileGeneralNavigation = [ + { + name: 'Notifications', + key: 'notifications', + icon: NotificationsIcon, + href: '/notifications', + }, +] + +export function GroupNavBar(props: { + currentPage: string + onClick: (key: string) => void +}) { + const { currentPage } = props + const user = useUser() + + return ( + <nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden"> + {mobileGroupNavigation.map((item) => ( + <NavBarItem + key={item.name} + item={item} + currentPage={currentPage} + onClick={props.onClick} + /> + ))} + + {mobileGeneralNavigation.map((item) => ( + <NavBarItem + key={item.name} + item={item} + currentPage={currentPage} + onClick={() => { + router.push(item.href) + }} + /> + ))} + + {user && ( + <NavBarItem + key={'profile'} + currentPage={currentPage} + onClick={() => { + router.push(`/${user.username}?tab=trades`) + }} + item={userProfileItem(user)} + /> + )} + </nav> + ) +} + +function NavBarItem(props: { + item: Item + currentPage: string + onClick: (key: string) => void +}) { + const { item, currentPage } = props + const track = trackCallback( + `group navbar: ${item.trackingEventName ?? item.name}` + ) + + return ( + <button onClick={() => props.onClick(item.key ?? '#')}> + <a + className={clsx( + 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', + currentPage === item.key && 'bg-gray-200 text-indigo-700' + )} + onClick={track} + > + {item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />} + {item.name} + </a> + </button> + ) +} diff --git a/web/components/nav/group-sidebar.tsx b/web/components/nav/group-sidebar.tsx new file mode 100644 index 00000000..12f9e7a9 --- /dev/null +++ b/web/components/nav/group-sidebar.tsx @@ -0,0 +1,82 @@ +import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { useUser } from 'web/hooks/use-user' +import { ManifoldLogo } from './manifold-logo' +import { ProfileSummary } from './profile-menu' +import React from 'react' +import TrophyIcon from 'web/lib/icons/trophy-icon' +import { SignInButton } from '../sign-in-button' +import NotificationsIcon from '../notifications-icon' +import { SidebarItem } from './sidebar' +import { buildArray } from 'common/util/array' +import { User } from 'common/user' +import { Row } from '../layout/row' +import { Spacer } from '../layout/spacer' + +const groupNavigation = [ + { name: 'Markets', key: 'markets', icon: HomeIcon }, + { name: 'About', key: 'about', icon: ClipboardIcon }, + { name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon }, +] + +const generalNavigation = (user?: User | null) => + buildArray( + user && { + name: 'Notifications', + href: `/notifications`, + key: 'notifications', + icon: NotificationsIcon, + } + ) + +export function GroupSidebar(props: { + groupName: string + className?: string + onClick: (key: string) => void + joinOrAddQuestionsButton: React.ReactNode + currentKey: string +}) { + const { className, groupName, currentKey } = props + + const user = useUser() + + return ( + <nav + aria-label="Group Sidebar" + className={clsx('flex max-h-[100vh] flex-col', className)} + > + <ManifoldLogo className="pt-6" twoLine /> + <Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row> + + <div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex "> + {user ? ( + <ProfileSummary user={user} /> + ) : ( + <SignInButton className="mb-4" /> + )} + </div> + + {/* Desktop navigation */} + {groupNavigation.map((item) => ( + <SidebarItem + key={item.key} + item={item} + currentPage={currentKey} + onClick={props.onClick} + /> + ))} + {generalNavigation(user).map((item) => ( + <SidebarItem + key={item.key} + item={item} + currentPage={currentKey} + onClick={props.onClick} + /> + ))} + + <Spacer h={2} /> + + {props.joinOrAddQuestionsButton} + </nav> + ) +} diff --git a/web/components/nav/menu.tsx b/web/components/nav/menu.tsx index f61ebad9..492488d8 100644 --- a/web/components/nav/menu.tsx +++ b/web/components/nav/menu.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' export type MenuItem = { name: string - href: string + href?: string onClick?: () => void } @@ -38,11 +38,11 @@ export function MenuButton(props: { {({ active }) => ( <a href={item.href} - target={item.href.startsWith('http') ? '_blank' : undefined} + target={item.href?.startsWith('http') ? '_blank' : undefined} onClick={item.onClick} className={clsx( active ? 'bg-gray-100' : '', - 'line-clamp-3 block py-1.5 px-4 text-sm text-gray-700' + 'line-clamp-3 block cursor-pointer py-1.5 px-4 text-sm text-gray-700' )} > {item.name} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index d7adfa28..ae03655b 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router' import { useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' -import { MenuButton } from './menu' +import { MenuButton, MenuItem } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' import React from 'react' @@ -35,6 +35,7 @@ const logout = async () => { function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, + { name: 'Search', href: '/search', icon: SearchIcon }, { name: 'Notifications', href: `/notifications`, @@ -100,7 +101,7 @@ function getMoreNavigation(user?: User | null) { const signedOutNavigation = [ { name: 'Home', href: '/', icon: HomeIcon }, - { name: 'Explore', href: '/home', icon: SearchIcon }, + { name: 'Explore', href: '/search', icon: SearchIcon }, { name: 'Help & About', href: 'https://help.manifold.markets/', @@ -139,7 +140,7 @@ function getMoreMobileNav() { } if (IS_PRIVATE_MANIFOLD) return [signOut] - return buildArray<Item>( + return buildArray<MenuItem>( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ { name: 'Groups', href: '/groups' }, @@ -156,39 +157,59 @@ function getMoreMobileNav() { export type Item = { name: string trackingEventName?: string - href: string + href?: string + key?: string icon?: React.ComponentType<{ className?: string }> } -function SidebarItem(props: { item: Item; currentPage: string }) { - const { item, currentPage } = props - return ( - <Link href={item.href} key={item.name}> - <a - onClick={trackCallback('sidebar: ' + item.name)} - className={clsx( - item.href == currentPage - ? 'bg-gray-200 text-gray-900' - : 'text-gray-600 hover:bg-gray-100', - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium' - )} - aria-current={item.href == currentPage ? 'page' : undefined} - > - {item.icon && ( - <item.icon - className={clsx( - item.href == currentPage - ? 'text-gray-500' - : 'text-gray-400 group-hover:text-gray-500', - '-ml-1 mr-3 h-6 w-6 flex-shrink-0' - )} - aria-hidden="true" - /> - )} - <span className="truncate">{item.name}</span> - </a> - </Link> +export function SidebarItem(props: { + item: Item + currentPage: string + onClick?: (key: string) => void +}) { + const { item, currentPage, onClick } = props + const isCurrentPage = + item.href != null ? item.href === currentPage : item.key === currentPage + + const sidebarItem = ( + <a + onClick={trackCallback('sidebar: ' + item.name)} + className={clsx( + isCurrentPage + ? 'bg-gray-200 text-gray-900' + : 'text-gray-600 hover:bg-gray-100', + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium' + )} + aria-current={item.href == currentPage ? 'page' : undefined} + > + {item.icon && ( + <item.icon + className={clsx( + isCurrentPage + ? 'text-gray-500' + : 'text-gray-400 group-hover:text-gray-500', + '-ml-1 mr-3 h-6 w-6 flex-shrink-0' + )} + aria-hidden="true" + /> + )} + <span className="truncate">{item.name}</span> + </a> ) + + if (item.href) { + return ( + <Link href={item.href} key={item.name}> + {sidebarItem} + </Link> + ) + } else { + return onClick ? ( + <button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button> + ) : ( + <> </> + ) + } } function SidebarButton(props: { diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index b806dfb2..7c1f3546 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -63,7 +63,6 @@ export function NotificationSettings(props: { 'contract_from_followed_user', 'unique_bettors_on_your_contract', // TODO: add these - // one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications // 'profit_loss_updates', - changes in markets you have shares in // biggest winner, here are the rest of your markets diff --git a/web/components/onboarding/group-selector-dialog.tsx b/web/components/onboarding/group-selector-dialog.tsx new file mode 100644 index 00000000..e109e356 --- /dev/null +++ b/web/components/onboarding/group-selector-dialog.tsx @@ -0,0 +1,89 @@ +import { sortBy } from 'lodash' +import React, { useRef } from 'react' + +import { Col } from 'web/components/layout/col' +import { Title } from 'web/components/title' +import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' +import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { useUser } from 'web/hooks/use-user' +import { Modal } from 'web/components/layout/modal' +import { PillButton } from 'web/components/buttons/pill-button' +import { Button } from 'web/components/button' +import { Group } from 'common/group' + +export default function GroupSelectorDialog(props: { + open: boolean + setOpen: (open: boolean) => void +}) { + const { open, setOpen } = props + + const groups = useGroups() + const user = useUser() + const memberGroupIds = useMemberGroupIds(user) || [] + const cachedGroups = useRef<Group[]>() + + if (groups && !cachedGroups.current) { + cachedGroups.current = groups + } + + const excludedGroups = [ + 'features', + 'personal', + 'private', + 'nomic', + 'proofnik', + 'free money', + 'motivation', + 'sf events', + 'please resolve', + 'short-term', + 'washifold', + ] + + const displayedGroups = sortBy(cachedGroups.current ?? [], [ + (group) => -1 * group.totalMembers, + (group) => -1 * group.totalContracts, + ]) + .filter((group) => group.anyoneCanJoin) + .filter((group) => + excludedGroups.every((name) => !group.name.toLowerCase().includes(name)) + ) + .filter( + (group) => + (group.mostRecentContractAddedTime ?? 0) > + Date.now() - 1000 * 60 * 60 * 24 * 7 + ) + .slice(0, 30) + + return ( + <Modal open={open} setOpen={setOpen}> + <Col className="h-[32rem] rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> + <Title text="What interests you?" /> + <p className="mb-4"> + Choose among the categories below to personalize your Manifold + experience. + </p> + + <div className="scrollbar-hide items-start gap-2 overflow-x-auto"> + {user && + displayedGroups.map((group) => ( + <PillButton + selected={memberGroupIds.includes(group.id)} + onSelect={() => + memberGroupIds.includes(group.id) + ? leaveGroup(group, user.id) + : joinGroup(group, user.id) + } + className="mr-1 mb-2 max-w-[12rem] truncate" + > + {group.name} + </PillButton> + ))} + </div> + </Col> + <Col> + <Button onClick={() => setOpen(false)}>Done</Button> + </Col> + </Modal> + ) +} diff --git a/web/components/onboarding/welcome.tsx b/web/components/onboarding/welcome.tsx index 5a187a24..654357c5 100644 --- a/web/components/onboarding/welcome.tsx +++ b/web/components/onboarding/welcome.tsx @@ -7,6 +7,7 @@ import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' import { Title } from '../title' +import GroupSelectorDialog from './group-selector-dialog' export default function Welcome() { const user = useUser() @@ -32,17 +33,26 @@ export default function Welcome() { } } - if (!user || !user.shouldShowWelcome) { - return <></> - } else - return ( - <Modal - open={open} - setOpen={(newOpen) => { - setUserHasSeenWelcome() - setOpen(newOpen) - }} - > + const [groupSelectorOpen, setGroupSelectorOpen] = useState(false) + + if (!user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <></> + + const toggleOpen = (isOpen: boolean) => { + setUserHasSeenWelcome() + setOpen(isOpen) + + if (!isOpen) { + setGroupSelectorOpen(true) + } + } + return ( + <> + <GroupSelectorDialog + open={groupSelectorOpen} + setOpen={() => setGroupSelectorOpen(false)} + /> + + <Modal open={open} setOpen={toggleOpen}> <Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> {page === 0 && <Page0 />} {page === 1 && <Page1 />} @@ -68,17 +78,15 @@ export default function Welcome() { </Row> <u className="self-center text-xs text-gray-500" - onClick={() => { - setOpen(false) - setUserHasSeenWelcome() - }} + onClick={() => toggleOpen(false)} > I got the gist, exit welcome </u> </Col> </Col> </Modal> - ) + </> + ) } function PageIndicator(props: { page: number; totalpages: number }) { diff --git a/web/components/page.tsx b/web/components/page.tsx index 826206f4..9b26e9f8 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import { ReactNode } from 'react' -import { BottomNavBar } from './nav/nav-bar' +import { BottomNavBar } from './nav/bottom-nav-bar' import Sidebar from './nav/sidebar' import { Toaster } from 'react-hot-toast' diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx deleted file mode 100644 index b284b242..00000000 --- a/web/components/profile/twitch-panel.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import clsx from 'clsx' -import { MouseEventHandler, ReactNode, useState } from 'react' -import toast from 'react-hot-toast' - -import { LinkIcon } from '@heroicons/react/solid' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { updatePrivateUser } from 'web/lib/firebase/users' -import { track } from 'web/lib/service/analytics' -import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' -import { copyToClipboard } from 'web/lib/util/copy' -import { Button, ColorType } from './../button' -import { Row } from './../layout/row' -import { LoadingIndicator } from './../loading-indicator' - -function BouncyButton(props: { - children: ReactNode - onClick?: MouseEventHandler<any> - color?: ColorType -}) { - const { children, onClick, color } = props - return ( - <Button - color={color} - size="lg" - onClick={onClick} - className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" - > - {children} - </Button> - ) -} - -export function TwitchPanel() { - const user = useUser() - const privateUser = usePrivateUser() - - const twitchInfo = privateUser?.twitchInfo - const twitchName = privateUser?.twitchInfo?.twitchName - const twitchToken = privateUser?.twitchInfo?.controlToken - const twitchBotConnected = privateUser?.twitchInfo?.botEnabled - - const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> - - const copyOverlayLink = async () => { - copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`) - toast.success('Overlay link copied!', { - icon: linkIcon, - }) - } - - const copyDockLink = async () => { - copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`) - toast.success('Dock link copied!', { - icon: linkIcon, - }) - } - - const updateBotConnected = (connected: boolean) => async () => { - if (user && twitchInfo) { - twitchInfo.botEnabled = connected - await updatePrivateUser(user.id, { twitchInfo }) - } - } - - const [twitchLoading, setTwitchLoading] = useState(false) - - const createLink = async () => { - if (!user || !privateUser) return - setTwitchLoading(true) - - const promise = linkTwitchAccountRedirect(user, privateUser) - track('link twitch from profile') - await promise - - setTwitchLoading(false) - } - - return ( - <> - <div> - <label className="label">Twitch</label> - - {!twitchName ? ( - <Row> - <Button - color="indigo" - onClick={createLink} - disabled={twitchLoading} - > - Link your Twitch account - </Button> - {twitchLoading && <LoadingIndicator className="ml-4" />} - </Row> - ) : ( - <Row> - <span className="mr-4 text-gray-500">Linked Twitch account</span>{' '} - {twitchName} - </Row> - )} - </div> - - {twitchToken && ( - <div> - <div className="flex w-full"> - <div - className={clsx( - 'flex grow gap-4', - twitchToken ? '' : 'tooltip tooltip-top' - )} - data-tip="You must link your Twitch account first" - > - <BouncyButton color="blue" onClick={copyOverlayLink}> - Copy overlay link - </BouncyButton> - <BouncyButton color="indigo" onClick={copyDockLink}> - Copy dock link - </BouncyButton> - {twitchBotConnected ? ( - <BouncyButton color="red" onClick={updateBotConnected(false)}> - Remove bot from your channel - </BouncyButton> - ) : ( - <BouncyButton color="green" onClick={updateBotConnected(true)}> - Add bot to your channel - </BouncyButton> - )} - </div> - </div> - </div> - )} - </> - ) -} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 87eefa38..f2403a15 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -2,17 +2,19 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Contract, - listenForActiveContracts, listenForContracts, listenForHotContracts, listenForInactiveContracts, - listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, listAllContracts, + trendingContractsQuery, + getContractsQuery, } from 'web/lib/firebase/contracts' import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' +import { query, limit } from 'firebase/firestore' +import { Sort } from 'web/components/contract-search' export const useContracts = () => { const [contracts, setContracts] = useState<Contract[] | undefined>() @@ -30,23 +32,25 @@ export const getCachedContracts = async () => staleTime: Infinity, }) -export const useActiveContracts = () => { - const [activeContracts, setActiveContracts] = useState< - Contract[] | undefined - >() - const [newContracts, setNewContracts] = useState<Contract[] | undefined>() +export const useTrendingContracts = (maxContracts: number) => { + const result = useFirestoreQueryData( + ['trending-contracts', maxContracts], + query(trendingContractsQuery, limit(maxContracts)) + ) + return result.data +} - useEffect(() => { - return listenForActiveContracts(setActiveContracts) - }, []) - - useEffect(() => { - return listenForNewContracts(setNewContracts) - }, []) - - if (!activeContracts || !newContracts) return undefined - - return [...activeContracts, ...newContracts] +export const useContractsQuery = ( + sort: Sort, + maxContracts: number, + filters: { groupSlug?: string } = {}, + visibility?: 'public' +) => { + const result = useFirestoreQueryData( + ['contracts-query', sort, maxContracts, filters], + getContractsQuery(sort, maxContracts, filters, visibility) + ) + return result.data } export const useInactiveContracts = () => { diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 781da9cb..d3d8dd9f 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -11,13 +11,17 @@ import { listenForMemberGroupIds, listenForOpenGroups, listGroups, + topFollowedGroupsQuery, } from 'web/lib/firebase/groups' import { getUser } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' -import { uniq } from 'lodash' +import { keyBy, uniq, uniqBy } from 'lodash' import { listenForValues } from 'web/lib/firebase/utils' import { useQuery } from 'react-query' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { limit, query } from 'firebase/firestore' +import { useTrendingContracts } from './use-contracts' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -49,6 +53,30 @@ export const useOpenGroups = () => { return groups } +export const useTopFollowedGroups = (count: number) => { + const result = useFirestoreQueryData( + ['top-followed-contracts', count], + query(topFollowedGroupsQuery, limit(count)) + ) + return result.data +} + +export const useTrendingGroups = () => { + const topGroups = useTopFollowedGroups(200) + const groupsById = keyBy(topGroups, 'id') + + const trendingContracts = useTrendingContracts(200) + + const groupLinks = uniqBy( + (trendingContracts ?? []).map((c) => c.groupLinks ?? []).flat(), + (link) => link.groupId + ) + + return filterDefined( + groupLinks.map((link) => groupsById[link.groupId]) + ).filter((group) => group.totalMembers >= 3) +} + export const useMemberGroups = (userId: string | null | undefined) => { const result = useQuery(['member-groups', userId ?? ''], () => getMemberGroups(userId ?? '') @@ -56,10 +84,11 @@ export const useMemberGroups = (userId: string | null | undefined) => { return result.data } -// Note: We cache member group ids in localstorage to speed up the initial load export const useMemberGroupIds = (user: User | null | undefined) => { + const cachedGroups = useMemberGroups(user?.id) + const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>( - undefined + cachedGroups?.map((g) => g.id) ) useEffect(() => { diff --git a/web/hooks/use-is-mobile.ts b/web/hooks/use-is-mobile.ts new file mode 100644 index 00000000..9ce0133c --- /dev/null +++ b/web/hooks/use-is-mobile.ts @@ -0,0 +1,7 @@ +import { useWindowSize } from 'web/hooks/use-window-size' + +// matches talwind sm breakpoint +export function useIsMobile() { + const { width } = useWindowSize() + return (width ?? 0) < 640 +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index d8ce025e..1de25bab 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -98,7 +98,7 @@ export function groupNotifications(notifications: Notification[]) { const notificationGroup: NotificationGroup = { notifications: notificationsForContractId, groupedById: contractId, - isSeen: notificationsForContractId[0].isSeen, + isSeen: notificationsForContractId.some((n) => !n.isSeen), timePeriod: day, type: 'normal', } diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts index 46d78b3c..5d95baf4 100644 --- a/web/hooks/use-prefetch.ts +++ b/web/hooks/use-prefetch.ts @@ -1,5 +1,6 @@ import { usePrefetchUserBetContracts } from './use-contracts' import { usePrefetchPortfolioHistory } from './use-portfolio-history' +import { usePrefetchProbChanges } from './use-prob-changes' import { usePrefetchUserBets } from './use-user-bets' export function usePrefetch(userId: string | undefined) { @@ -8,5 +9,6 @@ export function usePrefetch(userId: string | undefined) { usePrefetchUserBets(maybeUserId), usePrefetchUserBetContracts(maybeUserId), usePrefetchPortfolioHistory(maybeUserId, 'weekly'), + usePrefetchProbChanges(userId), ]) } diff --git a/web/hooks/use-prob-changes.tsx b/web/hooks/use-prob-changes.tsx index c5e2c9bd..699b67ee 100644 --- a/web/hooks/use-prob-changes.tsx +++ b/web/hooks/use-prob-changes.tsx @@ -1,8 +1,11 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { MINUTE_MS } from 'common/util/time' +import { useQueryClient } from 'react-query' import { getProbChangesNegative, getProbChangesPositive, } from 'web/lib/firebase/contracts' +import { getValues } from 'web/lib/firebase/utils' export const useProbChanges = (userId: string) => { const { data: positiveChanges } = useFirestoreQueryData( @@ -20,3 +23,19 @@ export const useProbChanges = (userId: string) => { return { positiveChanges, negativeChanges } } + +export const usePrefetchProbChanges = (userId: string | undefined) => { + const queryClient = useQueryClient() + if (userId) { + queryClient.prefetchQuery( + ['prob-changes-day-positive', userId], + () => getValues(getProbChangesPositive(userId)), + { staleTime: MINUTE_MS } + ) + queryClient.prefetchQuery( + ['prob-changes-day-negative', userId], + () => getValues(getProbChangesNegative(userId)), + { staleTime: MINUTE_MS } + ) + } +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 51ec3108..33f6533b 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract, CPMMContract } from 'common/contract' -import { createRNG, shuffle } from 'common/util/random' +import { chooseRandomSubset } from 'common/util/random' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' import { getBinaryProb } from 'common/contract-details' +import { Sort } from 'web/components/contract-search' export const contracts = coll<Contract>('contracts') @@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) { ) as Query<Contract> } -const activeContractsQuery = query( - contracts, - where('isResolved', '==', false), - where('visibility', '==', 'public'), - where('volume7Days', '>', 0) -) - -export function getActiveContracts() { - return getValues<Contract>(activeContractsQuery) -} - -export function listenForActiveContracts( - setContracts: (contracts: Contract[]) => void -) { - return listenForValues<Contract>(activeContractsQuery, setContracts) -} - const inactiveContractsQuery = query( contracts, where('isResolved', '==', false), @@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) { await deleteDoc(followDoc) } -function chooseRandomSubset(contracts: Contract[], count: number) { - const fiveMinutes = 5 * 60 * 1000 - const seed = Math.round(Date.now() / fiveMinutes).toString() - shuffle(contracts, createRNG(seed)) - return contracts.slice(0, count) -} - const hotContractsQuery = query( contracts, where('isResolved', '==', false), @@ -282,16 +259,17 @@ export function listenForHotContracts( }) } -const trendingContractsQuery = query( +export const trendingContractsQuery = query( contracts, where('isResolved', '==', false), where('visibility', '==', 'public'), - orderBy('popularityScore', 'desc'), - limit(10) + orderBy('popularityScore', 'desc') ) -export async function getTrendingContracts() { - return await getValues<Contract>(trendingContractsQuery) +export async function getTrendingContracts(maxContracts = 10) { + return await getValues<Contract>( + query(trendingContractsQuery, limit(maxContracts)) + ) } export async function getContractsBySlugs(slugs: string[]) { @@ -343,6 +321,51 @@ export const getTopGroupContracts = async ( return await getValues<Contract>(creatorContractsQuery) } +const sortToField = { + newest: 'createdTime', + score: 'popularityScore', + 'most-traded': 'volume', + '24-hour-vol': 'volume24Hours', + 'prob-change-day': 'probChanges.day', + 'last-updated': 'lastUpdated', + liquidity: 'totalLiquidity', + 'close-date': 'closeTime', + 'resolve-date': 'resolutionTime', + 'prob-descending': 'prob', + 'prob-ascending': 'prob', +} as const + +const sortToDirection = { + newest: 'desc', + score: 'desc', + 'most-traded': 'desc', + '24-hour-vol': 'desc', + 'prob-change-day': 'desc', + 'last-updated': 'desc', + liquidity: 'desc', + 'close-date': 'asc', + 'resolve-date': 'desc', + 'prob-ascending': 'asc', + 'prob-descending': 'desc', +} as const + +export const getContractsQuery = ( + sort: Sort, + maxItems: number, + filters: { groupSlug?: string } = {}, + visibility?: 'public' +) => { + const { groupSlug } = filters + return query( + contracts, + where('isResolved', '==', false), + ...(visibility ? [where('visibility', '==', visibility)] : []), + ...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []), + orderBy(sortToField[sort], sortToDirection[sort]), + limit(maxItems) + ) +} + export const getRecommendedContracts = async ( contract: Contract, excludeBettorId: string, diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index f27460d9..61424b8f 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -6,6 +6,7 @@ import { doc, getDocs, onSnapshot, + orderBy, query, setDoc, updateDoc, @@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) return members.map((m) => m.userId) } + +export const topFollowedGroupsQuery = query( + groups, + where('anyoneCanJoin', '==', true), + orderBy('totalMembers', 'desc') +) diff --git a/web/lib/icons/corner-down-right-icon.tsx b/web/lib/icons/corner-down-right-icon.tsx new file mode 100644 index 00000000..37d61afa --- /dev/null +++ b/web/lib/icons/corner-down-right-icon.tsx @@ -0,0 +1,19 @@ +export default function CornerDownRightIcon(props: { className?: string }) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + className={props.className} + > + <polyline points="15 10 20 15 15 20"></polyline> + <path d="M4 4v7a4 4 0 0 0 4 4h12"></path> + </svg> + ) +} diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts index 36fb12b5..f36a03b3 100644 --- a/web/lib/twitch/link-twitch-account.ts +++ b/web/lib/twitch/link-twitch-account.ts @@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key' const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately +async function postToBot(url: string, body: unknown) { + const result = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await result.json() + if (!result.ok) { + throw new Error(json.message) + } else { + return json + } +} + export async function initLinkTwitchAccount( manifoldUserID: string, manifoldUserAPIKey: string ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { - const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - manifoldID: manifoldUserID, - apiKey: manifoldUserAPIKey, - redirectURL: window.location.href, - }), + const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, }) - const responseData = await response.json() - if (!response.ok) { - throw new Error(responseData.message) - } const responseFetch = fetch( `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` ) - return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] + return [response.twitchAuthURL, responseFetch.then((r) => r.json())] } export async function linkTwitchAccountRedirect( @@ -38,4 +42,34 @@ export async function linkTwitchAccountRedirect( const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) window.location.href = twitchAuthURL + await new Promise((r) => setTimeout(r, 1e10)) // Wait "forever" for the page to change location +} + +export async function updateBotEnabledForUser( + privateUser: PrivateUser, + botEnabled: boolean +) { + if (botEnabled) { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } else { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } +} + +export function getOverlayURLForUser(privateUser: PrivateUser) { + const controlToken = privateUser?.twitchInfo?.controlToken + return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}` +} + +export function getDockURLForUser(privateUser: PrivateUser) { + const controlToken = privateUser?.twitchInfo?.controlToken + return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}` } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2c011c90..a0b2ed50 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,7 +37,6 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -257,7 +256,6 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> - <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx new file mode 100644 index 00000000..1e5b4c48 --- /dev/null +++ b/web/pages/daily-movers.tsx @@ -0,0 +1,21 @@ +import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { Col } from 'web/components/layout/col' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useProbChanges } from 'web/hooks/use-prob-changes' +import { useUser } from 'web/hooks/use-user' + +export default function DailyMovers() { + const user = useUser() + + const changes = useProbChanges(user?.id ?? '') + + return ( + <Page> + <Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4"> + <Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" /> + <ProbChangeTable changes={changes} full /> + </Col> + </Page> + ) +} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index fbeef88f..62dd1ae1 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -11,7 +11,7 @@ import { NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' -import { ContractDetails } from 'web/components/contract/contract-details' +import { MarketSubheader } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' import { NumericGraph } from 'web/components/contract/numeric-graph' import { Col } from 'web/components/layout/col' @@ -102,50 +102,40 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { return ( <Col className="h-[100vh] w-full bg-white"> - <div className="relative flex flex-col pt-2"> - <div className="px-3 text-xl text-indigo-700 md:text-2xl"> + <Row className="justify-between gap-4 px-2"> + <div className="text-xl text-indigo-700 md:text-2xl"> <SiteLink href={href}>{question}</SiteLink> </div> + {isBinary && ( + <BinaryResolutionOrChance contract={contract} probAfter={probAfter} /> + )} - <Spacer h={3} /> + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation contract={contract} /> + )} - <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails contract={contract} disabled /> + {outcomeType === 'FREE_RESPONSE' && ( + <FreeResponseResolutionOrChance contract={contract} truncate="long" /> + )} - {(isBinary || isPseudoNumeric) && - tradingAllowed(contract) && - !betPanelOpen && ( - <Button color="gradient" onClick={() => setBetPanelOpen(true)}> - Predict - </Button> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation contract={contract} /> + )} + </Row> + <Spacer h={3} /> + <Row className="items-center justify-between gap-4 px-2"> + <MarketSubheader contract={contract} disabled /> - {isBinary && ( - <BinaryResolutionOrChance - contract={contract} - probAfter={probAfter} - className="items-center" - /> + {(isBinary || isPseudoNumeric) && + tradingAllowed(contract) && + !betPanelOpen && ( + <Button color="gradient" onClick={() => setBetPanelOpen(true)}> + Predict + </Button> )} + </Row> - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation contract={contract} /> - )} - - {outcomeType === 'FREE_RESPONSE' && ( - <FreeResponseResolutionOrChance - contract={contract} - truncate="long" - /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation contract={contract} /> - )} - </Row> - - <Spacer h={2} /> - </div> + <Spacer h={2} /> {(isBinary || isPseudoNumeric) && betPanelOpen && ( <BetInline diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx deleted file mode 100644 index f5734918..00000000 --- a/web/pages/experimental/home/index.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react' -import Router from 'next/router' -import { - AdjustmentsIcon, - 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 { 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 { useUser } from 'web/hooks/use-user' -import { useMemberGroups } from 'web/hooks/use-group' -import { Button } from 'web/components/button' -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 { formatMoney } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { ProfitBadge } from 'web/components/bets-list' -import { calculatePortfolioProfit } from 'common/calculate-metrics' - -export default function Home() { - const user = useUser() - - useTracking('view home') - - useSaveReferral() - - const groups = useMemberGroups(user?.id) ?? [] - - const { sections } = getHomeItems(groups, user?.homeSections ?? []) - - return ( - <Page> - <Col className="pm:mx-10 gap-4 px-4 pb-12"> - <Row className={'mt-4 w-full items-start justify-between'}> - <Row className="items-end gap-4"> - <Title className="!mb-1 !mt-0" text="Home" /> - <EditButton /> - </Row> - <DailyProfitAndBalance className="" user={user} /> - </Row> - - {sections.map((item) => { - const { id } = item - if (id === 'daily-movers') { - return <DailyMoversSection key={id} userId={user?.id} /> - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.value === 'newest' ? 'New for you' : sort.label} - sort={sort.value} - followed={sort.value === 'newest'} - user={user} - /> - ) - - const group = groups.find((g) => g.id === id) - if (group) return <GroupSection key={id} group={group} user={user} /> - - return null - })} - </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-3 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') - }} - > - <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> - </button> - </Page> - ) -} - -function SearchSection(props: { - label: string - user: User | null | undefined | undefined - sort: Sort - yourBets?: boolean - followed?: boolean -}) { - const { label, user, sort, yourBets, followed } = props - - return ( - <Col> - <SectionHeader label={label} href={`/home?s=${sort}`} /> - <ContractSearch - user={user} - defaultSort={sort} - additionalFilter={ - yourBets - ? { yourBets: true } - : followed - ? { followed: true } - : undefined - } - noControls - maxResults={6} - persistPrefix={`experimental-home-${sort}`} - /> - </Col> - ) -} - -function GroupSection(props: { - group: Group - user: User | null | undefined | undefined -}) { - const { group, user } = props - - return ( - <Col> - <SectionHeader label={group.name} href={groupPath(group.slug)} /> - <ContractSearch - user={user} - defaultSort={'score'} - additionalFilter={{ groupSlug: group.slug }} - noControls - maxResults={6} - persistPrefix={`experimental-home-${group.slug}`} - /> - </Col> - ) -} - -function DailyMoversSection(props: { userId: string | null | undefined }) { - const { userId } = props - const changes = useProbChanges(userId ?? '') - - return ( - <Col className="gap-2"> - <SectionHeader label="Daily movers" href="daily-movers" /> - <ProbChangeTable changes={changes} /> - </Col> - ) -} - -function SectionHeader(props: { label: string; href: string }) { - const { label, href } = props - - return ( - <Row className="mb-3 items-center justify-between"> - <SiteLink className="text-xl" href={href}> - {label}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> - </Row> - ) -} - -function EditButton(props: { className?: string }) { - const { className } = props - - return ( - <SiteLink href="/experimental/home/edit"> - <Button size="sm" color="gray-white" className={clsx(className, 'flex')}> - <AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" /> - </Button> - </SiteLink> - ) -} - -function DailyProfitAndBalance(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]] - - if (first === undefined || last === undefined) return null - - const profit = - calculatePortfolioProfit(last) - calculatePortfolioProfit(first) - const 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> - <Col> - <div className="text-gray-500">Streak</div> - <Row className={clsx(className, 'items-center text-lg')}> - <span>🔥 {user?.currentBettingStreak ?? 0}</span> - </Row> - </Col> - </Row> - ) -} diff --git a/web/pages/explore-groups.tsx b/web/pages/explore-groups.tsx new file mode 100644 index 00000000..222aba13 --- /dev/null +++ b/web/pages/explore-groups.tsx @@ -0,0 +1,41 @@ +import Masonry from 'react-masonry-css' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useMemberGroupIds, useTrendingGroups } from 'web/hooks/use-group' +import { useUser } from 'web/hooks/use-user' +import { GroupCard } from './groups' + +export default function Explore() { + const user = useUser() + const groups = useTrendingGroups() + const memberGroupIds = useMemberGroupIds(user) || [] + + return ( + <Page> + <Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[115%]"> + <Row className={'w-full items-center justify-between'}> + <Title className="!mb-0" text="Trending groups" /> + </Row> + + <Masonry + breakpointCols={{ default: 3, 1200: 2, 570: 1 }} + className="-ml-4 flex w-auto self-center" + columnClassName="pl-4 bg-clip-padding" + > + {groups.map((g) => ( + <GroupCard + key={g.id} + className="mb-4 !min-w-[250px]" + group={g} + creator={null} + user={user} + isMember={memberGroupIds.includes(g.id)} + /> + ))} + </Masonry> + </Col> + </Page> + ) +} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 70b06ac5..1edcc638 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { toast } from 'react-hot-toast' +import { toast, Toaster } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' -import { Page } from 'web/components/page' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, @@ -30,7 +29,7 @@ import Custom404 from '../../404' import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { Tabs } from 'web/components/layout/tabs' + import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' @@ -49,6 +48,9 @@ import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' +import { GroupNavBar } from 'web/components/nav/group-nav-bar' +import { ArrowLeftIcon } from '@heroicons/react/solid' +import { GroupSidebar } from 'web/components/nav/group-sidebar' import { SelectMarketsModal } from 'web/components/contract-select-modal' import { BETTORS } from 'common/user' @@ -138,6 +140,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds + const [sidebarIndex, setSidebarIndex] = useState(0) useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -151,7 +154,7 @@ export default function GroupPage(props: { const isMember = user && memberIds.includes(user.id) const maxLeaderboardSize = 50 - const leaderboard = ( + const leaderboardPage = ( <Col> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <GroupLeaderboard @@ -170,7 +173,7 @@ export default function GroupPage(props: { </Col> ) - const aboutTab = ( + const aboutPage = ( <Col> {(group.aboutPostId != null || isCreator || isAdmin) && ( <GroupAboutPost @@ -190,73 +193,118 @@ export default function GroupPage(props: { </Col> ) - const questionsTab = ( - <ContractSearch - user={user} - defaultSort={'newest'} - defaultFilter={suggestedFilter} - additionalFilter={{ groupSlug: group.slug }} - persistPrefix={`group-${group.slug}`} - /> + const questionsPage = ( + <> + {/* align the divs to the right */} + <div className={' flex justify-end px-2 pb-2 sm:hidden'}> + <div> + <JoinOrAddQuestionsButtons + group={group} + user={user} + isMember={!!isMember} + /> + </div> + </div> + <ContractSearch + headerClassName="md:sticky" + user={user} + defaultSort={'newest'} + defaultFilter={suggestedFilter} + additionalFilter={{ groupSlug: group.slug }} + persistPrefix={`group-${group.slug}`} + /> + </> ) - const tabs = [ + const sidebarPages = [ { title: 'Markets', - content: questionsTab, + content: questionsPage, href: groupPath(group.slug, 'markets'), + key: 'markets', }, { title: 'Leaderboards', - content: leaderboard, + content: leaderboardPage, href: groupPath(group.slug, 'leaderboards'), + key: 'leaderboards', }, { title: 'About', - content: aboutTab, + content: aboutPage, href: groupPath(group.slug, 'about'), + key: 'about', }, ] - const tabIndex = tabs - .map((t) => t.title.toLowerCase()) - .indexOf(page ?? 'markets') + const pageContent = sidebarPages[sidebarIndex].content + const onSidebarClick = (key: string) => { + const index = sidebarPages.findIndex((t) => t.key === key) + setSidebarIndex(index) + } + + const joinOrAddQuestionsButton = ( + <JoinOrAddQuestionsButtons + group={group} + user={user} + isMember={!!isMember} + /> + ) return ( - <Page> - <SEO - title={group.name} - description={`Created by ${creator.name}. ${group.about}`} - url={groupPath(group.slug)} - /> - <Col className="relative px-3"> - <Row className={'items-center justify-between gap-4'}> - <div className={'sm:mb-1'}> - <div - className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'} - > - {group.name} - </div> - <div className={'hidden sm:block'}> - <Linkify text={group.about} /> - </div> - </div> - <div className="mt-2"> - <JoinOrAddQuestionsButtons - group={group} - user={user} - isMember={!!isMember} - /> - </div> - </Row> - </Col> - <Tabs - currentPageForAnalytics={groupPath(group.slug)} - className={'mx-2 mb-0 sm:mb-2'} - defaultIndex={tabIndex > 0 ? tabIndex : 0} - tabs={tabs} - /> - </Page> + <> + <TopGroupNavBar group={group} /> + <div> + <div + className={ + 'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8' + } + > + <Toaster /> + <GroupSidebar + groupName={group.name} + className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" + onClick={onSidebarClick} + joinOrAddQuestionsButton={joinOrAddQuestionsButton} + currentKey={sidebarPages[sidebarIndex].key} + /> + + <SEO + title={group.name} + description={`Created by ${creator.name}. ${group.about}`} + url={groupPath(group.slug)} + /> + <main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}> + {pageContent} + </main> + </div> + <GroupNavBar + currentPage={sidebarPages[sidebarIndex].key} + onClick={onSidebarClick} + /> + </div> + </> + ) +} + +export function TopGroupNavBar(props: { group: Group }) { + return ( + <header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12"> + <div className="flex items-center border-b border-gray-200 bg-white px-4"> + <div className="flex-shrink-0"> + <Link href="/"> + <a className="text-indigo-700 hover:text-gray-500 "> + <ArrowLeftIcon className="h-5 w-5" aria-hidden="true" /> + </a> + </Link> + </div> + <div className="ml-3"> + <h1 className="text-lg font-medium text-indigo-700"> + {props.group.name} + </h1> + </div> + </div> + </header> ) } @@ -264,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: { group: Group user: User | null | undefined isMember: boolean + className?: string }) { const { group, user, isMember } = props return user && isMember ? ( - <Row className={'mt-0 justify-end'}> + <Row className={'w-full self-start pt-4'}> <AddContractButton group={group} user={user} /> </Row> ) : group.anyoneCanJoin ? ( @@ -411,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) { return ( <> - <div className={'flex justify-center'}> + <div className={'flex w-full justify-center'}> <Button - className="whitespace-nowrap" + className="w-full whitespace-nowrap" size="md" color="indigo" onClick={() => setOpen(true)} @@ -468,7 +517,9 @@ function JoinGroupButton(props: { <div> <button onClick={follow} - className={'btn-md btn-outline btn whitespace-nowrap normal-case'} + className={ + 'btn-md btn-outline btn w-full whitespace-nowrap normal-case' + } > Follow </button> diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index f39a7647..1854da34 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -171,26 +171,34 @@ export default function Groups(props: { export function GroupCard(props: { group: Group - creator: User | undefined + creator: User | null | undefined user: User | undefined | null isMember: boolean + className?: string }) { - const { group, creator, user, isMember } = props + const { group, creator, user, isMember, className } = props const { totalContracts } = group return ( - <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> + <Col + className={clsx( + 'relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-6 shadow-md hover:bg-gray-100', + className + )} + > <Link href={groupPath(group.slug)}> <a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> </Link> - <div> - <Avatar - className={'absolute top-2 right-2 z-10'} - username={creator?.username} - avatarUrl={creator?.avatarUrl} - noLink={false} - size={12} - /> - </div> + {creator !== null && ( + <div> + <Avatar + className={'absolute top-2 right-2 z-10'} + username={creator?.username} + avatarUrl={creator?.avatarUrl} + noLink={false} + size={12} + /> + </div> + )} <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> diff --git a/web/pages/home.tsx b/web/pages/home.tsx deleted file mode 100644 index 972aa639..00000000 --- a/web/pages/home.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useRouter } from 'next/router' -import { PencilAltIcon } from '@heroicons/react/solid' - -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ContractSearch } from 'web/components/contract-search' -import { useTracking } from 'web/hooks/use-tracking' -import { useUser } from 'web/hooks/use-user' -import { track } from 'web/lib/service/analytics' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { usePrefetch } from 'web/hooks/use-prefetch' - -const Home = () => { - const user = useUser() - const router = useRouter() - useTracking('view home') - - useSaveReferral() - usePrefetch(user?.id) - - return ( - <> - <Page> - <Col className="mx-auto w-full p-2"> - <ContractSearch - user={user} - persistPrefix="home-search" - useQueryUrlParam={true} - /> - </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> - </> - ) -} - -export default Home diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/home/edit.tsx similarity index 78% rename from web/pages/experimental/home/edit.tsx rename to web/pages/home/edit.tsx index 8c242a34..9670181b 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/home/edit.tsx @@ -7,9 +7,12 @@ 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 { useMemberGroups } from 'web/hooks/use-group' import { useTracking } from 'web/hooks/use-tracking' import { useUser } from 'web/hooks/use-user' import { updateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { getHomeItems } from '.' export default function Home() { const user = useUser() @@ -24,6 +27,9 @@ export default function Home() { setHomeSections(newHomeSections) } + const groups = useMemberGroups(user?.id) ?? [] + const { sections } = getHomeItems(groups, homeSections) + return ( <Page> <Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2"> @@ -32,11 +38,7 @@ export default function Home() { <DoneButton /> </Row> - <ArrangeHome - user={user} - homeSections={homeSections} - setHomeSections={updateHomeSections} - /> + <ArrangeHome sections={sections} setSectionIds={updateHomeSections} /> </Col> </Page> ) @@ -46,11 +48,12 @@ function DoneButton(props: { className?: string }) { const { className } = props return ( - <SiteLink href="/experimental/home"> + <SiteLink href="/home"> <Button size="lg" color="blue" className={clsx(className, 'flex whitespace-nowrap')} + onClick={() => track('done editing home')} > Done </Button> diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx new file mode 100644 index 00000000..f486fa4c --- /dev/null +++ b/web/pages/home/index.tsx @@ -0,0 +1,387 @@ +import React, { ReactNode, useEffect, useState } from 'react' +import Router from 'next/router' +import { + AdjustmentsIcon, + PencilAltIcon, + ArrowSmRightIcon, +} from '@heroicons/react/solid' +import { XCircleIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { toast, Toaster } from 'react-hot-toast' + +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, + useMemberGroups, + 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 { + getGroup, + 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 { ProfitBadge } from 'web/components/bets-list' +import { calculatePortfolioProfit } from 'common/calculate-metrics' +import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' +import { useContractsQuery } from 'web/hooks/use-contracts' +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' + +export default function Home() { + const user = useUser() + + useTracking('view home') + + useSaveReferral() + usePrefetch(user?.id) + + const cachedGroups = useMemberGroups(user?.id) ?? [] + const groupIds = useMemberGroupIds(user) + const [groups, setGroups] = useState(cachedGroups) + + useEffect(() => { + if (groupIds) { + Promise.all(groupIds.map((id) => getGroup(id))).then((groups) => + setGroups(filterDefined(groups)) + ) + } + }, [groupIds]) + + const { sections } = getHomeItems(groups, user?.homeSections ?? []) + + return ( + <Page> + <Toaster /> + + <Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0"> + <Row className={'mb-2 w-full items-center justify-between gap-8'}> + <Title className="!mt-0 !mb-0" text="Home" /> + <DailyStats user={user} /> + </Row> + + {sections.map((section) => renderSection(section, user, groups))} + + <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: 'Trending', id: 'score' }, + { label: 'New for you', id: 'new-for-you' }, + { label: 'Recently updated', id: 'recently-updated-for-you' }, +] + +export const getHomeItems = (groups: Group[], sections: string[]) => { + // Accommodate old home sections. + if (!isArray(sections)) sections = [] + + const items = [ + ...HOME_SECTIONS, + ...groups.map((g) => ({ + label: g.name, + id: g.id, + })), + ] + const itemsById = keyBy(items, 'id') + + const sectionItems = filterDefined(sections.map((id) => itemsById[id])) + + // 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 | null | undefined, + groups: Group[] +) { + const { id, label } = section + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user?.id} /> + } + if (id === 'new-for-you') + return ( + <SearchSection + key={id} + label={label} + sort={'newest'} + pill="personal" + user={user} + /> + ) + if (id === 'recently-updated-for-you') + return ( + <SearchSection + key={id} + label={label} + sort={'last-updated'} + 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} /> + ) + + const group = groups.find((g) => g.id === id) + if (group) return <GroupSection key={id} group={group} user={user} /> + + 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 | null | undefined | undefined + 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} + headerClassName="sticky" + persistPrefix={`home-${sort}`} + /> + </Col> + ) +} + +function GroupSection(props: { + group: Group + user: User | null | undefined | undefined +}) { + const { group, user } = props + + const contracts = useContractsQuery('score', 4, { groupSlug: group.slug }) + + return ( + <Col> + <SectionHeader label={group.name} href={groupPath(group.slug)}> + <Button + className="" + color="gray-white" + onClick={() => { + if (user) { + 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={clsx('h-5 w-5 flex-shrink-0')} + aria-hidden="true" + /> + </Button> + </SectionHeader> + <ContractsGrid contracts={contracts} /> + </Col> + ) +} + +function DailyMoversSection(props: { userId: string | null | undefined }) { + const { userId } = props + const changes = useProbChanges(userId ?? '') + + 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 streaksHidden = + privateUser?.notificationPreferences.betting_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> + ) +} + +function TrendingGroupsSection(props: { user: User | null | undefined }) { + const { user } = props + const memberGroupIds = useMemberGroupIds(user) || [] + + const groups = useTrendingGroups().filter( + (g) => !memberGroupIds.includes(g.id) + ) + const count = 25 + const chosenGroups = groups.slice(0, count) + + return ( + <Col> + <SectionHeader label="Trending groups" href="/explore-groups"> + <CustomizeButton /> + </SectionHeader> + <Row className="flex-wrap gap-2"> + {chosenGroups.map((g) => ( + <PillButton + 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 }) + } + }} + > + {g.name} + </PillButton> + ))} + </Row> + </Col> + ) +} + +function CustomizeButton() { + return ( + <SiteLink + className="mb-2 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" + /> + Customize + </Button> + </SiteLink> + ) +} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a0c1ede5..2f5c0bf9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -904,25 +904,30 @@ function BetFillNotification(props: { }) { const { notification, isChildOfGroup, highlighted, justSummary } = props const { sourceText, data } = notification - const { creatorOutcome, probability } = (data as BetFillData) ?? {} + const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } = + (data as BetFillData) ?? {} const subtitle = 'bet against you' const amount = formatMoney(parseInt(sourceText ?? '0')) const description = creatorOutcome && probability ? ( <span> - of your{' '} + of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''} <span - className={ + className={clsx( + 'mx-1', creatorOutcome === 'YES' ? 'text-primary' : creatorOutcome === 'NO' ? 'text-red-500' : 'text-blue-500' - } + )} > - {creatorOutcome}{' '} + {creatorOutcome} </span> - limit order at {Math.round(probability * 100)}% was filled + limit order at {Math.round(probability * 100)}% was filled{' '} + {limitOrderRemaining + ? `(${formatMoney(limitOrderRemaining)} remaining)` + : ''} </span> ) : ( <span>of your limit order was filled</span> diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 6b70b5d2..2c095db6 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,24 +1,28 @@ -import React, { useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' - -import { AddFundsButton } from 'web/components/add-funds-button' -import { Page } from 'web/components/page' -import { SEO } from 'web/components/SEO' -import { Title } from 'web/components/title' -import { formatMoney } from 'common/util/format' +import { PrivateUser, User } from 'common/user' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' -import { changeUserInfo } from 'web/lib/firebase/api' -import { uploadImage } from 'web/lib/firebase/storage' +import { formatMoney } from 'common/util/format' +import Link from 'next/link' +import React, { useState } from 'react' +import Textarea from 'react-expanding-textarea' +import { AddFundsButton } from 'web/components/add-funds-button' +import { ConfirmationButton } from 'web/components/confirmation-button' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' -import { User, PrivateUser } from 'common/user' -import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' -import { defaultBannerUrl } from 'web/components/user-page' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' import { SiteLink } from 'web/components/site-link' -import Textarea from 'react-expanding-textarea' -import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { Title } from 'web/components/title' +import { defaultBannerUrl } from 'web/components/user-page' import { generateNewApiKey } from 'web/lib/api/api-key' -import { TwitchPanel } from 'web/components/profile/twitch-panel' +import { changeUserInfo } from 'web/lib/firebase/api' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { uploadImage } from 'web/lib/firebase/storage' +import { + getUserAndPrivateUser, + updatePrivateUser, + updateUser, +} from 'web/lib/firebase/users' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -93,10 +97,15 @@ export default function ProfilePage(props: { } } - const updateApiKey = async (e: React.MouseEvent) => { + const updateApiKey = async (e?: React.MouseEvent) => { const newApiKey = await generateNewApiKey(user.id) setApiKey(newApiKey ?? '') - e.preventDefault() + e?.preventDefault() + + if (!privateUser.twitchInfo) return + await updatePrivateUser(privateUser.id, { + twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true }, + }) } const fileHandler = async (event: any) => { @@ -229,16 +238,38 @@ export default function ProfilePage(props: { value={apiKey} readOnly /> - <button - className="btn btn-primary btn-square p-2" - onClick={updateApiKey} + <ConfirmationButton + openModalBtn={{ + className: 'btn btn-primary btn-square p-2', + label: '', + icon: <RefreshIcon />, + }} + submitBtn={{ + label: 'Update key', + className: 'btn-primary', + }} + onSubmitWithSuccess={async () => { + updateApiKey() + return true + }} > - <RefreshIcon /> - </button> + <Col> + <Title text={'Are you sure?'} /> + <div> + Updating your API key will break any existing applications + connected to your account, <b>including the Twitch bot</b>. + You will need to go to the{' '} + <Link href="/twitch"> + <a className="underline focus:outline-none"> + Twitch page + </a> + </Link>{' '} + to relink your account. + </div> + </Col> + </ConfirmationButton> </div> </div> - - <TwitchPanel /> </Col> </Col> </Page> diff --git a/web/pages/search.tsx b/web/pages/search.tsx new file mode 100644 index 00000000..38b9760d --- /dev/null +++ b/web/pages/search.tsx @@ -0,0 +1,26 @@ +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ContractSearch } from 'web/components/contract-search' +import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' +import { usePrefetch } from 'web/hooks/use-prefetch' + +export default function Search() { + const user = useUser() + usePrefetch(user?.id) + + useTracking('view search') + + return ( + <Page> + <Col className="mx-auto w-full p-2"> + <ContractSearch + user={user} + persistPrefix="search" + useQueryUrlParam={true} + autoFocus + /> + </Col> + </Page> + ) +} diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index e81c239f..b56e55e6 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -76,6 +76,13 @@ const Salem = { } const tourneys: Tourney[] = [ + { + title: 'Clearer Thinking Regrant Project', + blurb: 'Which projects will Clearer Thinking give a grant to?', + award: '$13,000', + endTime: toDate('Sep 30, 2022'), + groupId: 'fhksfIgqyWf7OxsV9nkM', + }, { title: 'Manifold F2P Tournament', blurb: @@ -99,13 +106,6 @@ const tourneys: Tourney[] = [ endTime: toDate('Jan 6, 2023'), groupId: 'SxGRqXRpV3RAQKudbcNb', }, - // { - // title: 'Clearer Thinking Regrant Project', - // blurb: 'Something amazing', - // award: '$10,000', - // endTime: toDate('Sep 22, 2022'), - // groupId: '2VsVVFGhKtIdJnQRAXVb', - // }, // Tournaments without awards get featured belows { diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 7ca892e8..46856eaf 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -1,28 +1,48 @@ -import { useState } from 'react' +import { LinkIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { PrivateUser, User } from 'common/user' +import Link from 'next/link' +import { MouseEventHandler, ReactNode, useState } from 'react' -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ManifoldLogo } from 'web/components/nav/manifold-logo' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { SEO } from 'web/components/SEO' -import { Spacer } from 'web/components/layout/spacer' -import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' -import { track } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' -import { Button } from 'web/components/button' -import { useTracking } from 'web/hooks/use-tracking' -import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { LoadingIndicator } from 'web/components/loading-indicator' import toast from 'react-hot-toast' +import { Button } from 'web/components/button' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Spacer } from 'web/components/layout/spacer' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { useTracking } from 'web/hooks/use-tracking' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { + firebaseLogin, + getUserAndPrivateUser, + updatePrivateUser, +} from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { + getDockURLForUser, + getOverlayURLForUser, + linkTwitchAccountRedirect, + updateBotEnabledForUser, +} from 'web/lib/twitch/link-twitch-account' +import { copyToClipboard } from 'web/lib/util/copy' -export default function TwitchLandingPage() { - useSaveReferral() - useTracking('view twitch landing page') +function ButtonGetStarted(props: { + user?: User | null + privateUser?: PrivateUser | null + buttonClass?: string + spinnerClass?: string +}) { + const { user, privateUser, buttonClass, spinnerClass } = props - const user = useUser() - const privateUser = usePrivateUser() - const twitchUser = privateUser?.twitchInfo?.twitchName + const [isLoading, setLoading] = useState(false) + const needsRelink = + privateUser?.twitchInfo?.twitchName && + privateUser?.twitchInfo?.needsRelinking const callback = user && privateUser @@ -34,11 +54,11 @@ export default function TwitchLandingPage() { const { user, privateUser } = await getUserAndPrivateUser(userId) if (!user || !privateUser) return + if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again + await linkTwitchAccountRedirect(user, privateUser) } - const [isLoading, setLoading] = useState(false) - const getStarted = async () => { try { setLoading(true) @@ -49,9 +69,335 @@ export default function TwitchLandingPage() { } catch (e) { console.error(e) toast.error('Failed to sign up. Please try again later.') + } finally { setLoading(false) } } + return isLoading ? ( + <LoadingIndicator + spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)} + /> + ) : ( + <Button + size="xl" + color={needsRelink ? 'red' : 'gradient'} + className={clsx('my-4 self-center !px-16', buttonClass)} + onClick={getStarted} + > + {needsRelink ? 'API key updated: relink Twitch' : 'Start playing'} + </Button> + ) +} + +function TwitchPlaysManifoldMarkets(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props + + const twitchInfo = privateUser?.twitchInfo + const twitchUser = twitchInfo?.twitchName + + return ( + <div> + <Row className="mb-4"> + <img + src="/twitch-glitch.svg" + className="mb-[0.4rem] mr-4 inline h-10 w-10" + ></img> + <Title + text={'Twitch plays Manifold Markets'} + className={'!-my-0 md:block'} + /> + </Row> + <Col className="gap-4"> + <div> + Similar to Twitch channel point predictions, Manifold Markets allows + you to create and feature on stream any question you like with users + predicting to earn play money. + </div> + <div> + The key difference is that Manifold's questions function more like a + stock market and viewers can buy and sell shares over the course of + the event and not just at the start. The market will eventually + resolve to yes or no at which point the winning shareholders will + receive their profit. + </div> + Start playing now by logging in with Google and typing commands in chat! + {twitchUser && !twitchInfo.needsRelinking ? ( + <Button + size="xl" + color="green" + className="btn-disabled my-4 self-center !border-none" + > + Account connected: {twitchUser} + </Button> + ) : ( + <ButtonGetStarted user={user} privateUser={privateUser} /> + )} + <div> + Instead of Twitch channel points we use our play money, Mana (M$). All + viewers start with M$1000 and more can be earned for free and then{' '} + <Link href="/charity"> + <a className="underline">donated to a charity</a> + </Link>{' '} + of their choice at no cost! + </div> + </Col> + </div> + ) +} + +function Subtitle(props: { text: string }) { + const { text } = props + return <div className="text-2xl">{text}</div> +} + +function Command(props: { command: string; desc: string }) { + const { command, desc } = props + return ( + <div> + <p className="inline font-bold">{'!' + command}</p> + {' - '} + <p className="inline">{desc}</p> + </div> + ) +} + +function TwitchChatCommands() { + return ( + <div> + <Title text="Twitch Chat Commands" className="md:block" /> + <Col className="gap-4"> + <Subtitle text="For Chat" /> + <Command command="bet yes#" desc="Bets a # of Mana on yes." /> + <Command command="bet no#" desc="Bets a # of Mana on no." /> + <Command + command="sell" + desc="Sells all shares you own. Using this command causes you to + cash out early before the market resolves. This could be profitable + (if the probability has moved towards the direction you bet) or cause + a loss, although at least you keep some Mana. For maximum profit (but + also risk) it is better to not sell and wait for a favourable + resolution." + /> + <Command command="balance" desc="Shows how much Mana you own." /> + <Command command="allin yes" desc="Bets your entire balance on yes." /> + <Command command="allin no" desc="Bets your entire balance on no." /> + + <Subtitle text="For Mods/Streamer" /> + <Command + command="create <question>" + desc="Creates and features the question. Be careful... this will override any question that is currently featured." + /> + <Command command="resolve yes" desc="Resolves the market as 'Yes'." /> + <Command command="resolve no" desc="Resolves the market as 'No'." /> + <Command + command="resolve n/a" + desc="Resolves the market as 'N/A' and refunds everyone their Mana." + /> + </Col> + </div> + ) +} + +function BotSetupStep(props: { + stepNum: number + buttonName?: string + buttonOnClick?: MouseEventHandler + overrideButton?: ReactNode + children: ReactNode +}) { + const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props + return ( + <Col className="flex-1"> + {(overrideButton || buttonName) && ( + <> + {overrideButton ?? ( + <Button + size={'md'} + color={'green'} + className="!border-none" + onClick={buttonOnClick} + > + {buttonName} + </Button> + )} + <Spacer h={4} /> + </> + )} + <div> + <p className="inline font-bold">Step {stepNum}. </p> + {children} + </div> + </Col> + ) +} + +function BotConnectButton(props: { + privateUser: PrivateUser | null | undefined +}) { + const { privateUser } = props + const [loading, setLoading] = useState(false) + + const updateBotConnected = (connected: boolean) => async () => { + if (!privateUser) return + const twitchInfo = privateUser.twitchInfo + if (!twitchInfo) return + + const error = connected + ? 'Failed to add bot to your channel' + : 'Failed to remove bot from your channel' + const success = connected + ? 'Added bot to your channel' + : 'Removed bot from your channel' + + setLoading(true) + toast.promise( + updateBotEnabledForUser(privateUser, connected) + .then(() => + updatePrivateUser(privateUser.id, { + twitchInfo: { ...twitchInfo, botEnabled: connected }, + }) + ) + .finally(() => setLoading(false)), + { loading: 'Updating bot settings...', error, success }, + { + loading: { + className: '!max-w-sm', + }, + success: { + className: + '!bg-primary !transition-all !duration-500 !text-white !max-w-sm', + }, + error: { + className: + '!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm', + }, + } + ) + } + + return ( + <> + {privateUser?.twitchInfo?.botEnabled ? ( + <Button + color="red" + onClick={updateBotConnected(false)} + className={clsx(loading && '!btn-disabled', 'border-none')} + > + {loading ? ( + <LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" /> + ) : ( + 'Remove bot from channel' + )} + </Button> + ) : ( + <Button + color="green" + onClick={updateBotConnected(true)} + className={clsx(loading && '!btn-disabled', 'border-none')} + > + {loading ? ( + <LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" /> + ) : ( + 'Add bot to your channel' + )} + </Button> + )} + </> + ) +} + +function SetUpBot(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props + const twitchLinked = + privateUser?.twitchInfo?.twitchName && + !privateUser?.twitchInfo?.needsRelinking + ? true + : undefined + const toastTheme = { + className: '!bg-primary !text-white', + icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, + } + const copyOverlayLink = async () => { + if (!privateUser) return + copyToClipboard(getOverlayURLForUser(privateUser)) + toast.success('Overlay link copied!', toastTheme) + } + const copyDockLink = async () => { + if (!privateUser) return + copyToClipboard(getDockURLForUser(privateUser)) + toast.success('Dock link copied!', toastTheme) + } + + return ( + <> + <Title + text={'Set up the bot for your own stream'} + className={'!mb-4 md:block'} + /> + <Col className="gap-4"> + <img + src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder + className="!-my-2" + ></img> + To add the bot to your stream make sure you have logged in then follow + the steps below. + {!twitchLinked && ( + <ButtonGetStarted + user={user} + privateUser={privateUser} + buttonClass={'!my-0'} + spinnerClass={'!my-0'} + /> + )} + <div className="flex flex-col gap-6 sm:flex-row"> + <BotSetupStep + stepNum={1} + overrideButton={ + twitchLinked && <BotConnectButton privateUser={privateUser} /> + } + > + Use the button above to add the bot to your channel. Then mod it by + typing in your Twitch chat: <b>/mod ManifoldBot</b> + <br /> + If the bot is not modded it will not be able to respond to commands + properly. + </BotSetupStep> + <BotSetupStep + stepNum={2} + buttonName={twitchLinked && 'Overlay link'} + buttonOnClick={copyOverlayLink} + > + Create a new browser source in your streaming software such as OBS. + Paste in the above link and resize it to your liking. We recommend + setting the size to 400x400. + </BotSetupStep> + <BotSetupStep + stepNum={3} + buttonName={twitchLinked && 'Control dock link'} + buttonOnClick={copyDockLink} + > + The bot can be controlled entirely through chat. But we made an easy + to use control panel. Share the link with your mods or embed it into + your OBS as a custom dock. + </BotSetupStep> + </div> + </Col> + </> + ) +} + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() return ( <Page> @@ -62,58 +408,11 @@ export default function TwitchLandingPage() { <div className="px-4 pt-2 md:mt-0 lg:hidden"> <ManifoldLogo /> </div> - <Col className="items-center"> - <Col className="max-w-3xl"> - <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> - <Row className="self-center"> - <img height={200} width={200} src="/twitch-logo.png" /> - <img height={200} width={200} src="/flappy-logo.gif" /> - </Row> - <div className="m-4 max-w-[550px] self-center"> - <h1 className="text-3xl sm:text-6xl xl:text-6xl"> - <div className="font-semibold sm:mb-2"> - <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> - Bet - </span>{' '} - on your favorite streams - </div> - </h1> - <Spacer h={6} /> - <div className="mb-4 px-2 "> - Get more out of Twitch with play-money betting markets.{' '} - {!twitchUser && - 'Click the button below to link your Twitch account.'} - <br /> - </div> - </div> - <Spacer h={6} /> - - {twitchUser ? ( - <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> - <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> - <div className="truncate text-sm font-medium text-gray-500"> - Twitch account linked - </div> - <div className="mt-1 text-2xl font-semibold text-gray-900"> - {twitchUser} - </div> - </div> - </div> - ) : isLoading ? ( - <LoadingIndicator spinnerClassName="!w-16 !h-16" /> - ) : ( - <Button - size="2xl" - color="gradient" - className="self-center" - onClick={getStarted} - > - Get started - </Button> - )} - </Col> - </Col> + <Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10"> + <TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} /> + <TwitchChatCommands /> + <SetUpBot user={user} privateUser={privateUser} /> </Col> </Page> ) diff --git a/web/public/twitch-glitch.svg b/web/public/twitch-glitch.svg new file mode 100644 index 00000000..3120fea7 --- /dev/null +++ b/web/public/twitch-glitch.svg @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill:#9146FF;} +</style> +<title>Asset 2 + + + + + + + + + + + diff --git a/web/tailwind.config.js b/web/tailwind.config.js index eb411216..7bea3ec2 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,6 +26,8 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', + 'highlight-blue': '#5BCEFF', + 'hover-blue': '#90DEFF', }, typography: { quoteless: {