Merge branch 'main' into range-order

This commit is contained in:
James Grugett 2022-07-20 15:55:48 -05:00
commit a945b2310c
62 changed files with 1444 additions and 893 deletions

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash' import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = { export const CATEGORIES = {
politics: 'Politics', politics: 'Politics',
technology: 'Technology', technology: 'Technology',
@ -37,3 +38,8 @@ export const EXCLUDED_CATEGORIES: category[] = [
] ]
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

View File

@ -48,6 +48,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
groupSlugs?: string[] groupSlugs?: string[]
uniqueBettorIds?: string[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -22,6 +22,7 @@ export type EnvConfig = {
// Currency controls // Currency controls
fixedAnte?: number fixedAnte?: number
startingBalance?: number startingBalance?: number
referralBonus?: number
} }
type FirebaseConfig = { type FirebaseConfig = {

View File

@ -63,3 +63,4 @@ export type notification_reason_types =
| 'on_group_you_are_member_of' | 'on_group_you_are_member_of'
| 'tip_received' | 'tip_received'
| 'bet_fill' | 'bet_fill'
| 'user_joined_from_your_group_invite'

View File

@ -16,8 +16,8 @@ export const getMappedValue =
const { min, max, isLogScale } = contract const { min, max, isLogScale } = contract
if (isLogScale) { if (isLogScale) {
const logValue = p * Math.log10(max - min) const logValue = p * Math.log10(max - min + 1)
return 10 ** logValue + min return 10 ** logValue + min - 1
} }
return p * (max - min) + min return p * (max - min) + min
@ -38,7 +38,7 @@ export const getPseudoProbability = (
isLogScale = false isLogScale = false
) => { ) => {
if (isLogScale) { if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min) return Math.log10(value - min + 1) / Math.log10(max - min + 1)
} }
return (value - min) / (max - min) return (value - min) / (max - min)

View File

@ -38,13 +38,14 @@ export type User = {
referredByUserId?: string referredByUserId?: string
referredByContractId?: string referredByContractId?: string
referredByGroupId?: string
lastPingTime?: number lastPingTime?: number
} }
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person // for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = 500 export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = { export type PrivateUser = {
id: string // same as User.id id: string // same as User.id
username: string // denormalized from User username: string // denormalized from User

View File

@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%' return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
} }
const showPrecision = (x: number, sigfigs: number) =>
// convert back to number for weird formatting reason
`${Number(x.toPrecision(sigfigs))}`
// Eg 1234567.89 => 1.23M; 5678 => 5.68K // Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string { export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num) const absNum = Math.abs(num)
if (absNum < 1) return num.toPrecision(sigfigs) if (absNum < 1) return showPrecision(num, sigfigs)
if (absNum < 100) return num.toPrecision(2) if (absNum < 100) return showPrecision(num, 2)
if (absNum < 1000) return num.toPrecision(3) if (absNum < 1000) return showPrecision(num, 3)
if (absNum < 10000) return num.toPrecision(4) if (absNum < 10000) return showPrecision(num, 4)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(absNum) / 3) const i = Math.floor(Math.log10(absNum) / 3)
const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs) const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
return `${numStr}${suffix[i]}` return `${numStr}${suffix[i] ?? ''}`
} }
export function toCamelCase(words: string) { export function toCamelCase(words: string) {

View File

@ -22,11 +22,11 @@ service cloud.firestore {
allow read; allow read;
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId', 'lastPingTime']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']);
// User referral rules // User referral rules
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId']) .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
// only one referral allowed per user // only one referral allowed per user
&& !("referredByUserId" in resource.data) && !("referredByUserId" in resource.data)
// user can't refer themselves // user can't refer themselves
@ -76,7 +76,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime']) .hasOnly(['description', 'closeTime', 'question'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;
allow update: if isAdmin(); allow update: if isAdmin();
match /comments/{commentId} { match /comments/{commentId} {

View File

@ -253,20 +253,6 @@ export const createNotification = async (
} }
} }
const notifyUserReceivedReferralBonus = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
// If the referrer is the market creator, just tell them they joined to bet on their market
reason:
sourceContract?.creatorId === relatedUserId
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
}
}
const notifyContractCreatorOfUniqueBettorsBonus = async ( const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
userId: string userId: string
@ -284,8 +270,6 @@ export const createNotification = async (
} else if (sourceType === 'group' && relatedUserId) { } else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created') if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
} }
// The following functions need sourceContract to be defined. // The following functions need sourceContract to be defined.
@ -411,6 +395,7 @@ export const createGroupCommentNotification = async (
group: Group, group: Group,
idempotencyKey: string idempotencyKey: string
) => { ) => {
if (toUserId === fromUser.id) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`) .collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -434,3 +419,52 @@ export const createGroupCommentNotification = async (
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
export const createReferralNotification = async (
toUser: User,
referredUser: User,
idempotencyKey: string,
bonusAmount: string,
referredByContract?: Contract,
referredByGroup?: Group
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: referredByGroup
? 'user_joined_from_your_group_invite'
: referredByContract?.creatorId === toUser.id
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
createdTime: Date.now(),
isSeen: false,
sourceId: referredUser.id,
sourceType: 'user',
sourceUpdateType: 'updated',
sourceContractId: referredByContract?.id,
sourceUserName: referredUser.name,
sourceUserUsername: referredUser.username,
sourceUserAvatarUrl: referredUser.avatarUrl,
sourceText: bonusAmount,
// Only pass the contract referral details if they weren't referred to a group
sourceContractCreatorUsername: !referredByGroup
? referredByContract?.creatorUsername
: undefined,
sourceContractTitle: !referredByGroup
? referredByContract?.question
: undefined,
sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined,
sourceSlug: referredByGroup
? groupPath(referredByGroup.slug)
: referredByContract?.slug,
sourceTitle: referredByGroup
? referredByGroup.name
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`

File diff suppressed because one or more lines are too long

View File

@ -302,7 +302,7 @@ export const sendNewCommentEmail = async (
)}` )}`
} }
const subject = `Comment from ${commentorName} on ${question}` const subject = `Comment on ${question}`
const from = `${commentorName} on Manifold <no-reply@manifold.markets>` const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {

View File

@ -22,6 +22,7 @@ export * from './on-update-user'
export * from './on-create-comment-on-group' export * from './on-create-comment-on-group'
export * from './on-create-txn' export * from './on-create-txn'
export * from './on-delete-group' export * from './on-delete-group'
export * from './score-contracts'
// v2 // v2
export * from './health' export * from './health'

View File

@ -2,11 +2,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { REFERRAL_AMOUNT, User } from '../../common/user' import { REFERRAL_AMOUNT, User } from '../../common/user'
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore' import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from 'common/group'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onUpdateUser = functions.firestore export const onUpdateUser = functions.firestore
@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
} }
console.log(`referredByContract: ${referredByContract}`) console.log(`referredByContract: ${referredByContract}`)
let referredByGroup: Group | undefined = undefined
if (user.referredByGroupId) {
const referredByGroupDoc = firestore.doc(
`groups/${user.referredByGroupId}`
)
referredByGroup = await transaction
.get(referredByGroupDoc)
.then((snap) => snap.data() as Group)
}
console.log(`referredByGroup: ${referredByGroup}`)
const txns = ( const txns = (
await firestore await firestore
.collection('txns') .collection('txns')
@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
}) })
await createNotification( await createReferralNotification(
user.id, referredByUser,
'user',
'updated',
user, user,
eventId, eventId,
txn.amount.toString(), txn.amount.toString(),
referredByContract, referredByContract,
'user', referredByGroup
referredByUser.id,
referredByContract?.slug,
referredByContract?.question
) )
}) })
} }

View File

@ -0,0 +1,54 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { log } from './utils'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
const firestore = admin.firestore()
async function scoreContractsInternal() {
const now = Date.now()
const lastHour = now - 60 * 60 * 1000
const last3Days = now - 1000 * 60 * 60 * 24 * 3
const activeContractsSnap = await firestore
.collection('contracts')
.where('lastUpdatedTime', '>', lastHour)
.get()
const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
// We have to downgrade previously active contracts to allow the new ones to bubble up
const previouslyActiveContractsSnap = await firestore
.collection('contracts')
.where('popularityScore', '>', 0)
.get()
const activeContractIds = activeContracts.map((c) => c.id)
const previouslyActiveContracts = previouslyActiveContractsSnap.docs
.map((doc) => doc.data() as Contract)
.filter((c) => !activeContractIds.includes(c.id))
const contracts = activeContracts.concat(previouslyActiveContracts)
log(`Found ${contracts.length} contracts to score`)
for (const contract of contracts) {
const bets = await firestore
.collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days)
.get()
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
await firestore
.collection('contracts')
.doc(contract.id)
.update({ popularityScore: score })
}
}

View File

@ -0,0 +1,210 @@
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import toast from 'react-hot-toast'
import { track } from '@amplitude/analytics-browser'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
export function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

View File

@ -31,6 +31,7 @@ export function Avatar(props: {
!noLink && 'cursor-pointer', !noLink && 'cursor-pointer',
className className
)} )}
style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl} src={avatarUrl}
onClick={onClick} onClick={onClick}
alt={username} alt={username}

View File

@ -53,14 +53,10 @@ export function BetPanel(props: {
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return ( return (
<Col className={className}> <Col className={className}>
<SellRow <SellRow
@ -96,7 +92,7 @@ export function BetPanel(props: {
<SignUpPrompt /> <SignUpPrompt />
</Col> </Col>
{showLimitOrders && ( {unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> <LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)} )}
</Col> </Col>
@ -116,9 +112,6 @@ export function SimpleBetPanel(props: {
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return ( return (
<Col className={className}> <Col className={className}>
@ -149,7 +142,7 @@ export function SimpleBetPanel(props: {
<SignUpPrompt /> <SignUpPrompt />
</Col> </Col>
{showLimitOrders && ( {unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> <LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)} )}
</Col> </Col>

View File

@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 20 const CONTRACTS_PER_PAGE = 50
export function BetsList(props: { export function BetsList(props: {
user: User user: User

View File

@ -39,7 +39,7 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300', color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -13,7 +13,7 @@ export function PillButton(props: {
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full',
selected selected
? ['text-white', color ?? 'bg-gray-700'] ? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200', : 'bg-gray-100 hover:bg-gray-200',

View File

@ -6,10 +6,9 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns' import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity; match?: number }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity, match } = props const { charity } = props
const { slug, photo, preview, id, tags } = charity const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id) const txns = useCharityTxns(id)
@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
{raised > 0 && ( {raised > 0 && (
<> <>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900"> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Col> <Row className="items-baseline gap-1">
<span className="text-3xl font-semibold"> <span className="text-3xl font-semibold">
{formatUsd(raised)} {formatUsd(raised)}
</span> </span>
<span>raised</span> raised
</Col> </Row>
{match && ( {/* {match && (
<Col className="text-gray-500"> <Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span> <span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span> <span className="">match</span>
</Col> </Col>
)} )} */}
</Row> </Row>
</> </>
)} )}

View File

@ -25,9 +25,10 @@ import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { toPairs } from 'lodash' import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -39,22 +40,16 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const sortIndexes = [ const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' }, { label: 'Newest', value: indexPrefix + 'contracts-newest' },
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' }, { label: 'Most popular', value: indexPrefix + 'contracts-score' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' }, { label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
] ]
export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
const filterOptions: { [label: string]: filter } = {
All: 'all',
Open: 'open',
Closed: 'closed',
Resolved: 'resolved',
'For you': 'personal',
}
export function ContractSearch(props: { export function ContractSearch(props: {
querySortOptions?: { querySortOptions?: {
@ -85,9 +80,24 @@ export function ContractSearch(props: {
} = props } = props
const user = useUser() const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
?.map((g) => g.slug) (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) )
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions) const { initialSort } = useInitialQueryAndSort(querySortOptions)
@ -95,34 +105,45 @@ export function ContractSearch(props: {
.map(({ value }) => value) .map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`) .includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort ? initialSort
: querySortOptions?.defaultSort ?? 'most-popular' : querySortOptions?.defaultSort ?? DEFAULT_SORT
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' querySortOptions?.defaultFilter ?? 'open'
) )
const pillsEnabled = !additionalFilter
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const { filters, numericFilters } = useMemo(() => { const { filters, numericFilters } = useMemo(() => {
let filters = [ let filters = [
filter === 'open' ? 'isResolved:false' : '', filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal' additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
additionalFilter?.groupSlug
? `groupSlugs:${additionalFilter.groupSlug}`
: '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupSlugs:${pillFilter}`
: '',
pillFilter === 'personal'
? // Show contracts in groups that the user is a member of ? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? []) memberGroupSlugs
.map((slug) => `groupSlugs:${slug}`)
// Show contracts created by users the user follows // Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) .concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows // Show contracts bet on by users the user follows
.concat( .concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
) )
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '', : '',
additionalFilter?.creatorId // Subtract contracts you bet on from For you.
? `creatorId:${additionalFilter.creatorId}` pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
: '', pillFilter === 'your-bets' && user
additionalFilter?.groupSlug ? // Show contracts bet on by the user
? `groupSlugs:${additionalFilter.groupSlug}` `uniqueBettorIds:${user.id}`
: '', : '',
].filter((f) => f) ].filter((f) => f)
// Hack to make Algolia work. // Hack to make Algolia work.
@ -137,8 +158,9 @@ export function ContractSearch(props: {
}, [ }, [
filter, filter,
Object.values(additionalFilter ?? {}).join(','), Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','), memberGroupSlugs.join(','),
(follows ?? []).join(','), (follows ?? []).join(','),
pillFilter,
]) ])
const indexName = `${indexPrefix}contracts-${sort}` const indexName = `${indexPrefix}contracts-${sort}`
@ -165,6 +187,17 @@ export function ContractSearch(props: {
}} }}
/> />
{/*// TODO track WHICH filter users are using*/} {/*// TODO track WHICH filter users are using*/}
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter')}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && ( {!hideOrderSelector && (
<SortBy <SortBy
items={sortIndexes} items={sortIndexes}
@ -184,21 +217,50 @@ export function ContractSearch(props: {
<Spacer h={3} /> <Spacer h={3} />
<Row className="gap-2"> {pillsEnabled && (
{toPairs<filter>(filterOptions).map(([label, f]) => { <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={() => setPillFilter(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={() => setPillFilter('personal')}
>
For you
</PillButton>
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={() => setPillFilter('your-bets')}
>
Your bets
</PillButton>
{pillGroups.map(({ name, slug }) => {
return ( return (
<PillButton selected={filter === f} onSelect={() => setFilter(f)}> <PillButton
{label} key={slug}
selected={pillFilter === slug}
onSelect={() => setPillFilter(slug)}
>
{name}
</PillButton> </PillButton>
) )
})} })}
</Row> </Row>
)}
<Spacer h={3} /> <Spacer h={3} />
{filter === 'personal' && {filter === 'personal' &&
(follows ?? []).length === 0 && (follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? ( memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</> <>You're not following anyone, nor in any of your own groups yet.</>
) : ( ) : (
<ContractSearchInner <ContractSearchInner

View File

@ -2,16 +2,17 @@ import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { CATEGORY_LIST } from '../../../common/categories'
import { Contract } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { parseTags, exhibitExts } from 'common/util/parse' import { exhibitExts, parseTags } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { updateContract } from 'web/lib/firebase/contracts' import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { TagsList } from '../tags-list'
import { Content } from '../editor' import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { TextEditor, useTextEditor } from 'web/components/editor'
import { Button } from '../button'
import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
@ -19,20 +20,36 @@ export function ContractDescription(props: {
className?: string className?: string
}) { }) {
const { contract, isCreator, className } = props const { contract, isCreator, className } = props
const descriptionTimestamp = () => `${dayjs().format('MMM D, h:mma')}: `
const isAdmin = useAdmin() const isAdmin = useAdmin()
return (
<div className={clsx('mt-2 text-gray-700', className)}>
{isCreator || isAdmin ? (
<RichEditContract contract={contract} isAdmin={isAdmin && !isCreator} />
) : (
<Content content={contract.description} />
)}
</div>
)
}
const desc = contract.description ?? '' function editTimestamp() {
return `${dayjs().format('MMM D, h:mma')}: `
}
// Append the new description (after a newline) function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
async function saveDescription(newText: string) { const { contract, isAdmin } = props
const editor = new Editor({ content: desc, extensions: exhibitExts }) const [editing, setEditing] = useState(false)
editor const [editingQ, setEditingQ] = useState(false)
.chain() const [isSubmitting, setIsSubmitting] = useState(false)
.focus('end')
.insertContent('<br /><br />') const { editor, upload } = useTextEditor({
.insertContent(newText.trim()) max: MAX_DESCRIPTION_LENGTH,
.run() defaultValue: contract.description,
disabled: isSubmitting,
})
async function saveDescription() {
if (!editor) return
const tags = parseTags( const tags = parseTags(
`${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}` `${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
@ -46,76 +63,94 @@ export function ContractDescription(props: {
}) })
} }
const { tags } = contract return editing ? (
const categories = tags.filter((tag) => <>
CATEGORY_LIST.includes(tag.toLowerCase()) <TextEditor editor={editor} upload={upload} />
) <Spacer h={2} />
<Row className="gap-2">
return ( <Button
<div onClick={async () => {
className={clsx( setIsSubmitting(true)
'mt-2 whitespace-pre-line break-words text-gray-700', await saveDescription()
className setEditing(false)
)} setIsSubmitting(false)
}}
> >
<Content content={desc} /> Save
</Button>
{categories.length > 0 && ( <Button color="gray" onClick={() => setEditing(false)}>
<div className="mt-4"> Cancel
<TagsList tags={categories} noLabel /> </Button>
</div> </Row>
)} </>
) : (
<br /> <>
<Content content={contract.description} />
{isCreator && ( <Spacer h={2} />
<EditContract <Row className="items-center gap-2">
// Note: Because descriptionTimestamp is called once, later edits use {isAdmin && 'Admin: '}
// a stale timestamp. Ideally this is a function that gets called when <Button
// isEditing is set to true. color="gray"
text={descriptionTimestamp()} size="xs"
onSave={saveDescription} onClick={() => {
buttonText="Add to description" setEditing(true)
editor
?.chain()
.setContent(contract.description)
.focus('end')
.insertContent(`<p>${editTimestamp()}</p>`)
.run()
}}
>
Edit description
</Button>
<Button color="gray" size="xs" onClick={() => setEditingQ(true)}>
Edit question
</Button>
</Row>
<EditQuestion
contract={contract}
editing={editingQ}
setEditing={setEditingQ}
/> />
)} </>
{isAdmin && (
<EditContract
text={contract.question}
onSave={(question) => updateContract(contract.id, { question })}
buttonText="ADMIN: Edit question"
/>
)}
{/* {isAdmin && (
<EditContract
text={contract.createdTime.toString()}
onSave={(time) =>
updateContract(contract.id, { createdTime: Number(time) })
}
buttonText="ADMIN: Edit createdTime"
/>
)} */}
</div>
) )
} }
function EditContract(props: { function EditQuestion(props: {
text: string contract: Contract
onSave: (newText: string) => void editing: boolean
buttonText: string setEditing: (editing: boolean) => void
}) { }) {
const [text, setText] = useState(props.text) const { contract, editing, setEditing } = props
const [editing, setEditing] = useState(false) const [text, setText] = useState(contract.question)
const onSave = (newText: string) => {
function questionChanged(oldQ: string, newQ: string) {
return `<p>${editTimestamp()}<s>${oldQ}</s> → ${newQ}</p>`
}
function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
editor.chain().focus('end').insertContent(newContent).run()
return editor.getJSON()
}
const onSave = async (newText: string) => {
setEditing(false) setEditing(false)
setText(props.text) // Reset to original text await updateContract(contract.id, {
props.onSave(newText) question: newText,
description: joinContent(
contract.description,
questionChanged(contract.question, newText)
),
})
} }
return editing ? ( return editing ? (
<div className="mt-4"> <div className="mt-4">
<Textarea <Textarea
className="textarea textarea-bordered mb-1 h-24 w-full resize-none" className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
rows={3} rows={2}
value={text} value={text}
onChange={(e) => setText(e.target.value || '')} onChange={(e) => setText(e.target.value || '')}
autoFocus autoFocus
@ -130,28 +165,11 @@ function EditContract(props: {
}} }}
/> />
<Row className="gap-2"> <Row className="gap-2">
<button <Button onClick={() => onSave(text)}>Save</Button>
className="btn btn-neutral btn-outline btn-sm" <Button color="gray" onClick={() => setEditing(false)}>
onClick={() => onSave(text)}
>
Save
</button>
<button
className="btn btn-error btn-outline btn-sm"
onClick={() => setEditing(false)}
>
Cancel Cancel
</button> </Button>
</Row> </Row>
</div> </div>
) : ( ) : null
<Row>
<button
className="btn btn-neutral btn-outline btn-xs mt-4"
onClick={() => setEditing(true)}
>
{props.buttonText}
</button>
</Row>
)
} }

View File

@ -16,7 +16,6 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title' import { Title } from '../title'
import { TweetButton } from '../tweet-button' import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip' import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody> </tbody>
</table> </table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} /> <LiquidityPanel contract={contract} />
)} )}

View File

@ -7,7 +7,6 @@ import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate' import { getInitialProbability } from 'common/calculate'
import { BinaryContract, PseudoNumericContract } from 'common/contract' import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { getMappedValue } from 'common/pseudo-numeric'
import { formatLargeNumber } from 'common/util/format' import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: { export const ContractProbGraph = memo(function ContractProbGraph(props: {
@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
...bets.map((bet) => bet.createdTime), ...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time)) ].map((time) => new Date(time))
const f = getMappedValue(contract) const f: (p: number) => number = isBinary
? (p) => p
: isLogScale
? (p) => p * Math.log10(contract.max - contract.min + 1)
: (p) => p * (contract.max - contract.min) + contract.min
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const points: { x: Date; y: number }[] = [] const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1 const s = isBinary ? 100 : 1
const c = isLogScale && contract.min === 0 ? 1 : 0
for (let i = 0; i < times.length - 1; i++) { for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: s * probs[i] + c } points[points.length] = { x: times[i], y: s * probs[i] }
const numPoints: number = Math.floor( const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
) )
@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
x: dayjs(times[i]) x: dayjs(times[i])
.add(thisTimeStep * n, 'ms') .add(thisTimeStep * n, 'ms')
.toDate(), .toDate(),
y: s * probs[i] + c, y: s * probs[i],
} }
} }
} }
@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const formatter = isBinary const formatter = isBinary
? formatPercent ? formatPercent
: isLogScale
? (x: DatumValue) =>
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
: (x: DatumValue) => formatLargeNumber(+x.valueOf()) : (x: DatumValue) => formatLargeNumber(+x.valueOf())
return ( return (
@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
yScale={ yScale={
isBinary isBinary
? { min: 0, max: 100, type: 'linear' } ? { min: 0, max: 100, type: 'linear' }
: { : isLogScale
min: contract.min + c, ? {
max: contract.max + c, min: 0,
type: contract.isLogScale ? 'log' : 'linear', max: Math.log10(contract.max - contract.min + 1),
type: 'linear',
} }
: { min: contract.min, max: contract.max, type: 'linear' }
} }
yFormat={formatter} yFormat={formatter}
gridYValues={yTickValues} gridYValues={yTickValues}
@ -143,7 +150,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
enableSlices="x" enableSlices="x"
enableGridX={!!width && width >= 800} enableGridX={!!width && width >= 800}
enableArea enableArea
margin={{ top: 20, right: 20, bottom: 25, left: 40 }} areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 65, left: 40 }}
animate={false} animate={false}
sliceTooltip={SliceTooltip} sliceTooltip={SliceTooltip}
/> />

View File

@ -3,31 +3,35 @@ import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
}
export function CopyLinkButton(props: { export function CopyLinkButton(props: {
contract: Contract url: string
displayUrl?: string
tracking?: string
buttonClassName?: string buttonClassName?: string
toastClassName?: string toastClassName?: string
}) { }) {
const { contract, buttonClassName, toastClassName } = props const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return ( return (
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={displayUrl ?? url}
/>
<Menu <Menu
as="div" as="div"
className="relative z-10 flex-shrink-0" className="relative z-10 flex-shrink-0"
onMouseUp={() => { onMouseUp={() => {
copyContractUrl(contract) copyToClipboard(url)
track('copy share link') track(tracking ?? 'copy share link')
}} }}
> >
<Menu.Button <Menu.Button
@ -56,5 +60,6 @@ export function CopyLinkButton(props: {
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
</Row>
) )
} }

View File

@ -5,6 +5,7 @@ import React from 'react'
export const createButtonStyle = export const createButtonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
export const CreateQuestionButton = (props: { export const CreateQuestionButton = (props: {
user: User | null | undefined user: User | null | undefined
overrideText?: string overrideText?: string

View File

@ -21,7 +21,8 @@ import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
const proseClass = clsx( const proseClass = clsx(
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless font-light' 'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
'font-light prose-a:font-light prose-blockquote:font-light'
) )
export function useTextEditor(props: { export function useTextEditor(props: {
@ -34,7 +35,7 @@ export function useTextEditor(props: {
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
'box-content min-h-[6em] textarea textarea-bordered' 'box-content min-h-[6em] textarea textarea-bordered text-base'
) )
const editor = useEditor({ const editor = useEditor({
@ -98,7 +99,7 @@ export function TextEditor(props: {
{editor && ( {editor && (
<FloatingMenu <FloatingMenu
editor={editor} editor={editor}
className="-ml-2 mr-2 w-full text-sm text-slate-300" className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
> >
Type <em>*markdown*</em>. Paste or{' '} Type <em>*markdown*</em>. Paste or{' '}
<FileUploadButton <FileUploadButton
@ -155,7 +156,9 @@ function RichContent(props: { content: JSONContent }) {
export function Content(props: { content: JSONContent | string }) { export function Content(props: { content: JSONContent | string }) {
const { content } = props const { content } = props
return typeof content === 'string' ? ( return typeof content === 'string' ? (
<div className="whitespace-pre-line font-light leading-relaxed">
<Linkify text={content} /> <Linkify text={content} />
</div>
) : ( ) : (
<RichContent content={content} /> <RichContent content={content} />
) )

View File

@ -53,9 +53,8 @@ export function GroupSelector(props: {
nullable={true} nullable={true}
className={'text-sm'} className={'text-sm'}
> >
{({ open }) => ( {() => (
<> <>
{!open && setQuery('')}
<Combobox.Label className="label justify-start gap-2 text-base"> <Combobox.Label className="label justify-start gap-2 text-base">
Add to Group Add to Group
<InfoTooltip text="Question will be displayed alongside the other questions in the group." /> <InfoTooltip text="Question will be displayed alongside the other questions in the group." />

View File

@ -135,7 +135,7 @@ export function JoinOrLeaveGroupButton(props: {
return ( return (
<button <button
className={clsx( className={clsx(
'btn btn-outline btn-sm', 'btn btn-outline btn-xs',
small && smallStyle, small && smallStyle,
className className
)} )}

View File

@ -76,11 +76,13 @@ export function LimitOrderTable(props: {
return ( return (
<table className="table-compact table w-full rounded text-gray-500"> <table className="table-compact table w-full rounded text-gray-500">
<thead> <thead>
<tr>
{!isYou && <th></th>} {!isYou && <th></th>}
<th>Outcome</th> <th>Outcome</th>
<th>Amount</th>
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th> <th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
<th>Amount</th>
{isYou && <th></th>} {isYou && <th></th>}
</tr>
</thead> </thead>
<tbody> <tbody>
{limitBets.map((bet) => ( {limitBets.map((bet) => (
@ -129,12 +131,12 @@ function LimitBet(props: {
)} )}
</div> </div>
</td> </td>
<td>{formatMoney(orderAmount - amount)}</td>
<td> <td>
{isPseudoNumeric {isPseudoNumeric
? getFormattedMappedValue(contract)(limitProb) ? getFormattedMappedValue(contract)(limitProb)
: formatPercent(limitProb)} : formatPercent(limitProb)}
</td> </td>
<td>{formatMoney(orderAmount - amount)}</td>
{isYou && ( {isYou && (
<td> <td>
{isCancelling ? ( {isCancelling ? (
@ -178,7 +180,7 @@ export function OrderBookButton(props: {
<Modal open={open} setOpen={setOpen} size="lg"> <Modal open={open} setOpen={setOpen} size="lg">
<Col className="rounded bg-white p-4 py-6"> <Col className="rounded bg-white p-4 py-6">
<Title className="!mt-0" text="Order book" /> <Title className="!mt-0" text="Order book" />
<Col className="justify-start gap-2 lg:flex-row lg:items-start"> <Row className="hidden items-start justify-start gap-2 md:flex">
<LimitOrderTable <LimitOrderTable
limitBets={yesBets} limitBets={yesBets}
contract={contract} contract={contract}
@ -189,6 +191,13 @@ export function OrderBookButton(props: {
contract={contract} contract={contract}
isYou={false} isYou={false}
/> />
</Row>
<Col className="md:hidden">
<LimitOrderTable
limitBets={limitBets}
contract={contract}
isYou={false}
/>
</Col> </Col>
</Col> </Col>
</Modal> </Modal>

View File

@ -13,7 +13,7 @@ import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { firebaseLogout, updateUser, User } from 'web/lib/firebase/users' import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo' import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
@ -193,10 +193,13 @@ export default function Sidebar(props: { className?: string }) {
const user = useUser() const user = useUser()
const privateUser = usePrivateUser(user?.id) const privateUser = usePrivateUser(user?.id)
// usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation() const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user const mobileNavigationOptions = !user
? signedOutMobileNavigation ? signedOutMobileNavigation
: signedInMobileNavigation : signedInMobileNavigation
const memberItems = ( const memberItems = (
useMemberGroups( useMemberGroups(
user?.id, user?.id,
@ -208,16 +211,6 @@ export default function Sidebar(props: { className?: string }) {
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
})) }))
useEffect(() => {
if (!user) return
const pingInterval = setInterval(() => {
updateUser(user.id, {
lastPingTime: Date.now(),
})
}, 1000 * 30)
return () => clearInterval(pingInterval)
}, [user])
return ( return (
<nav aria-label="Sidebar" className={className}> <nav aria-label="Sidebar" className={className}>
<ManifoldLogo className="py-6" twoLine /> <ManifoldLogo className="py-6" twoLine />
@ -242,7 +235,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
)} )}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}
@ -263,11 +259,7 @@ export default function Sidebar(props: { className?: string }) {
/> />
{/* Spacer if there are any groups */} {/* Spacer if there are any groups */}
{memberItems.length > 0 && ( {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}
@ -296,15 +288,17 @@ function GroupsList(props: {
// Set notification as seen if our current page is equal to the isSeenOnHref property // Set notification as seen if our current page is equal to the isSeenOnHref property
useEffect(() => { useEffect(() => {
const currentPageGroupSlug = currentPage.split('/')[2] const currentPageWithoutQuery = currentPage.split('?')[0]
const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2]
preferredNotifications.forEach((notification) => { preferredNotifications.forEach((notification) => {
if ( if (
notification.isSeenOnHref === currentPage || notification.isSeenOnHref === currentPage ||
// Old chat style group chat notif ended just with the group slug // Old chat style group chat notif was just /group/slug
notification.isSeenOnHref?.endsWith(currentPageGroupSlug) || (notification.isSeenOnHref &&
currentPageWithoutQuery.includes(notification.isSeenOnHref)) ||
// They're on the home page, so if they've a chat notif, they're seeing the chat // They're on the home page, so if they've a chat notif, they're seeing the chat
(notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) &&
currentPage.endsWith(currentPageGroupSlug)) currentPageWithoutQuery.endsWith(currentPageGroupSlug))
) { ) {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
} }

View File

@ -8,9 +8,11 @@ export function Page(props: {
rightSidebar?: ReactNode rightSidebar?: ReactNode
suspend?: boolean suspend?: boolean
className?: string className?: string
rightSidebarClassName?: string
children?: ReactNode children?: ReactNode
}) { }) {
const { children, rightSidebar, suspend, className } = props const { children, rightSidebar, suspend, className, rightSidebarClassName } =
props
const bottomBarPadding = 'pb-[58px] lg:pb-0 ' const bottomBarPadding = 'pb-[58px] lg:pb-0 '
return ( return (
@ -37,7 +39,11 @@ export function Page(props: {
<div className="block xl:hidden">{rightSidebar}</div> <div className="block xl:hidden">{rightSidebar}</div>
</main> </main>
<aside className="hidden xl:col-span-3 xl:block"> <aside className="hidden xl:col-span-3 xl:block">
<div className="sticky top-4 space-y-4">{rightSidebar}</div> <div
className={clsx('sticky top-4 space-y-4', rightSidebarClassName)}
>
{rightSidebar}
</div>
</aside> </aside>
</div> </div>

View File

@ -4,8 +4,18 @@ export function Pagination(props: {
totalItems: number totalItems: number
setPage: (page: number) => void setPage: (page: number) => void
scrollToTop?: boolean scrollToTop?: boolean
nextTitle?: string
prevTitle?: string
}) { }) {
const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props const {
page,
itemsPerPage,
totalItems,
setPage,
scrollToTop,
nextTitle,
prevTitle,
} = props
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
@ -25,19 +35,21 @@ export function Pagination(props: {
</p> </p>
</div> </div>
<div className="flex flex-1 justify-between sm:justify-end"> <div className="flex flex-1 justify-between sm:justify-end">
{page > 0 && (
<a <a
href={scrollToTop ? '#' : undefined} href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page > 0 && setPage(page - 1)} onClick={() => page > 0 && setPage(page - 1)}
> >
Previous {prevTitle ?? 'Previous'}
</a> </a>
)}
<a <a
href={scrollToTop ? '#' : undefined} href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page < maxPage && setPage(page + 1)} onClick={() => page < maxPage && setPage(page + 1)}
> >
Next {nextTitle ?? 'Next'}
</a> </a>
</div> </div>
</nav> </nav>

View File

@ -19,6 +19,13 @@ export const PortfolioValueSection = memo(
return <></> return <></>
} }
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
// instead of "All time"
const allTimeLabel =
lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z')
? 'Since June'
: 'All time'
return ( return (
<div> <div>
<Row className="gap-8"> <Row className="gap-8">
@ -39,7 +46,7 @@ export const PortfolioValueSection = memo(
setPortfolioPeriod(e.target.value as Period) setPortfolioPeriod(e.target.value as Period)
}} }}
> >
<option value="allTime">All time</option> <option value="allTime">{allTimeLabel}</option>
<option value="weekly">7 days</option> <option value="weekly">7 days</option>
<option value="daily">24 hours</option> <option value="daily">24 hours</option>
</select> </select>

View File

@ -1,5 +1,8 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Contract, contractUrl } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button' import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -7,18 +10,15 @@ import { Row } from './layout/row'
export function ShareMarket(props: { contract: Contract; className?: string }) { export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return ( return (
<Col className={clsx(className, 'gap-3')}> <Col className={clsx(className, 'gap-3')}>
<div>Share your market</div> <div>Share your market</div>
<Row className="mb-6 items-center"> <Row className="mb-6 items-center">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={contractUrl(contract)}
/>
<CopyLinkButton <CopyLinkButton
contract={contract} url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none" buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'} toastClassName={'-left-28 mt-1'}
/> />

View File

@ -39,6 +39,7 @@ import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button' import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -123,6 +124,7 @@ export function UserPage(props: {
const yourFollows = useFollows(currentUser?.id) const yourFollows = useFollows(currentUser?.id)
const isFollowing = yourFollows?.includes(user.id) const isFollowing = yourFollows?.includes(user.id)
const profit = user.profitCached.allTime
const onFollow = () => { const onFollow = () => {
if (!currentUser) return if (!currentUser) return
@ -187,6 +189,17 @@ export function UserPage(props: {
<Col className="mx-4 -mt-6"> <Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span> <span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span> <span className="text-gray-500">@{user.username}</span>
<span className="text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user' import { notification_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification' import { Notification } from 'common/notification'
import { import {
@ -6,7 +6,7 @@ import {
listenForNotifications, listenForNotifications,
} from 'web/lib/firebase/notifications' } from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash' import { groupBy, map } from 'lodash'
import { useFirestoreQuery } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export type NotificationGroup = { export type NotificationGroup = {
@ -19,36 +19,33 @@ export type NotificationGroup = {
// For some reason react-query subscriptions don't actually listen for notifications // For some reason react-query subscriptions don't actually listen for notifications
// Use useUnseenPreferredNotificationGroups to listen for new notifications // Use useUnseenPreferredNotificationGroups to listen for new notifications
export function usePreferredGroupedNotifications(privateUser: PrivateUser) { export function usePreferredGroupedNotifications(
const [notificationGroups, setNotificationGroups] = useState< privateUser: PrivateUser,
NotificationGroup[] | undefined cachedNotifications?: Notification[]
>(undefined) ) {
const [notifications, setNotifications] = useState<Notification[]>([]) const result = useFirestoreQueryData(
const key = `notifications-${privateUser.id}-all` ['notifications-all', privateUser.id],
getNotificationsQuery(privateUser.id)
const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id))
useEffect(() => {
if (result.isLoading) return
if (!result.data) return setNotifications([])
const notifications = result.data.docs.map(
(doc) => doc.data() as Notification
) )
const notifications = useMemo(() => {
if (result.isLoading) return cachedNotifications ?? []
if (!result.data) return cachedNotifications ?? []
const notifications = result.data as Notification[]
const notificationsToShow = getAppropriateNotifications( return getAppropriateNotifications(
notifications, notifications,
privateUser.notificationPreferences privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref) ).filter((n) => !n.isSeenOnHref)
setNotifications(notificationsToShow) }, [
}, [privateUser.notificationPreferences, result.data, result.isLoading]) cachedNotifications,
privateUser.notificationPreferences,
result.data,
result.isLoading,
])
useEffect(() => { return useMemo(() => {
if (!notifications) return if (notifications) return groupNotifications(notifications)
const groupedNotifications = groupNotifications(notifications)
setNotificationGroups(groupedNotifications)
}, [notifications]) }, [notifications])
return notificationGroups
} }
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {

16
web/hooks/use-ping.ts Normal file
View File

@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { updateUser } from 'web/lib/firebase/users'
export const usePing = (userId: string | undefined) => {
useEffect(() => {
if (!userId) return
const pingInterval = setInterval(() => {
updateUser(userId, {
lastPingTime: Date.now(),
})
}, 1000 * 30)
return () => clearInterval(pingInterval)
}, [userId])
}

View File

@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchBox } from 'react-instantsearch-hooks-web' import { useSearchBox } from 'react-instantsearch-hooks-web'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { DEFAULT_SORT } from 'web/components/contract-search'
const MARKETS_SORT = 'markets_sort' const MARKETS_SORT = 'markets_sort'
@ -10,11 +11,11 @@ export type Sort =
| 'newest' | 'newest'
| 'oldest' | 'oldest'
| 'most-traded' | 'most-traded'
| 'most-popular'
| '24-hour-vol' | '24-hour-vol'
| 'close-date' | 'close-date'
| 'resolve-date' | 'resolve-date'
| 'last-updated' | 'last-updated'
| 'score'
export function getSavedSort() { export function getSavedSort() {
// TODO: this obviously doesn't work with SSR, common sense would suggest // TODO: this obviously doesn't work with SSR, common sense would suggest
@ -31,7 +32,7 @@ export function useInitialQueryAndSort(options?: {
shouldLoadFromStorage?: boolean shouldLoadFromStorage?: boolean
}) { }) {
const { defaultSort, shouldLoadFromStorage } = defaults(options, { const { defaultSort, shouldLoadFromStorage } = defaults(options, {
defaultSort: 'most-popular', defaultSort: DEFAULT_SORT,
shouldLoadFromStorage: true, shouldLoadFromStorage: true,
}) })
const router = useRouter() const router = useRouter()
@ -53,9 +54,12 @@ export function useInitialQueryAndSort(options?: {
console.log('ready loading from storage ', sort ?? defaultSort) console.log('ready loading from storage ', sort ?? defaultSort)
const localSort = getSavedSort() const localSort = getSavedSort()
if (localSort) { if (localSort) {
router.query.s = localSort
// Use replace to not break navigating back. // Use replace to not break navigating back.
router.replace(router, undefined, { shallow: true }) router.replace(
{ query: { ...router.query, s: localSort } },
undefined,
{ shallow: true }
)
} }
setInitialSort(localSort ?? defaultSort) setInitialSort(localSort ?? defaultSort)
} else { } else {
@ -79,7 +83,9 @@ export function useUpdateQueryAndSort(props: {
const setSort = (sort: Sort | undefined) => { const setSort = (sort: Sort | undefined) => {
if (sort !== router.query.s) { if (sort !== router.query.s) {
router.query.s = sort router.query.s = sort
router.push(router, undefined, { shallow: true }) router.replace({ query: { ...router.query, s: sort } }, undefined, {
shallow: true,
})
if (shouldLoadFromStorage) { if (shouldLoadFromStorage) {
localStorage.setItem(MARKETS_SORT, sort || '') localStorage.setItem(MARKETS_SORT, sort || '')
} }
@ -97,7 +103,9 @@ export function useUpdateQueryAndSort(props: {
} else { } else {
delete router.query.q delete router.query.q
} }
router.push(router, undefined, { shallow: true }) router.replace({ query: router.query }, undefined, {
shallow: true,
})
track('search', { query }) track('search', { query })
}, 500), }, 500),
[router] [router]

54
web/lib/firebase/auth.ts Normal file
View File

@ -0,0 +1,54 @@
import { PROJECT_ID } from 'common/envs/constants'
import { setCookie, getCookies } from '../util/cookie'
import { IncomingMessage, ServerResponse } from 'http'
const TOKEN_KINDS = ['refresh', 'id'] as const
type TokenKind = typeof TOKEN_KINDS[number]
const getAuthCookieName = (kind: TokenKind) => {
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
return `FIREBASE_TOKEN_${suffix}`
}
const ID_COOKIE_NAME = getAuthCookieName('id')
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
export const getAuthCookies = (request?: IncomingMessage) => {
const data = request != null ? request.headers.cookie ?? '' : document.cookie
const cookies = getCookies(data)
return {
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
}
}
export const setAuthCookies = (
idToken?: string,
refreshToken?: string,
response?: ServerResponse
) => {
// these tokens last an hour
const idMaxAge = idToken != null ? 60 * 60 : 0
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
['path', '/'],
['max-age', idMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
// these tokens don't expire
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
['path', '/'],
['max-age', refreshMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
if (response != null) {
response.setHeader('Set-Cookie', [idCookie, refreshCookie])
} else {
document.cookie = idCookie
document.cookie = refreshCookie
}
}
export const deleteAuthCookies = () => setAuthCookies()

View File

@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
export function groupPath( export function groupPath(
groupSlug: string, groupSlug: string,
subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings' subpath?:
| 'edit'
| 'questions'
| 'about'
| typeof GROUP_CHAT_SLUG
| 'leaderboards'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }
@ -68,10 +73,10 @@ export function listenForMemberGroups(
const q = query(groups, where('memberIds', 'array-contains', userId)) const q = query(groups, where('memberIds', 'array-contains', userId))
const sorter = (group: Group) => { const sorter = (group: Group) => {
if (sort?.by === 'mostRecentChatActivityTime') { if (sort?.by === 'mostRecentChatActivityTime') {
return group.mostRecentChatActivityTime ?? group.mostRecentActivityTime return group.mostRecentChatActivityTime ?? group.createdTime
} }
if (sort?.by === 'mostRecentContractAddedTime') { if (sort?.by === 'mostRecentContractAddedTime') {
return group.mostRecentContractAddedTime ?? group.mostRecentActivityTime return group.mostRecentContractAddedTime ?? group.createdTime
} }
return group.mostRecentActivityTime return group.mostRecentActivityTime
} }
@ -89,11 +94,11 @@ export async function getGroupsWithContractId(
setGroups(await getValues<Group>(q)) setGroups(await getValues<Group>(q))
} }
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { export async function addUserToGroupViaId(groupId: string, userId: string) {
// get group to get the member ids // get group to get the member ids
const group = await getGroupBySlug(groupSlug) const group = await getGroup(groupId)
if (!group) { if (!group) {
console.error(`Group not found: ${groupSlug}`) console.error(`Group not found: ${groupId}`)
return return
} }
return await joinGroup(group, userId) return await joinGroup(group, userId)

View File

@ -0,0 +1,93 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { IncomingMessage, ServerResponse } from 'http'
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
import { getAuthCookies, setAuthCookies } from './auth'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
const ensureApp = async () => {
// Note: firebase-admin can only be imported from a server context,
// because it relies on Node standard library dependencies.
if (admin.apps.length === 0) {
// never initialize twice
return admin.initializeApp({ projectId: PROJECT_ID })
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return admin.apps[0]!
}
const requestFirebaseIdToken = async (refreshToken: string) => {
// See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token
const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token')
refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey)
const result = await fetch(refreshUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})
if (!result.ok) {
throw new Error(`Could not refresh ID token: ${await result.text()}`)
}
return (await result.json()) as any
}
type RequestContext = {
req: IncomingMessage
res: ServerResponse
}
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
const app = await ensureApp()
const auth = app.auth()
const { idToken, refreshToken } = getAuthCookies(ctx.req)
// If we have a valid ID token, verify the user immediately with no network trips.
// If the ID token doesn't verify, we'll have to refresh it to see who they are.
// If they don't have any tokens, then we have no idea who they are.
if (idToken != null) {
try {
return (await auth.verifyIdToken(idToken))?.uid
} catch {
// plausibly expired; try the refresh token, if it's present
}
}
if (refreshToken != null) {
try {
const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
return (await auth.verifyIdToken(resp.id_token))?.uid
} catch (e) {
// this is a big unexpected problem -- either their cookies are corrupt
// or the refresh token API is down. functionally, they are not logged in
console.error(e)
}
}
return undefined
}
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return fn != null ? await fn(ctx) : { props: {} }
} else {
return { redirect: { destination: dest, permanent: false } }
}
}
}
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return { redirect: { destination: dest, permanent: false } }
} else {
return fn != null ? await fn(ctx) : { props: {} }
}
}
}

View File

@ -16,7 +16,7 @@ import {
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { import {
onAuthStateChanged, onIdTokenChanged,
GoogleAuthProvider, GoogleAuthProvider,
signInWithPopup, signInWithPopup,
} from 'firebase/auth' } from 'firebase/auth'
@ -35,7 +35,7 @@ import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories' import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local' import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { addUserToGroupViaSlug } from 'web/lib/firebase/groups' import { addUserToGroupViaId } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { randomString } from 'common/util/random' import { randomString } from 'common/util/random'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -43,6 +43,7 @@ import utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { deleteAuthCookies, setAuthCookies } from './auth'
export const users = coll<User>('users') export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users') export const privateUsers = coll<PrivateUser>('private-users')
@ -99,13 +100,13 @@ export function listenForPrivateUser(
const CACHED_USER_KEY = 'CACHED_USER_KEY' const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY' const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
export function writeReferralInfo( export function writeReferralInfo(
defaultReferrerUsername: string, defaultReferrerUsername: string,
contractId?: string, contractId?: string,
referralUsername?: string, referralUsername?: string,
groupSlug?: string groupId?: string
) { ) {
const local = safeLocalStorage() const local = safeLocalStorage()
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
@ -121,7 +122,7 @@ export function writeReferralInfo(
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
// Always write the most recent explicit group invite query value // Always write the most recent explicit group invite query value
if (groupSlug) local?.setItem(CACHED_REFERRAL_GROUP_SLUG_KEY, groupSlug) if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)
// Write the first contract id that we see. // Write the first contract id that we see.
const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY) const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
@ -134,14 +135,14 @@ async function setCachedReferralInfoForUser(user: User | null) {
// if the user wasn't created in the last minute, don't bother // if the user wasn't created in the last minute, don't bother
const now = dayjs().utc() const now = dayjs().utc()
const userCreatedTime = dayjs(user.createdTime) const userCreatedTime = dayjs(user.createdTime)
if (now.diff(userCreatedTime, 'minute') > 1) return if (now.diff(userCreatedTime, 'minute') > 5) return
const local = safeLocalStorage() const local = safeLocalStorage()
const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
const cachedReferralContractId = local?.getItem( const cachedReferralContractId = local?.getItem(
CACHED_REFERRAL_CONTRACT_ID_KEY CACHED_REFERRAL_CONTRACT_ID_KEY
) )
const cachedReferralGroupSlug = local?.getItem(CACHED_REFERRAL_GROUP_SLUG_KEY) const cachedReferralGroupId = local?.getItem(CACHED_REFERRAL_GROUP_ID_KEY)
// get user via username // get user via username
if (cachedReferralUsername) if (cachedReferralUsername)
@ -155,6 +156,9 @@ async function setCachedReferralInfoForUser(user: User | null) {
referredByContractId: cachedReferralContractId referredByContractId: cachedReferralContractId
? cachedReferralContractId ? cachedReferralContractId
: undefined, : undefined,
referredByGroupId: cachedReferralGroupId
? cachedReferralGroupId
: undefined,
}) })
) )
.catch((err) => { .catch((err) => {
@ -165,15 +169,14 @@ async function setCachedReferralInfoForUser(user: User | null) {
userId: user.id, userId: user.id,
referredByUserId: referredByUser.id, referredByUserId: referredByUser.id,
referredByContractId: cachedReferralContractId, referredByContractId: cachedReferralContractId,
referredByGroupSlug: cachedReferralGroupSlug, referredByGroupId: cachedReferralGroupId,
}) })
}) })
}) })
if (cachedReferralGroupSlug) if (cachedReferralGroupId) addUserToGroupViaId(cachedReferralGroupId, user.id)
addUserToGroupViaSlug(cachedReferralGroupSlug, user.id)
local?.removeItem(CACHED_REFERRAL_GROUP_SLUG_KEY) local?.removeItem(CACHED_REFERRAL_GROUP_ID_KEY)
local?.removeItem(CACHED_REFERRAL_USERNAME_KEY) local?.removeItem(CACHED_REFERRAL_USERNAME_KEY)
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
} }
@ -186,10 +189,9 @@ export function listenForLogin(onUser: (user: User | null) => void) {
const cachedUser = local?.getItem(CACHED_USER_KEY) const cachedUser = local?.getItem(CACHED_USER_KEY)
onUser(cachedUser && JSON.parse(cachedUser)) onUser(cachedUser && JSON.parse(cachedUser))
return onAuthStateChanged(auth, async (fbUser) => { return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) { if (fbUser) {
let user: User | null = await getUser(fbUser.uid) let user: User | null = await getUser(fbUser.uid)
if (!user) { if (!user) {
if (createUserPromise == null) { if (createUserPromise == null) {
const local = safeLocalStorage() const local = safeLocalStorage()
@ -202,17 +204,19 @@ export function listenForLogin(onUser: (user: User | null) => void) {
} }
user = await createUserPromise user = await createUserPromise
} }
onUser(user) onUser(user)
// Persist to local storage, to reduce login blink next time. // Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb // Note: Cap on localStorage size is ~5mb
local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user) setCachedReferralInfoForUser(user)
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
} else { } else {
// User logged out; reset to null // User logged out; reset to null
onUser(null) onUser(null)
createUserPromise = undefined
local?.removeItem(CACHED_USER_KEY) local?.removeItem(CACHED_USER_KEY)
deleteAuthCookies()
} }
}) })
} }
@ -271,7 +275,7 @@ export function getTopTraders(period: Period) {
limit(20) limit(20)
) )
return getValues(topTraders) return getValues<User>(topTraders)
} }
export function getTopCreators(period: Period) { export function getTopCreators(period: Period) {
@ -280,7 +284,7 @@ export function getTopCreators(period: Period) {
orderBy('creatorVolumeCached.' + period, 'desc'), orderBy('creatorVolumeCached.' + period, 'desc'),
limit(20) limit(20)
) )
return getValues(topCreators) return getValues<User>(topCreators)
} }
export async function getTopFollowed() { export async function getTopFollowed() {

33
web/lib/util/cookie.ts Normal file
View File

@ -0,0 +1,33 @@
type CookieOptions = string[][]
const encodeCookie = (name: string, val: string) => {
return `${name}=${encodeURIComponent(val)}`
}
const decodeCookie = (cookie: string) => {
const parts = cookie.trim().split('=')
if (parts.length < 2) {
throw new Error(`Invalid cookie contents: ${cookie}`)
}
const rest = parts.slice(1).join('') // there may be more = in the value
return [parts[0], decodeURIComponent(rest)] as const
}
export const setCookie = (name: string, val: string, opts?: CookieOptions) => {
const parts = [encodeCookie(name, val)]
if (opts != null) {
parts.push(...opts.map((opt) => opt.join('=')))
}
return parts.join('; ')
}
// Note that this intentionally ignores the case where multiple cookies have
// the same name but different paths. Hopefully we never need to think about it.
export const getCookies = (cookies: string) => {
const data = cookies.trim()
if (!data) {
return {}
} else {
return Object.fromEntries(data.split(';').map(decodeCookie))
}
}

View File

@ -4,6 +4,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
module.exports = { module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false,
experimental: { experimental: {
externalDir: true, externalDir: true,
optimizeCss: true, optimizeCss: true,

View File

@ -6,16 +6,15 @@ export default function Document() {
<Html data-theme="mantic" className="min-h-screen"> <Html data-theme="mantic" className="min-h-screen">
<Head> <Head>
<link rel="icon" href={ENV_CONFIG.faviconPath} /> <link rel="icon" href={ENV_CONFIG.faviconPath} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link
rel="preconnect" rel="preconnect"
href="https://fonts.gstatic.com" href="https://fonts.gstatic.com"
crossOrigin="true" crossOrigin="anonymous"
/> />
<link <link
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
crossOrigin="anonymous"
/> />
<link <link
rel="stylesheet" rel="stylesheet"
@ -24,7 +23,6 @@ export default function Document() {
crossOrigin="anonymous" crossOrigin="anonymous"
/> />
</Head> </Head>
<body className="font-readex-pro bg-base-200 min-h-screen"> <body className="font-readex-pro bg-base-200 min-h-screen">
<Main /> <Main />
<NextScript /> <NextScript />

View File

@ -8,6 +8,9 @@ import { checkoutURL } from 'web/lib/service/stripe'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
export default function AddFundsPage() { export default function AddFundsPage() {
const user = useUser() const user = useUser()

View File

@ -9,6 +9,9 @@ import { useContracts } from 'web/hooks/use-contracts'
import { mapKeys } from 'lodash' import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
function avatarHtml(avatarUrl: string) { function avatarHtml(avatarUrl: string) {
return `<img return `<img

View File

@ -13,7 +13,6 @@ import { CharityCard } from 'web/components/charity/charity-card'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { getAllCharityTxns } from 'web/lib/firebase/txns' import { getAllCharityTxns } from 'web/lib/firebase/txns'
import { manaToUSD } from 'common/util/format' import { manaToUSD } from 'common/util/format'
@ -21,6 +20,9 @@ import { quadraticMatches } from 'common/quadratic-funding'
import { Txn } from 'common/txn' import { Txn } from 'common/txn'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { getUser } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link'
import { User } from 'common/user'
export async function getStaticProps() { export async function getStaticProps() {
const txns = await getAllCharityTxns() const txns = await getAllCharityTxns()
@ -34,6 +36,7 @@ export async function getStaticProps() {
]) ])
const matches = quadraticMatches(txns, totalRaised) const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length const numDonors = uniqBy(txns, (txn) => txn.fromId).length
const mostRecentDonor = await getUser(txns[txns.length - 1].fromId)
return { return {
props: { props: {
@ -42,6 +45,7 @@ export async function getStaticProps() {
matches, matches,
txns, txns,
numDonors, numDonors,
mostRecentDonor,
}, },
revalidate: 60, revalidate: 60,
} }
@ -50,22 +54,28 @@ export async function getStaticProps() {
type Stat = { type Stat = {
name: string name: string
stat: string stat: string
url?: string
} }
function DonatedStats(props: { stats: Stat[] }) { function DonatedStats(props: { stats: Stat[] }) {
const { stats } = props const { stats } = props
return ( return (
<dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3"> <dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3">
{stats.map((item) => ( {stats.map((stat) => (
<div <div
key={item.name} key={stat.name}
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6" className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
> >
<dt className="truncate text-sm font-medium text-gray-500"> <dt className="truncate text-sm font-medium text-gray-500">
{item.name} {stat.name}
</dt> </dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900"> <dd className="mt-1 text-3xl font-semibold text-gray-900">
{item.stat} {stat.url ? (
<SiteLink href={stat.url}>{stat.stat}</SiteLink>
) : (
<span>{stat.stat}</span>
)}
</dd> </dd>
</div> </div>
))} ))}
@ -79,8 +89,9 @@ export default function Charity(props: {
matches: { [charityId: string]: number } matches: { [charityId: string]: number }
txns: Txn[] txns: Txn[]
numDonors: number numDonors: number
mostRecentDonor: User
}) { }) {
const { totalRaised, charities, matches, numDonors } = props const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const debouncedQuery = debounce(setQuery, 50) const debouncedQuery = debounce(setQuery, 50)
@ -106,7 +117,7 @@ export default function Charity(props: {
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]">
<Col className=""> <Col className="">
<Title className="!mt-0" text="Manifold for Charity" /> <Title className="!mt-0" text="Manifold for Charity" />
<span className="text-gray-600"> {/* <span className="text-gray-600">
Through July 15, up to $25k of donations will be matched via{' '} Through July 15, up to $25k of donations will be matched via{' '}
<SiteLink href="https://wtfisqf.com/" className="font-bold"> <SiteLink href="https://wtfisqf.com/" className="font-bold">
quadratic funding quadratic funding
@ -116,7 +127,7 @@ export default function Charity(props: {
the FTX Future Fund the FTX Future Fund
</SiteLink> </SiteLink>
! !
</span> </span> */}
<DonatedStats <DonatedStats
stats={[ stats={[
{ {
@ -128,8 +139,9 @@ export default function Charity(props: {
stat: `${numDonors}`, stat: `${numDonors}`,
}, },
{ {
name: 'Matched via quadratic funding', name: 'Most recent donor',
stat: manaToUSD(sum(Object.values(matches))), stat: mostRecentDonor.name ?? 'Nobody',
url: `/${mostRecentDonor.username}`,
}, },
]} ]}
/> />

View File

@ -54,10 +54,8 @@ export default function ContractSearchFirestore(props: {
) )
} else if (sort === 'most-traded') { } else if (sort === 'most-traded') {
matches.sort((a, b) => b.volume - a.volume) matches.sort((a, b) => b.volume - a.volume)
} else if (sort === 'most-popular') { } else if (sort === 'score') {
matches.sort( matches.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
(a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0)
)
} else if (sort === '24-hour-vol') { } else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties. // Use lodash for stable sort, so previous sort breaks all ties.
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days) matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
@ -104,7 +102,7 @@ export default function ContractSearchFirestore(props: {
> >
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="oldest">Oldest</option> <option value="oldest">Oldest</option>
<option value="most-popular">Most popular</option> <option value="score">Most popular</option>
<option value="most-traded">Most traded</option> <option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option> <option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option> <option value="close-date">Closing soon</option>

View File

@ -28,6 +28,9 @@ import { GroupSelector } from 'web/components/groups/group-selector'
import { User } from 'common/user' import { User } from 'common/user'
import { TextEditor, useTextEditor } from 'web/components/editor' import { TextEditor, useTextEditor } from 'web/components/editor'
import { Checkbox } from 'web/components/checkbox' import { Checkbox } from 'web/components/checkbox'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
type NewQuestionParams = { type NewQuestionParams = {
groupId?: string groupId?: string
@ -55,10 +58,6 @@ export default function Create() {
}, [params.q]) }, [params.q])
const creator = useUser() const creator = useUser()
useEffect(() => {
if (creator === null) router.push('/')
}, [creator, router])
if (!router.isReady || !creator) return <div /> if (!router.isReady || !creator) return <div />
return ( return (
@ -93,7 +92,7 @@ export default function Create() {
// Allow user to create a new contract // Allow user to create a new contract
export function NewContract(props: { export function NewContract(props: {
creator: User creator?: User | null
question: string question: string
params?: NewQuestionParams params?: NewQuestionParams
}) { }) {
@ -207,7 +206,7 @@ export function NewContract(props: {
min, min,
max, max,
initialValue, initialValue,
isLogScale: (min ?? 0) < 0 ? false : isLogScale, isLogScale,
groupId: selectedGroup?.id, groupId: selectedGroup?.id,
}) })
) )
@ -294,7 +293,6 @@ export function NewContract(props: {
/> />
</Row> </Row>
{!(min !== undefined && min < 0) && (
<Checkbox <Checkbox
className="my-2 text-sm" className="my-2 text-sm"
label="Log scale" label="Log scale"
@ -302,7 +300,6 @@ export function NewContract(props: {
toggle={() => setIsLogScale(!isLogScale)} toggle={() => setIsLogScale(!isLogScale)}
disabled={isSubmitting} disabled={isSubmitting}
/> />
)}
{min !== undefined && max !== undefined && min >= max && ( {min !== undefined && max !== undefined && min >= max && (
<div className="mt-2 mb-2 text-sm text-red-500"> <div className="mt-2 mb-2 text-sm text-red-500">
@ -379,12 +376,10 @@ export function NewContract(props: {
type={'date'} type={'date'}
className="input input-bordered mt-4" className="input input-bordered mt-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange={(e) => setCloseDate(e.target.value)}
setCloseDate(dayjs(e.target.value).format('YYYY-MM-DD') || '')
}
min={Date.now()} min={Date.now()}
disabled={isSubmitting} disabled={isSubmitting}
value={dayjs(closeDate).format('YYYY-MM-DD')} value={closeDate}
/> />
<input <input
type={'time'} type={'time'}

View File

@ -1,4 +1,5 @@
import { take, sortBy, debounce } from 'lodash' import { take, sortBy, debounce } from 'lodash'
import PlusSmIcon from '@heroicons/react/solid/PlusSmIcon'
import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -32,10 +33,7 @@ import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { import { CreateQuestionButton } from 'web/components/create-question-button'
createButtonStyle,
CreateQuestionButton,
} from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat' import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
@ -44,7 +42,6 @@ import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments' import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user' import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx' import clsx from 'clsx'
@ -52,8 +49,10 @@ import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline' import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { OnlineUserList } from 'web/components/online-user-list'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -115,7 +114,7 @@ const groupSubpages = [
undefined, undefined,
GROUP_CHAT_SLUG, GROUP_CHAT_SLUG,
'questions', 'questions',
'rankings', 'leaderboards',
'about', 'about',
] as const ] as const
@ -161,9 +160,14 @@ export default function GroupPage(props: {
referrer?: string referrer?: string
} }
if (!user && router.isReady) if (!user && router.isReady)
writeReferralInfo(creator.username, undefined, referrer, group?.slug) writeReferralInfo(creator.username, undefined, referrer, group?.id)
}, [user, creator, group, router]) }, [user, creator, group, router])
const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled
const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280
const showChatTab = !chatDisabled && !showChatSidebar
if (group === null || !groupSubpages.includes(page) || slugs[2]) { if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 /> return <Custom404 />
} }
@ -171,16 +175,6 @@ export default function GroupPage(props: {
const isCreator = user && group && user.id === group.creatorId const isCreator = user && group && user.id === group.creatorId
const isMember = user && memberIds.includes(user.id) const isMember = user && memberIds.includes(user.id)
const rightSidebar = (
<Col className="mt-6 hidden xl:block">
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
<OnlineUserList users={members} />
</Col>
)
const leaderboard = ( const leaderboard = (
<Col> <Col>
<GroupLeaderboards <GroupLeaderboards
@ -206,28 +200,17 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const tabs = [ const chatTab = (
...(group.chatDisabled <Col className="">
? [] {messages ? (
: [ <GroupChat messages={messages} user={user} group={group} tips={tips} />
{
title: 'Chat',
content: messages ? (
<GroupChat
messages={messages}
user={user}
group={group}
tips={tips}
/>
) : ( ) : (
<LoadingIndicator /> <LoadingIndicator />
), )}
href: groupPath(group.slug, GROUP_CHAT_SLUG), </Col>
}, )
]),
{ const questionsTab = (
title: 'Questions',
content: (
<ContractSearch <ContractSearch
querySortOptions={{ querySortOptions={{
shouldLoadFromStorage: true, shouldLoadFromStorage: true,
@ -236,13 +219,27 @@ export default function GroupPage(props: {
}} }}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
/> />
), )
const tabs = [
...(!showChatTab
? []
: [
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, GROUP_CHAT_SLUG),
},
]),
{
title: 'Questions',
content: questionsTab,
href: groupPath(group.slug, 'questions'), href: groupPath(group.slug, 'questions'),
}, },
{ {
title: 'Rankings', title: 'Leaderboards',
content: leaderboard, content: leaderboard,
href: groupPath(group.slug, 'rankings'), href: groupPath(group.slug, 'leaderboards'),
}, },
{ {
title: 'About', title: 'About',
@ -251,8 +248,13 @@ export default function GroupPage(props: {
}, },
] ]
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
return ( return (
<Page rightSidebar={rightSidebar} className="!pb-0"> <Page
rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-none !pb-0' : ''}
>
<SEO <SEO
title={group.name} title={group.name}
description={`Created by ${creator.name}. ${group.about}`} description={`Created by ${creator.name}. ${group.about}`}
@ -262,9 +264,7 @@ export default function GroupPage(props: {
<Row className={'items-center justify-between gap-4'}> <Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}> <div className={'sm:mb-1'}>
<div <div
className={ className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
'line-clamp-1 my-1 text-lg text-indigo-700 sm:my-3 sm:text-2xl'
}
> >
{group.name} {group.name}
</div> </div>
@ -272,7 +272,7 @@ export default function GroupPage(props: {
<Linkify text={group.about} /> <Linkify text={group.about} />
</div> </div>
</div> </div>
<div className="hidden sm:block xl:hidden"> <div className="mt-2">
<JoinOrAddQuestionsButtons <JoinOrAddQuestionsButtons
group={group} group={group}
user={user} user={user}
@ -280,13 +280,6 @@ export default function GroupPage(props: {
/> />
</div> </div>
</Row> </Row>
<div className="block sm:hidden">
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</Col> </Col>
<Tabs <Tabs
currentPageForAnalytics={groupPath(group.slug)} currentPageForAnalytics={groupPath(group.slug)}
@ -305,21 +298,7 @@ function JoinOrAddQuestionsButtons(props: {
}) { }) {
const { group, user, isMember } = props const { group, user, isMember } = props
return user && isMember ? ( return user && isMember ? (
<Row <Row className={'mt-0 justify-end'}>
className={'-mt-2 justify-between sm:mt-0 sm:flex-col sm:justify-center'}
>
<CreateQuestionButton
user={user}
overrideText={'Add a new question'}
className={'hidden w-48 flex-shrink-0 sm:block'}
query={`?groupId=${group.id}`}
/>
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'block w-40 flex-shrink-0 sm:hidden'}
query={`?groupId=${group.id}`}
/>
<AddContractButton group={group} user={user} /> <AddContractButton group={group} user={user} />
</Row> </Row>
) : group.anyoneCanJoin ? ( ) : group.anyoneCanJoin ? (
@ -350,6 +329,11 @@ function GroupOverview(props: {
}) })
} }
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
return ( return (
<> <>
<Col className="gap-2 rounded-b bg-white p-2"> <Col className="gap-2 rounded-b bg-white p-2">
@ -394,21 +378,26 @@ function GroupOverview(props: {
</span> </span>
)} )}
</Row> </Row>
{anyoneCanJoin && user && ( {anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}> <Col className="my-4 px-2">
<span className={'text-gray-500'}>Share</span> <div className="text-lg">Invite</div>
<ShareIconButton <div className={'mb-2 text-gray-500'}>
group={group} Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
username={user.username} sign up!
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'} </div>
>
<span className={'mx-2'}> <CopyLinkButton
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! url={shareUrl}
</span> tracking="copy group share link"
</ShareIconButton> buttonClassName="btn-md rounded-l-none"
</Row> toastClassName={'-left-28 mt-1'}
/>
</Col>
)} )}
<Col className={'mt-2'}> <Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} /> <GroupMemberSearch members={members} group={group} />
</Col> </Col>
</Col> </Col>
@ -509,14 +498,14 @@ function GroupLeaderboards(props: {
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0} scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings" title="🏅 Top bettors"
header="Profit" header="Profit"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0} scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings" title="🏅 Top creators"
header="Market volume" header="Market volume"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />
@ -556,7 +545,7 @@ function GroupLeaderboards(props: {
} }
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
async function addContractToCurrentGroup(contract: Contract) { async function addContractToCurrentGroup(contract: Contract) {
@ -566,16 +555,39 @@ function AddContractButton(props: { group: Group; user: User }) {
return ( return (
<> <>
<div className={'flex justify-center'}>
<button
className={clsx('btn btn-sm btn-outline')}
onClick={() => setOpen(true)}
>
<PlusSmIcon className="h-6 w-6" aria-hidden="true" /> question
</button>
</div>
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}> <Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
<Col <Col
className={ className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8' 'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white'
} }
> >
<div className={'text-lg text-indigo-700'}> <Col className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>
Add a question to your group Add a question to your group
</div> </div>
<div className={'overflow-y-scroll p-1'}>
<Col className="items-center">
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'w-48 flex-shrink-0 '}
query={`?groupId=${group.id}`}
/>
<div className={'mt-2 text-lg text-indigo-700'}>or</div>
</Col>
</Col>
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch <ContractSearch
hideOrderSelector={true} hideOrderSelector={true}
onContractClick={addContractToCurrentGroup} onContractClick={addContractToCurrentGroup}
@ -587,26 +599,6 @@ function AddContractButton(props: { group: Group; user: User }) {
</div> </div>
</Col> </Col>
</Modal> </Modal>
<div className={'flex justify-center'}>
<button
className={clsx(
createButtonStyle,
'hidden w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:block'
)}
onClick={() => setOpen(true)}
>
Add an old question
</button>
<button
className={clsx(
createButtonStyle,
'block w-40 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:hidden'
)}
onClick={() => setOpen(true)}
>
Old question
</button>
</div>
</> </>
) )
} }

View File

@ -185,7 +185,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
</Link> </Link>
<div> <div>
<Avatar <Avatar
className={'absolute top-2 right-2'} className={'absolute top-2 right-2 z-10'}
username={creator?.username} username={creator?.username}
avatarUrl={creator?.avatarUrl} avatarUrl={creator?.avatarUrl}
noLink={false} noLink={false}

View File

@ -1,30 +1,26 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Router, { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid' import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]' import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
const Home = () => { const Home = () => {
const user = useUser()
const [contract, setContract] = useContractPage() const [contract, setContract] = useContractPage()
const router = useRouter() const router = useRouter()
useTracking('view home') useTracking('view home')
if (user === null) {
Router.replace('/')
return <></>
}
return ( return (
<> <>
<Page suspend={!!contract}> <Page suspend={!!contract}>
@ -32,7 +28,7 @@ const Home = () => {
<ContractSearch <ContractSearch
querySortOptions={{ querySortOptions={{
shouldLoadFromStorage: true, shouldLoadFromStorage: true,
defaultSort: getSavedSort() ?? 'most-popular', defaultSort: getSavedSort() ?? DEFAULT_SORT,
}} }}
onContractClick={(c) => { onContractClick={(c) => {
// Show contract without navigating to contract page. // Show contract without navigating to contract page.

View File

@ -1,14 +1,14 @@
import React from 'react' import React, { useEffect } from 'react'
import Router from 'next/router' import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel' import { LandingPagePanel } from 'web/components/landing-page-panel'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from 'web/components/nav/manifold-logo' import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth'
export async function getStaticProps() { export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
// These hardcoded markets will be shown in the frontpage for signed-out users: // These hardcoded markets will be shown in the frontpage for signed-out users:
const hotContracts = await getContractsBySlugs([ const hotContracts = await getContractsBySlugs([
'will-max-go-to-prom-with-a-girl', 'will-max-go-to-prom-with-a-girl',
@ -22,22 +22,21 @@ export async function getStaticProps() {
'will-congress-hold-any-hearings-abo-e21f987033b3', 'will-congress-hold-any-hearings-abo-e21f987033b3',
'will-at-least-10-world-cities-have', 'will-at-least-10-world-cities-have',
]) ])
return { props: { hotContracts } }
})
return { export default function Home(props: { hotContracts: Contract[] }) {
props: { hotContracts },
revalidate: 60, // regenerate after a minute
}
}
const Home = (props: { hotContracts: Contract[] }) => {
const { hotContracts } = props const { hotContracts } = props
// for now this redirect in the component is how we handle the case where they are
// on this page and they log in -- in the future we will make some cleaner way
const user = useUser() const user = useUser()
const router = useRouter()
if (user) { useEffect(() => {
Router.replace('/home') if (user != null) {
return <></> router.replace('/home')
} }
}, [router, user])
return ( return (
<Page> <Page>
@ -58,5 +57,3 @@ const Home = (props: { hotContracts: Contract[] }) => {
</Page> </Page>
) )
} }
export default Home

View File

@ -9,85 +9,88 @@ import {
User, User,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticProps() {
export async function getStaticPropz() { const props = await fetchProps()
return queryLeaderboardUsers('allTime')
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators, topFollowed] = await Promise.all([
getTopTraders(period).catch(() => {}),
getTopCreators(period).catch(() => {}),
getTopFollowed().catch(() => {}),
])
return { return {
props: { props,
topTraders,
topCreators,
topFollowed,
},
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
} }
} }
export default function Leaderboards(props: { const fetchProps = async () => {
const [allTime, monthly, weekly, daily] = await Promise.all([
queryLeaderboardUsers('allTime'),
queryLeaderboardUsers('monthly'),
queryLeaderboardUsers('weekly'),
queryLeaderboardUsers('daily'),
])
const topFollowed = await getTopFollowed()
return {
allTime,
monthly,
weekly,
daily,
topFollowed,
}
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators] = await Promise.all([
getTopTraders(period),
getTopCreators(period),
])
return {
topTraders,
topCreators,
}
}
type leaderboard = {
topTraders: User[] topTraders: User[]
topCreators: User[] topCreators: User[]
}
export default function Leaderboards(_props: {
allTime: leaderboard
monthly: leaderboard
weekly: leaderboard
daily: leaderboard
topFollowed: User[] topFollowed: User[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { const [props, setProps] = useState<Parameters<typeof Leaderboards>[0]>(_props)
topTraders: [],
topCreators: [],
topFollowed: [],
}
const { topFollowed } = props
const [topTradersState, setTopTraders] = useState(props.topTraders)
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
const [isLoading, setLoading] = useState(false)
const [period, setPeriod] = useState<Period>('allTime')
useEffect(() => { useEffect(() => {
setLoading(true) fetchProps().then((props) => setProps(props))
queryLeaderboardUsers(period).then((res) => { }, [])
setTopTraders(res.props.topTraders as User[])
setTopCreators(res.props.topCreators as User[]) const { topFollowed } = props
setLoading(false)
})
}, [period])
const LeaderboardWithPeriod = (period: Period) => { const LeaderboardWithPeriod = (period: Period) => {
const { topTraders, topCreators } = props[period]
return ( return (
<> <>
<Col className="mx-4 items-center gap-10 lg:flex-row"> <Col className="mx-4 items-center gap-10 lg:flex-row">
{!isLoading ? (
<>
{period === 'allTime' ||
period == 'weekly' ||
period === 'daily' ? ( //TODO: show other periods once they're available
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top bettors"
users={topTradersState} users={topTraders}
columns={[ columns={[
{ {
header: 'Total profit', header: 'Total profit',
renderCell: (user) => renderCell: (user) => formatMoney(user.profitCached[period]),
formatMoney(user.profitCached[period]),
}, },
]} ]}
/> />
) : (
<></>
)}
<Leaderboard <Leaderboard
title="🏅 Top creators" title="🏅 Top creators"
users={topCreatorsState} users={topCreators}
columns={[ columns={[
{ {
header: 'Total bet', header: 'Total bet',
@ -96,10 +99,6 @@ export default function Leaderboards(props: {
}, },
]} ]}
/> />
</>
) : (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</Col> </Col>
{period === 'allTime' ? ( {period === 'allTime' ? (
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row"> <Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
@ -127,20 +126,17 @@ export default function Leaderboards(props: {
<Title text={'Leaderboards'} className={'hidden md:block'} /> <Title text={'Leaderboards'} className={'hidden md:block'} />
<Tabs <Tabs
currentPageForAnalytics={'leaderboards'} currentPageForAnalytics={'leaderboards'}
defaultIndex={0} defaultIndex={1}
onClick={(title, index) => {
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
setPeriod(period as Period)
}}
tabs={[ tabs={[
{ {
title: 'All Time', title: 'All Time',
content: LeaderboardWithPeriod('allTime'), content: LeaderboardWithPeriod('allTime'),
}, },
{ // TODO: Enable this near the end of July!
title: 'Monthly', // {
content: LeaderboardWithPeriod('monthly'), // title: 'Monthly',
}, // content: LeaderboardWithPeriod('monthly'),
// },
{ {
title: 'Weekly', title: 'Weekly',
content: LeaderboardWithPeriod('weekly'), content: LeaderboardWithPeriod('weekly'),

View File

@ -18,11 +18,14 @@ import { Avatar } from 'web/components/avatar'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat' import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
export const getServerSideProps = redirectIfLoggedOut('/')
export function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {
return `${location.protocol}//${location.host}/link/${slug}` return `${location.protocol}//${location.host}/link/${slug}`
} }

View File

@ -1,6 +1,6 @@
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Notification, notification_source_types } from 'common/notification' import { Notification, notification_source_types } from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -12,12 +12,10 @@ import { UserLink } from 'web/components/user-page'
import { import {
MANIFOLD_AVATAR_URL, MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME, MANIFOLD_USERNAME,
notification_subscribe_types,
PrivateUser, PrivateUser,
User,
} from 'common/user' } from 'common/user'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { getUser } from 'web/lib/firebase/users'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx' import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
@ -32,8 +30,7 @@ import {
NotificationGroup, NotificationGroup,
usePreferredGroupedNotifications, usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications' } from 'web/hooks/use-notifications'
import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import { TrendingUpIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
@ -42,15 +39,40 @@ import Custom404 from 'web/pages/404'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import Router from 'next/router' import { safeLocalStorage } from 'web/lib/util/local'
import {
getServerAuthenticatedUid,
redirectIfLoggedOut,
} from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
export const NOTIFICATIONS_PER_PAGE = 30 export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers' const MULTIPLE_USERS_KEY = 'multipleUsers'
const HIGHLIGHT_CLASS = 'bg-indigo-50' const HIGHLIGHT_CLASS = 'bg-indigo-50'
export default function Notifications() { export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => {
const user = useUser() const uid = await getServerAuthenticatedUid(ctx)
if (!uid) {
return { props: { user: null } }
}
const user = await getUser(uid)
return { props: { user } }
})
export default function Notifications(props: { user: User }) {
const { user } = props
const privateUser = usePrivateUser(user?.id) const privateUser = usePrivateUser(user?.id)
const local = safeLocalStorage()
let localNotifications = [] as Notification[]
const localSavedNotificationGroups = local?.getItem('notification-groups')
let localNotificationGroups = [] as NotificationGroup[]
if (localSavedNotificationGroups) {
localNotificationGroups = JSON.parse(localSavedNotificationGroups)
localNotifications = localNotificationGroups
.map((g) => g.notifications)
.flat()
}
if (!user) return <Custom404 /> if (!user) return <Custom404 />
return ( return (
@ -67,9 +89,16 @@ export default function Notifications() {
{ {
title: 'Notifications', title: 'Notifications',
content: privateUser ? ( content: privateUser ? (
<NotificationsList privateUser={privateUser} /> <NotificationsList
privateUser={privateUser}
cachedNotifications={localNotifications}
/>
) : ( ) : (
<LoadingIndicator /> <div className={'min-h-[100vh]'}>
<RenderNotificationGroups
notificationGroups={localNotificationGroups}
/>
</div>
), ),
}, },
{ {
@ -88,39 +117,13 @@ export default function Notifications() {
) )
} }
function NotificationsList(props: { privateUser: PrivateUser }) { function RenderNotificationGroups(props: {
const { privateUser } = props notificationGroups: NotificationGroup[]
const [page, setPage] = useState(0) }) {
const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) const { notificationGroups } = props
const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] =
useState<NotificationGroup[] | undefined>(undefined)
useEffect(() => {
if (!allGroupedNotifications) return
const start = page * NOTIFICATIONS_PER_PAGE
const end = start + NOTIFICATIONS_PER_PAGE
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
const remainingNotification = allGroupedNotifications.slice(end)
for (const notification of remainingNotification) {
if (notification.isSeen) break
else setNotificationsAsSeen(notification.notifications)
}
setPaginatedGroupedNotifications(maxNotificationsToShow)
}, [allGroupedNotifications, page])
if (!paginatedGroupedNotifications || !allGroupedNotifications)
return <LoadingIndicator />
return ( return (
<div className={'min-h-[100vh]'}> <>
{paginatedGroupedNotifications.length === 0 && ( {notificationGroups.map((notification) =>
<div className={'mt-2'}>
You don't have any notifications. Try changing your settings to see
more.
</div>
)}
{paginatedGroupedNotifications.map((notification) =>
notification.type === 'income' ? ( notification.type === 'income' ? (
<IncomeNotificationGroupItem <IncomeNotificationGroupItem
notificationGroup={notification} notificationGroup={notification}
@ -138,6 +141,52 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
/> />
) )
)} )}
</>
)
}
function NotificationsList(props: {
privateUser: PrivateUser
cachedNotifications: Notification[]
}) {
const { privateUser, cachedNotifications } = props
const [page, setPage] = useState(0)
const allGroupedNotifications = usePreferredGroupedNotifications(
privateUser,
cachedNotifications
)
const paginatedGroupedNotifications = useMemo(() => {
if (!allGroupedNotifications) return
const start = page * NOTIFICATIONS_PER_PAGE
const end = start + NOTIFICATIONS_PER_PAGE
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
const remainingNotification = allGroupedNotifications.slice(end)
for (const notification of remainingNotification) {
if (notification.isSeen) break
else setNotificationsAsSeen(notification.notifications)
}
const local = safeLocalStorage()
local?.setItem(
'notification-groups',
JSON.stringify(allGroupedNotifications)
)
return maxNotificationsToShow
}, [allGroupedNotifications, page])
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
return (
<div className={'min-h-[100vh]'}>
{paginatedGroupedNotifications.length === 0 && (
<div className={'mt-2'}>
You don't have any notifications. Try changing your settings to see
more.
</div>
)}
<RenderNotificationGroups
notificationGroups={paginatedGroupedNotifications}
/>
{paginatedGroupedNotifications.length > 0 && {paginatedGroupedNotifications.length > 0 &&
allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
<Pagination <Pagination
@ -146,6 +195,8 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
totalItems={allGroupedNotifications.length} totalItems={allGroupedNotifications.length}
setPage={setPage} setPage={setPage}
scrollToTop scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/> />
)} )}
</div> </div>
@ -382,7 +433,11 @@ function IncomeNotificationItem(props: {
highlighted && HIGHLIGHT_CLASS highlighted && HIGHLIGHT_CLASS
)} )}
> >
<a href={getSourceUrl(notification)}> <div className={'relative'}>
<SiteLink
href={getSourceUrl(notification) ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/>
<Row className={'items-center text-gray-500 sm:justify-start'}> <Row className={'items-center text-gray-500 sm:justify-start'}>
<div className={'line-clamp-2 flex max-w-xl shrink '}> <div className={'line-clamp-2 flex max-w-xl shrink '}>
<div className={'inline'}> <div className={'inline'}>
@ -408,7 +463,7 @@ function IncomeNotificationItem(props: {
</div> </div>
</Row> </Row>
<div className={'mt-4 border-b border-gray-300'} /> <div className={'mt-4 border-b border-gray-300'} />
</a> </div>
</div> </div>
) )
} }
@ -597,11 +652,11 @@ function NotificationItem(props: {
highlighted && HIGHLIGHT_CLASS highlighted && HIGHLIGHT_CLASS
)} )}
> >
<div <div className={'relative cursor-pointer'}>
className={'cursor-pointer'} <SiteLink
onClick={(event) => { href={getSourceUrl(notification) ?? ''}
event.stopPropagation() className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
Router.push(getSourceUrl(notification) ?? '') onClick={() =>
track('Notification Clicked', { track('Notification Clicked', {
type: 'notification item', type: 'notification item',
sourceType, sourceType,
@ -613,8 +668,8 @@ function NotificationItem(props: {
sourceUserUsername, sourceUserUsername,
sourceText, sourceText,
}) })
}} }
> />
<Row className={'items-center text-gray-500 sm:justify-start'}> <Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar <Avatar
avatarUrl={ avatarUrl={
@ -623,7 +678,7 @@ function NotificationItem(props: {
: sourceUserAvatarUrl : sourceUserAvatarUrl
} }
size={'sm'} size={'sm'}
className={'mr-2'} className={'z-10 mr-2'}
username={ username={
questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername
} }
@ -639,7 +694,7 @@ function NotificationItem(props: {
<UserLink <UserLink
name={sourceUserName || ''} name={sourceUserName || ''}
username={sourceUserUsername || ''} username={sourceUserUsername || ''}
className={'mr-1 flex-shrink-0'} className={'relative mr-1 flex-shrink-0'}
justFirstName={true} justFirstName={true}
/> />
)} )}
@ -706,15 +761,17 @@ function QuestionOrGroupLink(props: {
</span> </span>
) )
return ( return (
<a <SiteLink
className={ className={'relative ml-1 font-bold'}
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2 '
}
href={ href={
sourceContractCreatorUsername sourceContractCreatorUsername
? `/${sourceContractCreatorUsername}/${sourceContractSlug}` ? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
: (sourceType === 'group' || sourceType === 'tip') && sourceSlug : // User's added to group or received a tip there
(sourceType === 'group' || sourceType === 'tip') && sourceSlug
? `${groupPath(sourceSlug)}` ? `${groupPath(sourceSlug)}`
: // User referral via group
sourceSlug?.includes('/group/')
? `${sourceSlug}`
: '' : ''
} }
onClick={() => onClick={() =>
@ -730,7 +787,7 @@ function QuestionOrGroupLink(props: {
} }
> >
{sourceContractTitle || sourceTitle} {sourceContractTitle || sourceTitle}
</a> </SiteLink>
) )
} }
@ -745,12 +802,16 @@ function getSourceUrl(notification: Notification) {
} = notification } = notification
if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'follow') return `/${sourceUserUsername}`
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
// User referral via contract:
if ( if (
sourceContractCreatorUsername && sourceContractCreatorUsername &&
sourceContractSlug && sourceContractSlug &&
sourceType === 'user' sourceType === 'user'
) )
return `/${sourceContractCreatorUsername}/${sourceContractSlug}` return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
// User referral:
if (sourceType === 'user' && !sourceContractSlug)
return `/${sourceUserUsername}`
if (sourceType === 'tip' && sourceContractSlug) if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
@ -915,203 +976,3 @@ function getReasonForShowingNotification(
} }
return reasonText return reasonText
} }
// TODO: where should we put referral bonus notifications?
function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline' import { RefreshIcon } from '@heroicons/react/outline'
import Router from 'next/router'
import { AddFundsButton } from 'web/components/add-funds-button' import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -18,6 +17,9 @@ import { updateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page' import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
function EditUserField(props: { function EditUserField(props: {
user: User user: User
@ -134,8 +136,7 @@ export default function ProfilePage() {
}) })
} }
if (user === null) { if (user == null) {
Router.replace('/')
return <></> return <></>
} }

View File

@ -1,17 +1,10 @@
import Router from 'next/router' import Router from 'next/router'
import { useEffect } from 'react' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useUser } from 'web/hooks/use-user' export const getServerSideProps = redirectIfLoggedOut('/')
// Deprecated: redirects to /portfolio. // Deprecated: redirects to /portfolio.
// Eventually, this will be removed. // Eventually, this will be removed.
export default function TradesPage() { export default function TradesPage() {
const user = useUser() Router.replace('/portfolio')
useEffect(() => {
if (user === null) Router.replace('/')
else Router.replace('/portfolio')
})
return <></>
} }

View File

@ -1,4 +1,5 @@
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin')
module.exports = { module.exports = {
content: [ content: [
@ -32,6 +33,22 @@ module.exports = {
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'), require('@tailwindcss/line-clamp'),
require('daisyui'), require('daisyui'),
plugin(function ({ addUtilities }) {
addUtilities({
'.scrollbar-hide': {
/* IE and Edge */
'-ms-overflow-style': 'none',
/* Firefox */
'scrollbar-width': 'none',
/* Safari and Chrome */
'&::-webkit-scrollbar': {
display: 'none',
},
},
})
}),
], ],
daisyui: { daisyui: {
themes: [ themes: [