Merge branch 'main' into range-order
This commit is contained in:
commit
a945b2310c
|
@ -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],
|
||||||
|
}))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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} {
|
||||||
|
|
|
@ -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}`
|
||||||
|
|
29
functions/src/email-templates/500-mana.html
Normal file
29
functions/src/email-templates/500-mana.html
Normal file
File diff suppressed because one or more lines are too long
|
@ -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) {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
54
functions/src/score-contracts.ts
Normal file
54
functions/src/score-contracts.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
210
web/components/NotificationSettings.tsx
Normal file
210
web/components/NotificationSettings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Content content={desc} />
|
|
||||||
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<TagsList tags={categories} noLabel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
{isCreator && (
|
|
||||||
<EditContract
|
|
||||||
// Note: Because descriptionTimestamp is called once, later edits use
|
|
||||||
// a stale timestamp. Ideally this is a function that gets called when
|
|
||||||
// isEditing is set to true.
|
|
||||||
text={descriptionTimestamp()}
|
|
||||||
onSave={saveDescription}
|
|
||||||
buttonText="Add to description"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{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: {
|
|
||||||
text: string
|
|
||||||
onSave: (newText: string) => void
|
|
||||||
buttonText: string
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState(props.text)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const onSave = (newText: string) => {
|
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
setText(props.text) // Reset to original text
|
setIsSubmitting(false)
|
||||||
props.onSave(newText)
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" onClick={() => setEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Content content={contract.description} />
|
||||||
|
<Spacer h={2} />
|
||||||
|
<Row className="items-center gap-2">
|
||||||
|
{isAdmin && 'Admin: '}
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditQuestion(props: {
|
||||||
|
contract: Contract
|
||||||
|
editing: boolean
|
||||||
|
setEditing: (editing: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { contract, editing, setEditing } = props
|
||||||
|
const [text, setText] = useState(contract.question)
|
||||||
|
|
||||||
|
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)
|
||||||
|
await updateContract(contract.id, {
|
||||||
|
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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} />
|
||||||
)
|
)
|
||||||
|
|
|
@ -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." />
|
||||||
|
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
||||||
|
|
|
@ -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
16
web/hooks/use-ping.ts
Normal 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])
|
||||||
|
}
|
|
@ -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
54
web/lib/firebase/auth.ts
Normal 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()
|
|
@ -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)
|
||||||
|
|
93
web/lib/firebase/server-auth.ts
Normal file
93
web/lib/firebase/server-auth.ts
Normal 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: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
33
web/lib/util/cookie.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 <></>
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
Loading…
Reference in New Issue
Block a user