Merge branch 'main' into autosave
This commit is contained in:
commit
5efdd157d9
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
name: Merge main into main2 on every commit
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
merge-branch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: Merge main -> main2
|
||||||
|
uses: devmasx/merge-branch@master
|
||||||
|
with:
|
||||||
|
type: now
|
||||||
|
target_branch: main2
|
||||||
|
github_token: ${{ github.token }}
|
123
common/badge.ts
Normal file
123
common/badge.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import { User } from './user'
|
||||||
|
|
||||||
|
export type Badge = {
|
||||||
|
type: BadgeTypes
|
||||||
|
createdTime: number
|
||||||
|
data: { [key: string]: any }
|
||||||
|
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
|
||||||
|
|
||||||
|
export type ProvenCorrectBadgeData = {
|
||||||
|
type: 'PROVEN_CORRECT'
|
||||||
|
data: {
|
||||||
|
contractSlug: string
|
||||||
|
contractCreatorUsername: string
|
||||||
|
contractTitle: string
|
||||||
|
commentId: string
|
||||||
|
betAmount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarketCreatorBadgeData = {
|
||||||
|
type: 'MARKET_CREATOR'
|
||||||
|
data: {
|
||||||
|
totalContractsCreated: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreakerBadgeData = {
|
||||||
|
type: 'STREAKER'
|
||||||
|
data: {
|
||||||
|
totalBettingStreak: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
|
||||||
|
export type StreakerBadge = Badge & StreakerBadgeData
|
||||||
|
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
|
||||||
|
|
||||||
|
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
|
||||||
|
export const provenCorrectRarityThresholds = [1, 1000, 10000]
|
||||||
|
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
|
||||||
|
const { betAmount } = badge.data
|
||||||
|
const thresholdArray = provenCorrectRarityThresholds
|
||||||
|
let i = thresholdArray.length - 1
|
||||||
|
while (i >= 0) {
|
||||||
|
if (betAmount >= thresholdArray[i]) {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streakerBadgeRarityThresholds = [1, 50, 250]
|
||||||
|
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
|
||||||
|
const { totalBettingStreak } = badge.data
|
||||||
|
const thresholdArray = streakerBadgeRarityThresholds
|
||||||
|
let i = thresholdArray.length - 1
|
||||||
|
while (i >= 0) {
|
||||||
|
if (totalBettingStreak == thresholdArray[i]) {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
|
||||||
|
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
|
||||||
|
const { totalContractsCreated } = badge.data
|
||||||
|
const thresholdArray = marketCreatorBadgeRarityThresholds
|
||||||
|
let i = thresholdArray.length - 1
|
||||||
|
while (i >= 0) {
|
||||||
|
if (totalContractsCreated == thresholdArray[i]) {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export type rarities = 'bronze' | 'silver' | 'gold'
|
||||||
|
|
||||||
|
const rarityRanks: { [key: number]: rarities } = {
|
||||||
|
1: 'bronze',
|
||||||
|
2: 'silver',
|
||||||
|
3: 'gold',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateBadgeRarity = (badge: Badge) => {
|
||||||
|
switch (badge.type) {
|
||||||
|
case 'PROVEN_CORRECT':
|
||||||
|
return rarityRanks[
|
||||||
|
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
|
||||||
|
]
|
||||||
|
case 'MARKET_CREATOR':
|
||||||
|
return rarityRanks[
|
||||||
|
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
|
||||||
|
]
|
||||||
|
case 'STREAKER':
|
||||||
|
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
|
||||||
|
default:
|
||||||
|
return rarityRanks[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBadgesByRarity = (user: User | null | undefined) => {
|
||||||
|
const rarities: { [key in rarities]: number } = {
|
||||||
|
bronze: 0,
|
||||||
|
silver: 0,
|
||||||
|
gold: 0,
|
||||||
|
}
|
||||||
|
if (!user) return rarities
|
||||||
|
Object.values(user.achievements).map((value) => {
|
||||||
|
value.badges.map((badge) => {
|
||||||
|
rarities[calculateBadgeRarity(badge)] =
|
||||||
|
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return rarities
|
||||||
|
}
|
|
@ -1,11 +1,17 @@
|
||||||
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
|
import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
|
||||||
import { calculatePayout } from './calculate'
|
import { calculatePayout, getContractBetMetrics } from './calculate'
|
||||||
import { Bet, LimitBet } from './bet'
|
import { Bet, LimitBet } from './bet'
|
||||||
import { Contract, CPMMContract, DPMContract } from './contract'
|
import {
|
||||||
|
Contract,
|
||||||
|
CPMMBinaryContract,
|
||||||
|
CPMMContract,
|
||||||
|
DPMContract,
|
||||||
|
} from './contract'
|
||||||
import { PortfolioMetrics, User } from './user'
|
import { PortfolioMetrics, User } from './user'
|
||||||
import { DAY_MS } from './util/time'
|
import { DAY_MS } from './util/time'
|
||||||
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
|
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
|
||||||
import { getCpmmProbability } from './calculate-cpmm'
|
import { getCpmmProbability } from './calculate-cpmm'
|
||||||
|
import { removeUndefinedProps } from './util/object'
|
||||||
|
|
||||||
const computeInvestmentValue = (
|
const computeInvestmentValue = (
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
|
@ -35,8 +41,7 @@ export const computeInvestmentValueCustomProb = (
|
||||||
|
|
||||||
const betP = outcome === 'YES' ? p : 1 - p
|
const betP = outcome === 'YES' ? p : 1 - p
|
||||||
|
|
||||||
const payout = betP * shares
|
const value = betP * shares
|
||||||
const value = payout - (bet.loanAmount ?? 0)
|
|
||||||
if (isNaN(value)) return 0
|
if (isNaN(value)) return 0
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
@ -97,7 +102,11 @@ export const computeBinaryCpmmElasticity = (
|
||||||
)
|
)
|
||||||
const resultNo = getCpmmProbability(poolN, pN)
|
const resultNo = getCpmmProbability(poolN, pN)
|
||||||
|
|
||||||
return resultYes - resultNo
|
// handle AMM overflow
|
||||||
|
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
|
||||||
|
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
|
||||||
|
|
||||||
|
return safeYes - safeNo
|
||||||
}
|
}
|
||||||
|
|
||||||
export const computeDpmElasticity = (
|
export const computeDpmElasticity = (
|
||||||
|
@ -190,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateProfitForPeriod = (
|
const calculateProfitForPeriod = (
|
||||||
startTime: number,
|
startingPortfolio: PortfolioMetrics | undefined,
|
||||||
descendingPortfolio: PortfolioMetrics[],
|
|
||||||
currentProfit: number
|
currentProfit: number
|
||||||
) => {
|
) => {
|
||||||
const startingPortfolio = descendingPortfolio.find(
|
|
||||||
(p) => p.timestamp < startTime
|
|
||||||
)
|
|
||||||
|
|
||||||
if (startingPortfolio === undefined) {
|
if (startingPortfolio === undefined) {
|
||||||
return currentProfit
|
return currentProfit
|
||||||
}
|
}
|
||||||
|
@ -212,33 +216,88 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculateNewProfit = (
|
export const calculateNewProfit = (
|
||||||
portfolioHistory: PortfolioMetrics[],
|
portfolioHistory: Record<
|
||||||
|
'current' | 'day' | 'week' | 'month',
|
||||||
|
PortfolioMetrics | undefined
|
||||||
|
>,
|
||||||
newPortfolio: PortfolioMetrics
|
newPortfolio: PortfolioMetrics
|
||||||
) => {
|
) => {
|
||||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||||
const descendingPortfolio = sortBy(
|
|
||||||
portfolioHistory,
|
|
||||||
(p) => p.timestamp
|
|
||||||
).reverse()
|
|
||||||
|
|
||||||
const newProfit = {
|
const newProfit = {
|
||||||
daily: calculateProfitForPeriod(
|
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
|
||||||
Date.now() - 1 * DAY_MS,
|
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
|
||||||
descendingPortfolio,
|
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
weekly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 7 * DAY_MS,
|
|
||||||
descendingPortfolio,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
monthly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 30 * DAY_MS,
|
|
||||||
descendingPortfolio,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
allTime: allTimeProfit,
|
allTime: allTimeProfit,
|
||||||
}
|
}
|
||||||
|
|
||||||
return newProfit
|
return newProfit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const calculateMetricsByContract = (
|
||||||
|
bets: Bet[],
|
||||||
|
contractsById: Dictionary<Contract>
|
||||||
|
) => {
|
||||||
|
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||||
|
const unresolvedContracts = Object.keys(betsByContract)
|
||||||
|
.map((cid) => contractsById[cid])
|
||||||
|
.filter((c) => c && !c.isResolved)
|
||||||
|
|
||||||
|
return unresolvedContracts.map((c) => {
|
||||||
|
const bets = betsByContract[c.id] ?? []
|
||||||
|
const current = getContractBetMetrics(c, bets)
|
||||||
|
|
||||||
|
let periodMetrics
|
||||||
|
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
|
||||||
|
const periods = ['day', 'week', 'month'] as const
|
||||||
|
periodMetrics = Object.fromEntries(
|
||||||
|
periods.map((period) => [
|
||||||
|
period,
|
||||||
|
calculatePeriodProfit(c, bets, period),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeUndefinedProps({
|
||||||
|
contractId: c.id,
|
||||||
|
...current,
|
||||||
|
from: periodMetrics,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculatePeriodProfit = (
|
||||||
|
contract: CPMMBinaryContract,
|
||||||
|
bets: Bet[],
|
||||||
|
period: 'day' | 'week' | 'month'
|
||||||
|
) => {
|
||||||
|
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
|
||||||
|
const fromTime = Date.now() - days * DAY_MS
|
||||||
|
const previousBets = bets.filter((b) => b.createdTime < fromTime)
|
||||||
|
|
||||||
|
const prevProb = contract.prob - contract.probChanges[period]
|
||||||
|
const prob = contract.resolutionProbability
|
||||||
|
? contract.resolutionProbability
|
||||||
|
: contract.prob
|
||||||
|
|
||||||
|
const previousBetsValue = computeInvestmentValueCustomProb(
|
||||||
|
previousBets,
|
||||||
|
contract,
|
||||||
|
prevProb
|
||||||
|
)
|
||||||
|
const currentBetsValue = computeInvestmentValueCustomProb(
|
||||||
|
previousBets,
|
||||||
|
contract,
|
||||||
|
prob
|
||||||
|
)
|
||||||
|
const profit = currentBetsValue - previousBetsValue
|
||||||
|
const profitPercent =
|
||||||
|
previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue)
|
||||||
|
|
||||||
|
return {
|
||||||
|
profit,
|
||||||
|
profitPercent,
|
||||||
|
prevValue: previousBetsValue,
|
||||||
|
value: currentBetsValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -215,7 +215,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const profit = payout + saleValue + redeemed - totalInvested
|
const profit = payout + saleValue + redeemed - totalInvested
|
||||||
const profitPercent = (profit / totalInvested) * 100
|
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
|
||||||
|
|
||||||
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
||||||
const hasShares = Object.values(totalShares).some(
|
const hasShares = Object.values(totalShares).some(
|
||||||
|
|
|
@ -63,6 +63,7 @@ export function getNewContract(
|
||||||
tags: [],
|
tags: [],
|
||||||
lowercaseTags: [],
|
lowercaseTags: [],
|
||||||
visibility,
|
visibility,
|
||||||
|
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
closeTime,
|
closeTime,
|
||||||
|
|
|
@ -4,7 +4,7 @@ export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
reasonText?: string
|
reasonText?: string
|
||||||
reason?: notification_reason_types
|
reason?: notification_reason_types | notification_preference
|
||||||
createdTime: number
|
createdTime: number
|
||||||
viewTime?: number
|
viewTime?: number
|
||||||
isSeen: boolean
|
isSeen: boolean
|
||||||
|
@ -46,6 +46,7 @@ export type notification_source_types =
|
||||||
| 'loan'
|
| 'loan'
|
||||||
| 'like'
|
| 'like'
|
||||||
| 'tip_and_like'
|
| 'tip_and_like'
|
||||||
|
| 'badge'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
simple: `Only on markets you're invested in`,
|
simple: `Only on markets you're invested in`,
|
||||||
detailed: `Answers on markets that you're watching and that you're invested in`,
|
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||||
},
|
},
|
||||||
|
badges_awarded: {
|
||||||
|
simple: 'New badges awarded',
|
||||||
|
detailed: 'New badges you have earned',
|
||||||
|
},
|
||||||
opt_out_all: {
|
opt_out_all: {
|
||||||
simple: 'Opt out of all notifications (excludes when your markets close)',
|
simple: 'Opt out of all notifications (excludes when your markets close)',
|
||||||
detailed:
|
detailed:
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { groupBy, sumBy, mapValues } from 'lodash'
|
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getContractBetMetrics } from './calculate'
|
import { getContractBetMetrics, resolvedPayout } from './calculate'
|
||||||
import { Contract } from './contract'
|
import { Contract } from './contract'
|
||||||
|
import { ContractComment } from './comment'
|
||||||
|
|
||||||
export function scoreCreators(contracts: Contract[]) {
|
export function scoreCreators(contracts: Contract[]) {
|
||||||
const creatorScore = mapValues(
|
const creatorScore = mapValues(
|
||||||
|
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||||
const betsByUser = groupBy(bets, bet => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
return mapValues(
|
||||||
|
betsByUser,
|
||||||
|
(bets) => getContractBetMetrics(contract, bets).profit
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addUserScores(
|
export function addUserScores(
|
||||||
|
@ -43,3 +47,47 @@ export function addUserScores(
|
||||||
dest[userId] += score
|
dest[userId] += score
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scoreCommentorsAndBettors(
|
||||||
|
contract: Contract,
|
||||||
|
bets: Bet[],
|
||||||
|
comments: ContractComment[]
|
||||||
|
) {
|
||||||
|
const commentsById = keyBy(comments, 'id')
|
||||||
|
const betsById = keyBy(bets, 'id')
|
||||||
|
|
||||||
|
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||||
|
// Otherwise, we record the profit at resolution time
|
||||||
|
const profitById: Record<string, number> = {}
|
||||||
|
for (const bet of bets) {
|
||||||
|
if (bet.sale) {
|
||||||
|
const originalBet = betsById[bet.sale.betId]
|
||||||
|
const profit = bet.sale.amount - originalBet.amount
|
||||||
|
profitById[bet.id] = profit
|
||||||
|
profitById[originalBet.id] = profit
|
||||||
|
} else {
|
||||||
|
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now find the betId with the highest profit
|
||||||
|
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||||
|
const topBettor = betsById[topBetId]?.userName
|
||||||
|
|
||||||
|
// And also the commentId of the comment with the highest profit
|
||||||
|
const topCommentId = sortBy(
|
||||||
|
comments,
|
||||||
|
(c) => c.betId && -profitById[c.betId]
|
||||||
|
)[0]?.id
|
||||||
|
const topCommentBetId = commentsById[topCommentId]?.betId
|
||||||
|
|
||||||
|
return {
|
||||||
|
topCommentId,
|
||||||
|
topBetId,
|
||||||
|
topBettor,
|
||||||
|
profitById,
|
||||||
|
commentsById,
|
||||||
|
betsById,
|
||||||
|
topCommentBetId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ export type notification_preferences = {
|
||||||
profit_loss_updates: notification_destination_types[]
|
profit_loss_updates: notification_destination_types[]
|
||||||
onboarding_flow: notification_destination_types[]
|
onboarding_flow: notification_destination_types[]
|
||||||
thank_you_for_purchases: notification_destination_types[]
|
thank_you_for_purchases: notification_destination_types[]
|
||||||
|
badges_awarded: notification_destination_types[]
|
||||||
opt_out_all: notification_destination_types[]
|
opt_out_all: notification_destination_types[]
|
||||||
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
||||||
}
|
}
|
||||||
|
@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
|
||||||
onboarding_flow: constructPref(false, false),
|
onboarding_flow: constructPref(false, false),
|
||||||
|
|
||||||
opt_out_all: [],
|
opt_out_all: [],
|
||||||
|
badges_awarded: constructPref(true, false),
|
||||||
}
|
}
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
@ -178,6 +179,8 @@ export const getNotificationDestinationsForUser = (
|
||||||
reason: notification_reason_types | notification_preference
|
reason: notification_reason_types | notification_preference
|
||||||
) => {
|
) => {
|
||||||
const notificationSettings = privateUser.notificationPreferences
|
const notificationSettings = privateUser.notificationPreferences
|
||||||
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
|
try {
|
||||||
let destinations
|
let destinations
|
||||||
let subscriptionType: notification_preference | undefined
|
let subscriptionType: notification_preference | undefined
|
||||||
if (Object.keys(notificationSettings).includes(reason)) {
|
if (Object.keys(notificationSettings).includes(reason)) {
|
||||||
|
@ -198,11 +201,22 @@ export const getNotificationDestinationsForUser = (
|
||||||
const optedOutOfBrowser =
|
const optedOutOfBrowser =
|
||||||
optOutOfAllSettings.includes('browser') &&
|
optOutOfAllSettings.includes('browser') &&
|
||||||
subscriptionType !== 'your_contract_closed'
|
subscriptionType !== 'your_contract_closed'
|
||||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
|
||||||
return {
|
return {
|
||||||
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||||
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fail safely
|
||||||
|
console.log(
|
||||||
|
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
sendToEmail: false,
|
||||||
|
sendToBrowser: false,
|
||||||
|
unsubscribeUrl: '',
|
||||||
|
urlToManageThisNotification: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { notification_preferences } from './user-notification-preferences'
|
import { notification_preferences } from './user-notification-preferences'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from './envs/constants'
|
||||||
|
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -11,7 +12,6 @@ export type User = {
|
||||||
|
|
||||||
// For their user page
|
// For their user page
|
||||||
bio?: string
|
bio?: string
|
||||||
bannerUrl?: string
|
|
||||||
website?: string
|
website?: string
|
||||||
twitterHandle?: string
|
twitterHandle?: string
|
||||||
discordHandle?: string
|
discordHandle?: string
|
||||||
|
@ -51,6 +51,18 @@ export type User = {
|
||||||
hasSeenContractFollowModal?: boolean
|
hasSeenContractFollowModal?: boolean
|
||||||
freeMarketsCreated?: number
|
freeMarketsCreated?: number
|
||||||
isBannedFromPosting?: boolean
|
isBannedFromPosting?: boolean
|
||||||
|
|
||||||
|
achievements: {
|
||||||
|
provenCorrect?: {
|
||||||
|
badges: ProvenCorrectBadge[]
|
||||||
|
}
|
||||||
|
marketCreator?: {
|
||||||
|
badges: MarketCreatorBadge[]
|
||||||
|
}
|
||||||
|
streaker?: {
|
||||||
|
badges: StreakerBadge[]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
|
@ -81,7 +93,8 @@ export type PortfolioMetrics = {
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
|
||||||
|
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
||||||
// TODO: remove. Hardcoding the strings would be better.
|
// TODO: remove. Hardcoding the strings would be better.
|
||||||
|
|
|
@ -52,7 +52,7 @@ export function parseMentions(data: JSONContent): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||||
export const exhibitExts = [
|
const stringParseExts = [
|
||||||
Blockquote,
|
Blockquote,
|
||||||
Bold,
|
Bold,
|
||||||
BulletList,
|
BulletList,
|
||||||
|
@ -72,7 +72,8 @@ export const exhibitExts = [
|
||||||
|
|
||||||
Image,
|
Image,
|
||||||
Link,
|
Link,
|
||||||
Mention,
|
Mention, // user @mention
|
||||||
|
Mention.extend({ name: 'contract-mention' }), // market %mention
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
TiptapSpoiler,
|
TiptapSpoiler,
|
||||||
|
@ -96,7 +97,7 @@ export function richTextToString(text?: JSONContent) {
|
||||||
current.type = 'text'
|
current.type = 'text'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return generateText(newText, exhibitExts)
|
return generateText(newText, stringParseExts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
|
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
|
||||||
|
|
|
@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
||||||
|
|
||||||
## Awarded bounties
|
## Awarded bounties
|
||||||
|
|
||||||
|
💥 *Awarded on 2022-10-07*
|
||||||
|
|
||||||
|
**[Pepe](https://manifold.markets/Pepe): M$10,000**
|
||||||
|
**[Jack](https://manifold.markets/jack): M$2,000**
|
||||||
|
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
|
||||||
|
**[Yev](https://manifold.markets/Yev): M$2,000**
|
||||||
|
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
|
||||||
|
|
||||||
|
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
|
||||||
|
|
||||||
|
**[Matt](https://manifold.markets/MattP): M$5,000**
|
||||||
|
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
|
||||||
|
**[Yev](https://manifold.markets/Yev): M$5,000**
|
||||||
|
|
||||||
|
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
|
||||||
|
|
||||||
🎈 *Awarded on 2022-06-14*
|
🎈 *Awarded on 2022-06-14*
|
||||||
|
|
||||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||||
|
|
|
@ -27,7 +27,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||||
// User referral rules
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
|
3
functions/.env.dev
Normal file
3
functions/.env.dev
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=DEV
|
|
@ -5,7 +5,7 @@
|
||||||
"firestore": "dev-mantic-markets.appspot.com"
|
"firestore": "dev-mantic-markets.appspot.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
|
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
|
||||||
"compile": "tsc -b",
|
"compile": "tsc -b",
|
||||||
"watch": "tsc -w",
|
"watch": "tsc -w",
|
||||||
"shell": "yarn build && firebase functions:shell",
|
"shell": "yarn build && firebase functions:shell",
|
||||||
|
|
|
@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||||
},
|
},
|
||||||
} as EndpointDefinition
|
} as EndpointDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const newEndpointNoAuth = (
|
||||||
|
endpointOpts: EndpointOptions,
|
||||||
|
fn: (req: Request) => Promise<Output>
|
||||||
|
) => {
|
||||||
|
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
|
||||||
|
return {
|
||||||
|
opts,
|
||||||
|
handler: async (req: Request, res: Response) => {
|
||||||
|
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
|
||||||
|
try {
|
||||||
|
if (opts.method !== req.method) {
|
||||||
|
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
|
||||||
|
}
|
||||||
|
res.status(200).json(await fn(req))
|
||||||
|
} catch (e) {
|
||||||
|
writeResponseError(e, res)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as EndpointDefinition
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,12 @@ import {
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import {
|
||||||
|
MANIFOLD_AVATAR_URL,
|
||||||
|
MANIFOLD_USER_NAME,
|
||||||
|
MANIFOLD_USER_USERNAME,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getValues } from './utils'
|
import { getPrivateUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
|
@ -30,6 +35,7 @@ import {
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||||
import { ContractFollow } from '../../common/follow'
|
import { ContractFollow } from '../../common/follow'
|
||||||
|
import { Badge } from 'common/badge'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type recipients_to_reason_texts = {
|
type recipients_to_reason_texts = {
|
||||||
|
@ -1087,6 +1093,43 @@ export const createBountyNotification = async (
|
||||||
sourceTitle: contract.question,
|
sourceTitle: contract.question,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
// maybe TODO: send email notification to comment creator
|
|
||||||
|
export const createBadgeAwardedNotification = async (
|
||||||
|
user: User,
|
||||||
|
badge: Badge
|
||||||
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(user.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'badges_awarded'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${user.id}/notifications`)
|
||||||
|
.doc()
|
||||||
|
const notification: Notification = {
|
||||||
|
id: notificationRef.id,
|
||||||
|
userId: user.id,
|
||||||
|
reason: 'badges_awarded',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: badge.type,
|
||||||
|
sourceType: 'badge',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: MANIFOLD_USER_NAME,
|
||||||
|
sourceUserUsername: MANIFOLD_USER_USERNAME,
|
||||||
|
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||||
|
sourceText: `You earned a new ${badge.name} badge!`,
|
||||||
|
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
|
||||||
|
sourceTitle: badge.name,
|
||||||
|
data: {
|
||||||
|
badge,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO send email notification
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
shouldShowWelcome: true,
|
shouldShowWelcome: true,
|
||||||
fractionResolvedCorrectly: 1,
|
fractionResolvedCorrectly: 1,
|
||||||
|
achievements: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('users').doc(auth.uid).create(user)
|
await firestore.collection('users').doc(auth.uid).create(user)
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { contractUrl, getUser } from './utils'
|
import { contractUrl, getUser, log } from './utils'
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
import { notification_reason_types } from '../../common/notification'
|
import { notification_reason_types } from '../../common/notification'
|
||||||
import { Dictionary } from 'lodash'
|
import { Dictionary } from 'lodash'
|
||||||
|
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'onboarding_flow'
|
'onboarding_flow'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
sendTime: string
|
sendTime: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'onboarding_flow'
|
'onboarding_flow'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
|
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
|
||||||
'email'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'thank_you_for_purchases'
|
'thank_you_for_purchases'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!sendToEmail) return
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Thanks for your Manifold purchase',
|
'Thanks for your Manifold purchase',
|
||||||
|
@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async (
|
||||||
contractsToSend: Contract[],
|
contractsToSend: Contract[],
|
||||||
deliveryTime?: string
|
deliveryTime?: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.trending_markets.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'trending_markets'
|
'trending_markets'
|
||||||
)
|
)
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
||||||
investments: PerContractInvestmentsData[],
|
investments: PerContractInvestmentsData[],
|
||||||
overallPerformance: OverallPerformanceData
|
overallPerformance: OverallPerformanceData
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (!privateUser || !privateUser.email) return
|
||||||
!privateUser ||
|
|
||||||
!privateUser.email ||
|
|
||||||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||||
privateUser,
|
privateUser,
|
||||||
'profit_loss_updates'
|
'profit_loss_updates'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!sendToEmail) return
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const templateData: Record<string, string> = {
|
const templateData: Record<string, string> = {
|
||||||
|
@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
||||||
: 'portfolio-update',
|
: 'portfolio-update',
|
||||||
templateData
|
templateData
|
||||||
)
|
)
|
||||||
|
log('Sent portfolio update email to', privateUser.email)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ export * from './on-create-user'
|
||||||
export * from './on-create-bet'
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment-on-contract'
|
export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './update-metrics'
|
export { scheduleUpdateMetrics } from './update-metrics'
|
||||||
export * from './update-stats'
|
export * from './update-stats'
|
||||||
export * from './update-loans'
|
export * from './update-loans'
|
||||||
export * from './backup-db'
|
export * from './backup-db'
|
||||||
|
@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user'
|
||||||
import { acceptchallenge } from './accept-challenge'
|
import { acceptchallenge } from './accept-challenge'
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
import { updatemetrics } from './update-metrics'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
const createPostFunction = toCloudFunction(createpost)
|
const createPostFunction = toCloudFunction(createpost)
|
||||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||||
|
const updateMetricsFunction = toCloudFunction(updatemetrics)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -133,4 +135,5 @@ export {
|
||||||
saveTwitchCredentials as savetwitchcredentials,
|
saveTwitchCredentials as savetwitchcredentials,
|
||||||
addCommentBounty as addcommentbounty,
|
addCommentBounty as addcommentbounty,
|
||||||
awardCommentBounty as awardcommentbounty,
|
awardCommentBounty as awardcommentbounty,
|
||||||
|
updateMetricsFunction as updatemetrics,
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
revalidateStaticProps,
|
revalidateStaticProps,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import {
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
createBetFillNotification,
|
createBetFillNotification,
|
||||||
createBettingStreakBonusNotification,
|
createBettingStreakBonusNotification,
|
||||||
createUniqueBettorBonusNotification,
|
createUniqueBettorBonusNotification,
|
||||||
|
@ -33,6 +34,10 @@ import { APIError } from '../../common/api'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||||
|
import {
|
||||||
|
StreakerBadge,
|
||||||
|
streakerBadgeRarityThresholds,
|
||||||
|
} from '../../common/badge'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||||
|
@ -143,7 +148,7 @@ const updateBettingStreak = async (
|
||||||
log('message:', result.message)
|
log('message:', result.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.txn)
|
if (result.txn) {
|
||||||
await createBettingStreakBonusNotification(
|
await createBettingStreakBonusNotification(
|
||||||
user,
|
user,
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
|
@ -153,6 +158,8 @@ const updateBettingStreak = async (
|
||||||
newBettingStreak,
|
newBettingStreak,
|
||||||
eventId
|
eventId
|
||||||
)
|
)
|
||||||
|
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
@ -296,3 +303,39 @@ const notifyFills = async (
|
||||||
const currentDateBettingStreakResetTime = () => {
|
const currentDateBettingStreakResetTime = () => {
|
||||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBettingStreakBadgeAward(
|
||||||
|
user: User,
|
||||||
|
newBettingStreak: number
|
||||||
|
) {
|
||||||
|
const alreadyHasBadgeForFirstStreak =
|
||||||
|
user.achievements?.streaker?.badges.some(
|
||||||
|
(badge) => badge.data.totalBettingStreak === 1
|
||||||
|
)
|
||||||
|
// TODO: check if already awarded 50th streak as well
|
||||||
|
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
|
||||||
|
|
||||||
|
if (newBettingStreak in streakerBadgeRarityThresholds) {
|
||||||
|
const badge = {
|
||||||
|
type: 'STREAKER',
|
||||||
|
name: 'Streaker',
|
||||||
|
data: {
|
||||||
|
totalBettingStreak: newBettingStreak,
|
||||||
|
},
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as StreakerBadge
|
||||||
|
// update user
|
||||||
|
await firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.update({
|
||||||
|
achievements: {
|
||||||
|
...user.achievements,
|
||||||
|
streaker: {
|
||||||
|
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createBadgeAwardedNotification(user, badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser, getValues } from './utils'
|
||||||
import { createNewContractNotification } from './create-notification'
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
|
createNewContractNotification,
|
||||||
|
} from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
import { User } from '../../common/user'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import {
|
||||||
|
MarketCreatorBadge,
|
||||||
|
marketCreatorBadgeRarityThresholds,
|
||||||
|
} from '../../common/badge'
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -28,4 +37,43 @@ export const onCreateContract = functions
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
mentioned
|
mentioned
|
||||||
)
|
)
|
||||||
|
await handleMarketCreatorBadgeAward(contractCreator)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function handleMarketCreatorBadgeAward(contractCreator: User) {
|
||||||
|
// get all contracts by user and calculate size of array
|
||||||
|
const contracts = await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.where('creatorId', '==', contractCreator.id)
|
||||||
|
.where('resolution', '!=', 'CANCEL')
|
||||||
|
)
|
||||||
|
if (contracts.length in marketCreatorBadgeRarityThresholds) {
|
||||||
|
const badge = {
|
||||||
|
type: 'MARKET_CREATOR',
|
||||||
|
name: 'Market Creator',
|
||||||
|
data: {
|
||||||
|
totalContractsCreated: contracts.length,
|
||||||
|
},
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as MarketCreatorBadge
|
||||||
|
// update user
|
||||||
|
await firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(contractCreator.id)
|
||||||
|
.update({
|
||||||
|
achievements: {
|
||||||
|
...contractCreator.achievements,
|
||||||
|
marketCreator: {
|
||||||
|
badges: [
|
||||||
|
...(contractCreator.achievements?.marketCreator?.badges ?? []),
|
||||||
|
badge,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createBadgeAwardedNotification(contractCreator, badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import { getUser, getValues } from './utils'
|
||||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
import {
|
||||||
|
createBadgeAwardedNotification,
|
||||||
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
|
} from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { GroupContractDoc } from '../../common/group'
|
import { Bet } from '../../common/bet'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { ContractComment } from '../../common/comment'
|
||||||
|
import { scoreCommentorsAndBettors } from '../../common/scoring'
|
||||||
|
import {
|
||||||
|
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
|
||||||
|
ProvenCorrectBadge,
|
||||||
|
} from '../../common/badge'
|
||||||
|
import { GroupContractDoc } from '../../common/group'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
.document('contracts/{contractId}')
|
.document('contracts/{contractId}')
|
||||||
|
@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore
|
||||||
|
|
||||||
if (!previousContract.isResolved && contract.isResolved) {
|
if (!previousContract.isResolved && contract.isResolved) {
|
||||||
// No need to notify users of resolution, that's handled in resolve-market
|
// No need to notify users of resolution, that's handled in resolve-market
|
||||||
return
|
return await handleResolvedContract(contract)
|
||||||
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
||||||
await handleContractGroupUpdated(previousContract, contract)
|
await handleContractGroupUpdated(previousContract, contract)
|
||||||
} else if (
|
} else if (
|
||||||
|
@ -26,6 +36,63 @@ export const onUpdateContract = functions.firestore
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleResolvedContract(contract: Contract) {
|
||||||
|
if (
|
||||||
|
(contract.uniqueBettorCount ?? 0) <
|
||||||
|
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
// get all bets on this contract
|
||||||
|
const bets = await getValues<Bet>(
|
||||||
|
firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// get comments on this contract
|
||||||
|
const comments = await getValues<ContractComment>(
|
||||||
|
firestore.collection(`contracts/${contract.id}/comments`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
|
||||||
|
scoreCommentorsAndBettors(contract, bets, comments)
|
||||||
|
if (topCommentBetId && profitById[topCommentBetId] > 0) {
|
||||||
|
// award proven correct badge to user
|
||||||
|
const comment = commentsById[topCommentId]
|
||||||
|
const bet = betsById[topCommentBetId]
|
||||||
|
|
||||||
|
const user = await getUser(comment.userId)
|
||||||
|
if (!user) return
|
||||||
|
const newProvenCorrectBadge = {
|
||||||
|
createdTime: Date.now(),
|
||||||
|
type: 'PROVEN_CORRECT',
|
||||||
|
name: 'Proven Correct',
|
||||||
|
data: {
|
||||||
|
contractSlug: contract.slug,
|
||||||
|
contractCreatorUsername: contract.creatorUsername,
|
||||||
|
commentId: comment.id,
|
||||||
|
betAmount: bet.amount,
|
||||||
|
contractTitle: contract.question,
|
||||||
|
},
|
||||||
|
} as ProvenCorrectBadge
|
||||||
|
// update user
|
||||||
|
await firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.update({
|
||||||
|
achievements: {
|
||||||
|
...user.achievements,
|
||||||
|
provenCorrect: {
|
||||||
|
badges: [
|
||||||
|
...(user.achievements?.provenCorrect?.badges ?? []),
|
||||||
|
newProvenCorrectBadge,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpdatedCloseTime(
|
async function handleUpdatedCloseTime(
|
||||||
previousContract: Contract,
|
previousContract: Contract,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
|
|
|
@ -11,15 +11,18 @@ async function main() {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
privateUsers.map((privateUser) => {
|
privateUsers.map((privateUser) => {
|
||||||
if (!privateUser.id) return Promise.resolve()
|
if (!privateUser.id) return Promise.resolve()
|
||||||
|
if (privateUser.notificationPreferences.badges_awarded === undefined) {
|
||||||
return firestore
|
return firestore
|
||||||
.collection('private-users')
|
.collection('private-users')
|
||||||
.doc(privateUser.id)
|
.doc(privateUser.id)
|
||||||
.update({
|
.update({
|
||||||
notificationPreferences: {
|
notificationPreferences: {
|
||||||
...privateUser.notificationPreferences,
|
...privateUser.notificationPreferences,
|
||||||
opt_out_all: [],
|
badges_awarded: ['browser'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
102
functions/src/scripts/backfill-badges.ts
Normal file
102
functions/src/scripts/backfill-badges.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { getAllUsers, getValues } from '../utils'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import {
|
||||||
|
MarketCreatorBadge,
|
||||||
|
marketCreatorBadgeRarityThresholds,
|
||||||
|
StreakerBadge,
|
||||||
|
streakerBadgeRarityThresholds,
|
||||||
|
} from 'common/badge'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const users = await getAllUsers()
|
||||||
|
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
|
||||||
|
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
|
||||||
|
await Promise.all(
|
||||||
|
users.map(async (user) => {
|
||||||
|
if (!user.id) return
|
||||||
|
// Only backfill users without achievements
|
||||||
|
if (user.achievements === undefined) {
|
||||||
|
await firestore.collection('users').doc(user.id).update({
|
||||||
|
achievements: {},
|
||||||
|
})
|
||||||
|
user.achievements = {}
|
||||||
|
user.achievements = await awardMarketCreatorBadges(user)
|
||||||
|
user.achievements = await awardBettingStreakBadges(user)
|
||||||
|
console.log('Added achievements to user', user.id)
|
||||||
|
// going to ignore backfilling the proven correct badges for now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
||||||
|
|
||||||
|
async function awardMarketCreatorBadges(user: User) {
|
||||||
|
// Award market maker badges
|
||||||
|
const contracts = await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.where('creatorId', '==', user.id)
|
||||||
|
.where('resolution', '!=', 'CANCEL')
|
||||||
|
)
|
||||||
|
|
||||||
|
const achievements = {
|
||||||
|
...user.achievements,
|
||||||
|
marketCreator: {
|
||||||
|
badges: [...(user.achievements.marketCreator?.badges ?? [])],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for (const threshold of marketCreatorBadgeRarityThresholds) {
|
||||||
|
if (contracts.length >= threshold) {
|
||||||
|
const badge = {
|
||||||
|
type: 'MARKET_CREATOR',
|
||||||
|
name: 'Market Creator',
|
||||||
|
data: {
|
||||||
|
totalContractsCreated: threshold,
|
||||||
|
},
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as MarketCreatorBadge
|
||||||
|
achievements.marketCreator.badges.push(badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update user
|
||||||
|
await firestore.collection('users').doc(user.id).update({
|
||||||
|
achievements,
|
||||||
|
})
|
||||||
|
return achievements
|
||||||
|
}
|
||||||
|
|
||||||
|
async function awardBettingStreakBadges(user: User) {
|
||||||
|
const streak = user.currentBettingStreak ?? 0
|
||||||
|
const achievements = {
|
||||||
|
...user.achievements,
|
||||||
|
streaker: {
|
||||||
|
badges: [...(user.achievements?.streaker?.badges ?? [])],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for (const threshold of streakerBadgeRarityThresholds) {
|
||||||
|
if (streak >= threshold) {
|
||||||
|
const badge = {
|
||||||
|
type: 'STREAKER',
|
||||||
|
name: 'Streaker',
|
||||||
|
data: {
|
||||||
|
totalBettingStreak: threshold,
|
||||||
|
},
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as StreakerBadge
|
||||||
|
achievements.streaker.badges.push(badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update user
|
||||||
|
await firestore.collection('users').doc(user.id).update({
|
||||||
|
achievements,
|
||||||
|
})
|
||||||
|
return achievements
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
import { groupBy, keyBy, sortBy } from 'lodash'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract, CPMM } from '../../common/contract'
|
import { Contract, CPMM } from '../../common/contract'
|
||||||
|
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
@ -14,19 +15,44 @@ import {
|
||||||
calculateNewPortfolioMetrics,
|
calculateNewPortfolioMetrics,
|
||||||
calculateNewProfit,
|
calculateNewProfit,
|
||||||
calculateProbChanges,
|
calculateProbChanges,
|
||||||
|
calculateMetricsByContract,
|
||||||
computeElasticity,
|
computeElasticity,
|
||||||
computeVolume,
|
computeVolume,
|
||||||
} from '../../common/calculate-metrics'
|
} from '../../common/calculate-metrics'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { batchedWaitAll } from '../../common/util/promise'
|
import { batchedWaitAll } from '../../common/util/promise'
|
||||||
|
import { newEndpointNoAuth } from './api'
|
||||||
|
import { getFunctionUrl } from '../../common/api'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
export const scheduleUpdateMetrics = functions.pubsub
|
||||||
|
.schedule('every 15 minutes')
|
||||||
|
.onRun(async () => {
|
||||||
|
const url = getFunctionUrl('updatemetrics')
|
||||||
|
console.log('Scheduling update metrics', url)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
export const updateMetrics = functions
|
const json = await response.json()
|
||||||
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
|
||||||
.pubsub.schedule('every 15 minutes')
|
if (response.ok) console.log(json)
|
||||||
.onRun(updateMetricsCore)
|
else console.error(json)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updatemetrics = newEndpointNoAuth(
|
||||||
|
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
|
||||||
|
async (_req) => {
|
||||||
|
await updateMetricsCore()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export async function updateMetricsCore() {
|
export async function updateMetricsCore() {
|
||||||
console.log('Loading users')
|
console.log('Loading users')
|
||||||
|
@ -36,11 +62,7 @@ export async function updateMetricsCore() {
|
||||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||||
|
|
||||||
console.log('Loading portfolio history')
|
console.log('Loading portfolio history')
|
||||||
const allPortfolioHistories = await getValues<PortfolioMetrics>(
|
const userPortfolioHistory = await loadPortfolioHistory(users)
|
||||||
firestore
|
|
||||||
.collectionGroup('portfolioHistory')
|
|
||||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Loading groups')
|
console.log('Loading groups')
|
||||||
const groups = await getValues<Group>(firestore.collection('groups'))
|
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||||
|
@ -117,11 +139,10 @@ export async function updateMetricsCore() {
|
||||||
)
|
)
|
||||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
|
||||||
|
|
||||||
const userMetrics = users.map((user) => {
|
const userMetrics = users.map((user) => {
|
||||||
const currentBets = betsByUser[user.id] ?? []
|
const currentBets = betsByUser[user.id] ?? []
|
||||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
const portfolioHistory = userPortfolioHistory[user.id] ?? []
|
||||||
const userContracts = contractsByUser[user.id] ?? []
|
const userContracts = contractsByUser[user.id] ?? []
|
||||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||||
const newPortfolio = calculateNewPortfolioMetrics(
|
const newPortfolio = calculateNewPortfolioMetrics(
|
||||||
|
@ -129,14 +150,20 @@ export async function updateMetricsCore() {
|
||||||
contractsById,
|
contractsById,
|
||||||
currentBets
|
currentBets
|
||||||
)
|
)
|
||||||
const lastPortfolio = last(portfolioHistory)
|
const currPortfolio = portfolioHistory.current
|
||||||
const didPortfolioChange =
|
const didPortfolioChange =
|
||||||
lastPortfolio === undefined ||
|
currPortfolio === undefined ||
|
||||||
lastPortfolio.balance !== newPortfolio.balance ||
|
currPortfolio.balance !== newPortfolio.balance ||
|
||||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
currPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||||
|
|
||||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||||
|
|
||||||
|
const metricsByContract = calculateMetricsByContract(
|
||||||
|
currentBets,
|
||||||
|
contractsById
|
||||||
|
)
|
||||||
|
|
||||||
const contractRatios = userContracts
|
const contractRatios = userContracts
|
||||||
.map((contract) => {
|
.map((contract) => {
|
||||||
if (
|
if (
|
||||||
|
@ -146,7 +173,7 @@ export async function updateMetricsCore() {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const contractRatio =
|
const contractRatio =
|
||||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
|
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
|
||||||
|
|
||||||
return contractRatio
|
return contractRatio
|
||||||
})
|
})
|
||||||
|
@ -167,6 +194,7 @@ export async function updateMetricsCore() {
|
||||||
newProfit,
|
newProfit,
|
||||||
didPortfolioChange,
|
didPortfolioChange,
|
||||||
newFractionResolvedCorrectly,
|
newFractionResolvedCorrectly,
|
||||||
|
metricsByContract,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -182,17 +210,9 @@ export async function updateMetricsCore() {
|
||||||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||||
|
|
||||||
const userUpdates = userMetrics.map(
|
const userUpdates = userMetrics.map(
|
||||||
({
|
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
|
||||||
user,
|
|
||||||
newCreatorVolume,
|
|
||||||
newPortfolio,
|
|
||||||
newProfit,
|
|
||||||
didPortfolioChange,
|
|
||||||
newFractionResolvedCorrectly,
|
|
||||||
}) => {
|
|
||||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||||
return {
|
return {
|
||||||
fieldUpdates: {
|
|
||||||
doc: firestore.collection('users').doc(user.id),
|
doc: firestore.collection('users').doc(user.id),
|
||||||
fields: {
|
fields: {
|
||||||
creatorVolumeCached: newCreatorVolume,
|
creatorVolumeCached: newCreatorVolume,
|
||||||
|
@ -200,45 +220,51 @@ export async function updateMetricsCore() {
|
||||||
nextLoanCached,
|
nextLoanCached,
|
||||||
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await writeAsync(firestore, userUpdates)
|
||||||
|
|
||||||
subcollectionUpdates: {
|
const portfolioHistoryUpdates = filterDefined(
|
||||||
|
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
|
||||||
|
return didPortfolioChange
|
||||||
|
? {
|
||||||
doc: firestore
|
doc: firestore
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(user.id)
|
.doc(user.id)
|
||||||
.collection('portfolioHistory')
|
.collection('portfolioHistory')
|
||||||
.doc(),
|
.doc(),
|
||||||
fields: didPortfolioChange ? newPortfolio : {},
|
fields: newPortfolio,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
|
||||||
|
|
||||||
|
const contractMetricsUpdates = userMetrics.flatMap(
|
||||||
|
({ user, metricsByContract }) => {
|
||||||
|
const collection = firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.collection('contract-metrics')
|
||||||
|
return metricsByContract.map((metrics) => ({
|
||||||
|
doc: collection.doc(metrics.contractId),
|
||||||
|
fields: metrics,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await writeAsync(
|
|
||||||
firestore,
|
await writeAsync(firestore, contractMetricsUpdates, 'set')
|
||||||
userUpdates.map((u) => u.fieldUpdates)
|
|
||||||
)
|
|
||||||
await writeAsync(
|
|
||||||
firestore,
|
|
||||||
userUpdates
|
|
||||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
|
||||||
.map((u) => u.subcollectionUpdates),
|
|
||||||
'set'
|
|
||||||
)
|
|
||||||
log(`Updated metrics for ${users.length} users.`)
|
log(`Updated metrics for ${users.length} users.`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupUpdates = groups.map((group, index) => {
|
const groupUpdates = groups.map((group, index) => {
|
||||||
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||||
const groupContracts = groupContractIds
|
const groupContracts = filterDefined(
|
||||||
.map((e) => contractsById[e.contractId])
|
groupContractIds.map((e) => contractsById[e.contractId])
|
||||||
.filter((e) => e !== undefined) as Contract[]
|
)
|
||||||
const bets = groupContracts.map((e) => {
|
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
|
||||||
if (e != null && e.id in betsByContract) {
|
|
||||||
return betsByContract[e.id] ?? []
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const creatorScores = scoreCreators(groupContracts)
|
const creatorScores = scoreCreators(groupContracts)
|
||||||
const traderScores = scoreTraders(groupContracts, bets)
|
const traderScores = scoreTraders(groupContracts, bets)
|
||||||
|
@ -272,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
|
||||||
type GroupContractDoc = { contractId: string; createdTime: number }
|
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
||||||
const BAD_RESOLUTION_THRESHOLD = 0.1
|
const BAD_RESOLUTION_THRESHOLD = 0.1
|
||||||
|
|
||||||
|
const loadPortfolioHistory = async (users: User[]) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const userPortfolioHistory = await batchedWaitAll(
|
||||||
|
users.map((user) => async () => {
|
||||||
|
const query = firestore
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.id)
|
||||||
|
.collection('portfolioHistory')
|
||||||
|
.orderBy('timestamp', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const portfolioMetrics = await Promise.all([
|
||||||
|
getValues<PortfolioMetrics>(query),
|
||||||
|
getValues<PortfolioMetrics>(
|
||||||
|
query.where('timestamp', '<', now - DAY_MS)
|
||||||
|
),
|
||||||
|
getValues<PortfolioMetrics>(
|
||||||
|
query.where('timestamp', '<', now - 7 * DAY_MS)
|
||||||
|
),
|
||||||
|
getValues<PortfolioMetrics>(
|
||||||
|
query.where('timestamp', '<', now - 30 * DAY_MS)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
const [current, day, week, month] = portfolioMetrics.map(
|
||||||
|
(p) => p[0] as PortfolioMetrics | undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
current,
|
||||||
|
day,
|
||||||
|
week,
|
||||||
|
month,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
100
|
||||||
|
)
|
||||||
|
|
||||||
|
return keyBy(userPortfolioHistory, (p) => p.userId)
|
||||||
|
}
|
||||||
|
|
|
@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
|
||||||
return users.docs.map((doc) => doc.data() as PrivateUser)
|
return users.docs.map((doc) => doc.data() as PrivateUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAllUsers = async () => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const users = await firestore.collection('users').get()
|
||||||
|
return users.docs.map((doc) => doc.data() as User)
|
||||||
|
}
|
||||||
|
|
||||||
export const getUserByUsername = async (username: string) => {
|
export const getUserByUsername = async (username: string) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const snap = await firestore
|
const snap = await firestore
|
||||||
|
|
|
@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
log('Found', contractsUsersBetOn.length, 'contracts')
|
|
||||||
let count = 0
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||||
const user = await getUser(privateUser.id)
|
const user = await getUser(privateUser.id)
|
||||||
// Don't send to a user unless they're over 5 days old
|
// Don't send to a user unless they're over 5 days old
|
||||||
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
|
if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
|
||||||
|
return await setEmailFlagAsSent(privateUser.id)
|
||||||
const userBets = usersBets[privateUser.id] as Bet[]
|
const userBets = usersBets[privateUser.id] as Bet[]
|
||||||
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||||
userBets.some((bet) => bet.contractId === contract.id)
|
userBets.some((bet) => bet.contractId === contract.id)
|
||||||
|
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
(differences) => Math.abs(differences.profit)
|
(differences) => Math.abs(differences.profit)
|
||||||
).reverse()
|
).reverse()
|
||||||
|
|
||||||
log(
|
|
||||||
'Found',
|
|
||||||
investmentValueDifferences.length,
|
|
||||||
'investment differences for user',
|
|
||||||
privateUser.id
|
|
||||||
)
|
|
||||||
|
|
||||||
const [winningInvestments, losingInvestments] = partition(
|
const [winningInvestments, losingInvestments] = partition(
|
||||||
investmentValueDifferences.filter(
|
investmentValueDifferences.filter(
|
||||||
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
||||||
|
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
usersToContractsCreated[privateUser.id].length === 0
|
usersToContractsCreated[privateUser.id].length === 0
|
||||||
) {
|
) {
|
||||||
log(
|
log(
|
||||||
'No bets in last week, no market movers, no markets created. Not sending an email.'
|
`No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
|
||||||
)
|
)
|
||||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
return await setEmailFlagAsSent(privateUser.id)
|
||||||
weeklyPortfolioUpdateEmailSent: true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
// Set the flag beforehand just to be safe
|
||||||
|
await setEmailFlagAsSent(privateUser.id)
|
||||||
await sendWeeklyPortfolioUpdateEmail(
|
await sendWeeklyPortfolioUpdateEmail(
|
||||||
user,
|
user,
|
||||||
privateUser,
|
privateUser,
|
||||||
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||||
performanceData
|
performanceData
|
||||||
)
|
)
|
||||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
|
||||||
weeklyPortfolioUpdateEmailSent: true,
|
|
||||||
})
|
|
||||||
log('Sent weekly portfolio update email to', privateUser.email)
|
|
||||||
count++
|
|
||||||
log('sent out emails to users:', count)
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setEmailFlagAsSent(privateUserId: string) {
|
||||||
|
await firestore.collection('private-users').doc(privateUserId).update({
|
||||||
|
weeklyPortfolioUpdateEmailSent: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type PerContractInvestmentsData = {
|
export type PerContractInvestmentsData = {
|
||||||
questionTitle: string
|
questionTitle: string
|
||||||
questionUrl: string
|
questionUrl: string
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { Col } from './layout/col'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { AddFundsModal } from './add-funds-modal'
|
import { AddFundsModal } from './add-funds-modal'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -44,9 +45,9 @@ export function AmountInput(props: {
|
||||||
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
|
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<Input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
|
'pl-9',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
'w-24 md:w-auto',
|
'w-24 md:w-auto',
|
||||||
inputClassName
|
inputClassName
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
|
||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||||
import { Linkify } from '../linkify'
|
import { Linkify } from '../linkify'
|
||||||
|
import { Input } from '../input'
|
||||||
|
|
||||||
export function AnswerItem(props: {
|
export function AnswerItem(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
|
@ -74,8 +75,8 @@ export function AnswerItem(props: {
|
||||||
<Row className="items-center justify-end gap-4 self-end sm:self-start">
|
<Row className="items-center justify-end gap-4 self-end sm:self-start">
|
||||||
{!wasResolvedTo &&
|
{!wasResolvedTo &&
|
||||||
(showChoice === 'checkbox' ? (
|
(showChoice === 'checkbox' ? (
|
||||||
<input
|
<Input
|
||||||
className="input input-bordered w-24 justify-self-end text-2xl"
|
className="w-24 justify-self-end !text-2xl"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={`${roundedProb}`}
|
placeholder={`${roundedProb}`}
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { findBestMatch } from 'string-similarity'
|
import { findBestMatch } from 'string-similarity'
|
||||||
|
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract } from 'common/contract'
|
||||||
|
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { lowerCase } from 'lodash'
|
import { lowerCase } from 'lodash'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
<Col className="gap-4 rounded">
|
<Col className="gap-4 rounded">
|
||||||
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
||||||
<div className="mb-1">Add your answer</div>
|
<div className="mb-1">Add your answer</div>
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => changeAnswer(e.target.value)}
|
onChange={(e) => changeAnswer(e.target.value)}
|
||||||
className="textarea textarea-bordered w-full resize-none"
|
className="w-full"
|
||||||
placeholder="Type your answer..."
|
placeholder="Type your answer..."
|
||||||
rows={1}
|
rows={1}
|
||||||
maxLength={MAX_ANSWER_LENGTH}
|
maxLength={MAX_ANSWER_LENGTH}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function MultipleChoiceAnswers(props: {
|
export function MultipleChoiceAnswers(props: {
|
||||||
answers: string[]
|
answers: string[]
|
||||||
|
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
|
||||||
{answers.map((answer, i) => (
|
{answers.map((answer, i) => (
|
||||||
<Row className="mb-2 items-center gap-2 align-middle">
|
<Row className="mb-2 items-center gap-2 align-middle">
|
||||||
{i + 1}.{' '}
|
{i + 1}.{' '}
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
value={answer}
|
value={answer}
|
||||||
onChange={(e) => setAnswer(i, e.target.value)}
|
onChange={(e) => setAnswer(i, e.target.value)}
|
||||||
className="textarea textarea-bordered ml-2 w-full resize-none"
|
className="ml-2 w-full"
|
||||||
placeholder="Type your answer..."
|
placeholder="Type your answer..."
|
||||||
rows={1}
|
rows={1}
|
||||||
maxLength={MAX_ANSWER_LENGTH}
|
maxLength={MAX_ANSWER_LENGTH}
|
||||||
|
|
62
web/components/badge-display.tsx
Normal file
62
web/components/badge-display.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getBadgesByRarity } from 'common/badge'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { BadgesModal } from 'web/components/profile/badges-modal'
|
||||||
|
import { ParsedUrlQuery } from 'querystring'
|
||||||
|
|
||||||
|
export const goldClassName = 'text-amber-400'
|
||||||
|
export const silverClassName = 'text-gray-500'
|
||||||
|
export const bronzeClassName = 'text-amber-900'
|
||||||
|
|
||||||
|
export function BadgeDisplay(props: {
|
||||||
|
user: User | undefined | null
|
||||||
|
query: ParsedUrlQuery
|
||||||
|
}) {
|
||||||
|
const { user, query } = props
|
||||||
|
const [showBadgesModal, setShowBadgesModal] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showBadgesModal = query['show'] == 'badges'
|
||||||
|
setShowBadgesModal(showBadgesModal)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
// get number of badges of each rarity type
|
||||||
|
const badgesByRarity = getBadgesByRarity(user)
|
||||||
|
const badgesByRarityItems = Object.entries(badgesByRarity).map(
|
||||||
|
([rarity, numBadges]) => {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
key={rarity}
|
||||||
|
className={clsx(
|
||||||
|
'items-center gap-2',
|
||||||
|
rarity === 'bronze'
|
||||||
|
? bronzeClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: goldClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx('-m-0.5 text-lg')}>•</span>
|
||||||
|
<span className="text-xs">{numBadges}</span>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={'cursor-pointer gap-2'}
|
||||||
|
onClick={() => setShowBadgesModal(true)}
|
||||||
|
>
|
||||||
|
{badgesByRarityItems}
|
||||||
|
{user && (
|
||||||
|
<BadgesModal
|
||||||
|
isOpen={showBadgesModal}
|
||||||
|
setOpen={setShowBadgesModal}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
|
@ -20,10 +20,10 @@ import { getProbability } from 'common/calculate'
|
||||||
import { createMarket } from 'web/lib/firebase/api'
|
import { createMarket } from 'web/lib/firebase/api'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { FIXED_ANTE } from 'common/economy'
|
import { FIXED_ANTE } from 'common/economy'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { CopyLinkButton } from '../copy-link-button'
|
import { CopyLinkButton } from '../copy-link-button'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
type challengeInfo = {
|
type challengeInfo = {
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -150,9 +150,9 @@ function CreateChallengeForm(props: {
|
||||||
{contract ? (
|
{contract ? (
|
||||||
<span className="underline">{contract.question}</span>
|
<span className="underline">{contract.question}</span>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder="e.g. Will a Democrat be the next president?"
|
placeholder="e.g. Will a Democrat be the next president?"
|
||||||
className="input input-bordered mt-1 w-full resize-none"
|
className="mt-1 w-full"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
maxLength={MAX_QUESTION_LENGTH}
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
value={challengeInfo.question}
|
value={challengeInfo.question}
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
import { Title } from './title'
|
import { Title } from './title'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export const SORTS = [
|
export const SORTS = [
|
||||||
{ label: 'Newest', value: 'newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
|
@ -438,13 +439,13 @@ function ContractSearchControls(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => updateQuery(e.target.value)}
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
onBlur={trackCallback('search', { query: query })}
|
onBlur={trackCallback('search', { query: query })}
|
||||||
placeholder={'Search'}
|
placeholder="Search"
|
||||||
className="input input-bordered w-full"
|
className="w-full"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{!isMobile && !query && (
|
{!isMobile && !query && (
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { CurrencyDollarIcon } from '@heroicons/react/outline'
|
import { CurrencyDollarIcon } from '@heroicons/react/outline'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { Tooltip } from 'web/components/tooltip'
|
|
||||||
import { formatMoney } from 'common/util/format'
|
|
||||||
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { Tooltip } from 'web/components/tooltip'
|
||||||
|
import { CommentBountyDialog } from './comment-bounty-dialog'
|
||||||
|
|
||||||
export function BountiedContractBadge() {
|
export function BountiedContractBadge() {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
|
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white">
|
||||||
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
|
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, showAmount } = props
|
const { contract, showAmount } = props
|
||||||
const { openCommentBounties } = contract
|
const { openCommentBounties } = contract
|
||||||
if (!openCommentBounties) return <div />
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!openCommentBounties && !showAmount) return <></>
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const bountiesClosed =
|
||||||
|
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
|
||||||
|
|
||||||
|
if (!openCommentBounties) {
|
||||||
|
if (bountiesClosed) return <></>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<>
|
||||||
text={CommentBountiesTooltipText(
|
{modal}
|
||||||
contract.creatorName,
|
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
|
||||||
openCommentBounties
|
</>
|
||||||
)}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
|
|
||||||
<CurrencyDollarIcon className={'h3 w-3'} />
|
|
||||||
{showAmount && formatMoney(openCommentBounties)} Bounty
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentBountiesTooltipText = (
|
const tooltip = `${contract.creatorName} may award ${formatMoney(
|
||||||
creator: string,
|
|
||||||
openCommentBounties: number
|
|
||||||
) =>
|
|
||||||
`${creator} may award ${formatMoney(
|
|
||||||
COMMENT_BOUNTY_AMOUNT
|
COMMENT_BOUNTY_AMOUNT
|
||||||
)} for good comments. ${formatMoney(
|
)} for good comments. ${formatMoney(
|
||||||
openCommentBounties
|
openCommentBounties
|
||||||
)} currently available.`
|
)} currently available.`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip text={tooltip} placement="bottom">
|
||||||
|
{modal}
|
||||||
|
<SmallBadge
|
||||||
|
text={`${formatMoney(openCommentBounties)} bounty`}
|
||||||
|
onClick={bountiesClosed ? undefined : () => setOpen(true)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallBadge(props: { text: string; onClick?: () => void }) {
|
||||||
|
const { text, onClick } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white',
|
||||||
|
!onClick && 'cursor-default'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CurrencyDollarIcon className={'h4 w-4'} />
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -8,9 +8,16 @@ import clsx from 'clsx'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
|
import { Title } from '../title'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Modal } from '../layout/modal'
|
||||||
|
|
||||||
export function AddCommentBountyPanel(props: { contract: Contract }) {
|
export function CommentBountyDialog(props: {
|
||||||
const { contract } = props
|
contract: Contract
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { contract, open, setOpen } = props
|
||||||
const { id: contractId, slug } = contract
|
const { id: contractId, slug } = contract
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -45,7 +52,10 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
<Col className="gap-4 rounded bg-white p-6">
|
||||||
|
<Title className="!mt-0 !mb-0" text="Comment bounty" />
|
||||||
|
|
||||||
<div className="mb-4 text-gray-500">
|
<div className="mb-4 text-gray-500">
|
||||||
Add a {formatMoney(amount)} bounty for good comments that the creator
|
Add a {formatMoney(amount)} bounty for good comments that the creator
|
||||||
can award.{' '}
|
can award.{' '}
|
||||||
|
@ -69,6 +79,7 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <div>Processing...</div>}
|
{isLoading && <div>Processing...</div>}
|
||||||
</>
|
</Col>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -1,20 +1,22 @@
|
||||||
import clsx from 'clsx'
|
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 { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
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 { Content } from '../editor'
|
import { Content } from '../editor'
|
||||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
import {
|
||||||
|
TextEditor,
|
||||||
|
editorExtensions,
|
||||||
|
useTextEditor,
|
||||||
|
} from 'web/components/editor'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Editor, Content as ContentType } from '@tiptap/react'
|
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||||
import { insertContent } from '../editor/utils'
|
import { insertContent } from '../editor/utils'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function ContractDescription(props: {
|
export function ContractDescription(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -120,7 +122,10 @@ function EditQuestion(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinContent(oldContent: ContentType, newContent: string) {
|
function joinContent(oldContent: ContentType, newContent: string) {
|
||||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
const editor = new Editor({
|
||||||
|
content: oldContent,
|
||||||
|
extensions: editorExtensions(),
|
||||||
|
})
|
||||||
editor.commands.focus('end')
|
editor.commands.focus('end')
|
||||||
insertContent(editor, newContent)
|
insertContent(editor, newContent)
|
||||||
return editor.getJSON()
|
return editor.getJSON()
|
||||||
|
@ -139,8 +144,8 @@ function EditQuestion(props: {
|
||||||
|
|
||||||
return editing ? (
|
return editing ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
|
className="mb-1 h-24 w-full"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value || '')}
|
onChange={(e) => setText(e.target.value || '')}
|
||||||
|
|
|
@ -8,7 +8,6 @@ import clsx from 'clsx'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||||
|
@ -20,7 +19,6 @@ import NewContractBadge from '../new-contract-badge'
|
||||||
import { MiniUserFollowButton } from '../follow-button'
|
import { MiniUserFollowButton } from '../follow-button'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
@ -40,6 +38,8 @@ import {
|
||||||
BountiedContractBadge,
|
BountiedContractBadge,
|
||||||
BountiedContractSmallBadge,
|
BountiedContractSmallBadge,
|
||||||
} from 'web/components/contract/bountied-contract-badge'
|
} from 'web/components/contract/bountied-contract-badge'
|
||||||
|
import { Input } from '../input'
|
||||||
|
import { editorExtensions } from '../editor'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -153,8 +153,8 @@ export function MarketSubheader(props: {
|
||||||
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
|
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
|
||||||
const { resolvedDate } = contractMetrics(contract)
|
const { resolvedDate } = contractMetrics(contract)
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const correctResolutionPercentage =
|
const creator = useUserById(creatorId)
|
||||||
useUserById(creatorId)?.fractionResolvedCorrectly
|
const correctResolutionPercentage = creator?.fractionResolvedCorrectly
|
||||||
const isCreator = user?.id === creatorId
|
const isCreator = user?.id === creatorId
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
return (
|
return (
|
||||||
|
@ -177,12 +177,14 @@ export function MarketSubheader(props: {
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
creatorName
|
creatorName
|
||||||
) : (
|
) : (
|
||||||
|
<Row className={'gap-2'}>
|
||||||
<UserLink
|
<UserLink
|
||||||
className="my-auto whitespace-nowrap"
|
className="my-auto whitespace-nowrap"
|
||||||
name={creatorName}
|
name={creatorName}
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
short={isMobile}
|
|
||||||
/>
|
/>
|
||||||
|
{/*<BadgeDisplay user={creator} />*/}
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
{correctResolutionPercentage != null &&
|
{correctResolutionPercentage != null &&
|
||||||
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
|
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
|
||||||
|
@ -418,7 +420,7 @@ function EditableCloseDate(props: {
|
||||||
const content = contract.description
|
const content = contract.description
|
||||||
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
||||||
|
|
||||||
const editor = new Editor({ content, extensions: exhibitExts })
|
const editor = new Editor({ content, extensions: editorExtensions() })
|
||||||
editor.commands.focus('end')
|
editor.commands.focus('end')
|
||||||
insertContent(
|
insertContent(
|
||||||
editor,
|
editor,
|
||||||
|
@ -445,17 +447,17 @@ function EditableCloseDate(props: {
|
||||||
<Col className="rounded bg-white px-8 pb-8">
|
<Col className="rounded bg-white px-8 pb-8">
|
||||||
<Subtitle text="Edit market close time" />
|
<Subtitle text="Edit market close time" />
|
||||||
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
|
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
|
||||||
<input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="input input-bordered w-full shrink-0 sm:w-fit"
|
className="w-full shrink-0 sm:w-fit"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseDate(e.target.value)}
|
onChange={(e) => setCloseDate(e.target.value)}
|
||||||
min={Date.now()}
|
min={Date.now()}
|
||||||
value={closeDate}
|
value={closeDate}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
className="input input-bordered w-full shrink-0 sm:w-max"
|
className="w-full shrink-0 sm:w-max"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||||
min="00:00"
|
min="00:00"
|
||||||
|
|
|
@ -2,15 +2,17 @@ import { Bet } from 'common/bet'
|
||||||
import { resolvedPayout } from 'common/calculate'
|
import { resolvedPayout } from 'common/calculate'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
|
||||||
import { memo } from 'react'
|
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||||
import { useComments } from 'web/hooks/use-comments'
|
|
||||||
import { FeedBet } from '../feed/feed-bets'
|
import { FeedBet } from '../feed/feed-bets'
|
||||||
import { FeedComment } from '../feed/feed-comments'
|
import { FeedComment } from '../feed/feed-comments'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Leaderboard } from '../leaderboard'
|
import { Leaderboard } from '../leaderboard'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { BETTORS } from 'common/user'
|
import { BETTORS } from 'common/user'
|
||||||
|
import { scoreCommentorsAndBettors } from 'common/scoring'
|
||||||
|
import { ContractComment } from 'common/comment'
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
||||||
) : null
|
) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractTopTrades(props: {
|
||||||
const { contract, bets } = props
|
contract: Contract
|
||||||
// todo: this stuff should be calced in DB at resolve time
|
bets: Bet[]
|
||||||
const comments = useComments(contract.id)
|
comments: ContractComment[]
|
||||||
const betsById = keyBy(bets, 'id')
|
}) {
|
||||||
|
const { contract, bets, comments } = props
|
||||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
const {
|
||||||
// Otherwise, we record the profit at resolution time
|
topBetId,
|
||||||
const profitById: Record<string, number> = {}
|
topBettor,
|
||||||
for (const bet of bets) {
|
profitById,
|
||||||
if (bet.sale) {
|
betsById,
|
||||||
const originalBet = betsById[bet.sale.betId]
|
topCommentId,
|
||||||
const profit = bet.sale.amount - originalBet.amount
|
commentsById,
|
||||||
profitById[bet.id] = profit
|
topCommentBetId,
|
||||||
profitById[originalBet.id] = profit
|
} = scoreCommentorsAndBettors(contract, bets, comments)
|
||||||
} else {
|
|
||||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now find the betId with the highest profit
|
|
||||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
|
||||||
const topBettor = betsById[topBetId]?.userName
|
|
||||||
|
|
||||||
// And also the comment with the highest profit
|
|
||||||
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 max-w-sm">
|
<div className="mt-12 max-w-sm">
|
||||||
{topComment && profitById[topComment.id] > 0 && (
|
{topCommentBetId && profitById[topCommentBetId] > 0 && (
|
||||||
<>
|
<>
|
||||||
<Title text="💬 Proven correct" className="!mt-0" />
|
<Title text="💬 Proven correct" className="!mt-0" />
|
||||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||||
<FeedComment contract={contract} comment={topComment} />
|
<FeedComment
|
||||||
|
contract={contract}
|
||||||
|
comment={commentsById[topCommentId]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Spacer h={16} />
|
<Spacer h={16} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* If they're the same, only show the comment; otherwise show both */}
|
{/* If they're the same, only show the comment; otherwise show both */}
|
||||||
{topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
|
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||||
<>
|
<>
|
||||||
<Title text="💸 Best bet" className="!mt-0" />
|
<Title text="💸 Best bet" className="!mt-0" />
|
||||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||||
|
|
|
@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const tips = useTipTxns({ contractId: contract.id })
|
const tips = useTipTxns({ contractId: contract.id })
|
||||||
const comments = useComments(contract.id) ?? props.comments
|
const comments = useComments(contract.id) ?? props.comments
|
||||||
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Best', {
|
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
|
||||||
key: `contract-comments-sort`,
|
key: `contract-comments-sort`,
|
||||||
store: storageStore(safeLocalStorage()),
|
store: storageStore(safeLocalStorage()),
|
||||||
})
|
})
|
||||||
|
@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
||||||
<Col className="mt-8 flex w-full">
|
<Col className="mt-8 flex w-full">
|
||||||
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
|
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
|
||||||
<div className="mb-4 w-full border-b border-gray-200" />
|
<div className="mb-4 w-full border-b border-gray-200" />
|
||||||
{sortRow}
|
|
||||||
<ContractCommentInput className="mb-5" contract={contract} />
|
<ContractCommentInput className="mb-5" contract={contract} />
|
||||||
|
{sortRow}
|
||||||
|
|
||||||
{generalTopLevelComments.map((comment) => (
|
{generalTopLevelComments.map((comment) => (
|
||||||
<FeedCommentThread
|
<FeedCommentThread
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
|
@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortRow}
|
|
||||||
<ContractCommentInput className="mb-5" contract={contract} />
|
<ContractCommentInput className="mb-5" contract={contract} />
|
||||||
|
{sortRow}
|
||||||
|
|
||||||
{topLevelComments.map((parent) => (
|
{topLevelComments.map((parent) => (
|
||||||
<FeedCommentThread
|
<FeedCommentThread
|
||||||
key={parent.id}
|
key={parent.id}
|
||||||
|
|
|
@ -16,7 +16,8 @@ import { InfoTooltip } from 'web/components/info-tooltip'
|
||||||
import { BETTORS, PRESENT_BET } from 'common/user'
|
import { BETTORS, PRESENT_BET } from 'common/user'
|
||||||
import { buildArray } from 'common/util/array'
|
import { buildArray } from 'common/util/array'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
|
import { AlertBox } from '../alert-box'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
|
||||||
export function LiquidityBountyPanel(props: { contract: Contract }) {
|
export function LiquidityBountyPanel(props: { contract: Contract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
@ -36,13 +37,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) {
|
||||||
const isCreator = user?.id === contract.creatorId
|
const isCreator = user?.id === contract.creatorId
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
|
|
||||||
|
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={buildArray(
|
tabs={buildArray(
|
||||||
{
|
|
||||||
title: 'Bounty Comments',
|
|
||||||
content: <AddCommentBountyPanel contract={contract} />,
|
|
||||||
},
|
|
||||||
(isCreator || isAdmin) &&
|
(isCreator || isAdmin) &&
|
||||||
isCPMM && {
|
isCPMM && {
|
||||||
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
|
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
|
||||||
|
@ -118,7 +117,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
<div className="mb-4 text-gray-500">
|
<div className="mb-4 text-gray-500">
|
||||||
Contribute your M$ to make this market more accurate.{' '}
|
Contribute your M$ to make this market more accurate.{' '}
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
|
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -145,6 +144,12 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <div>Processing...</div>}
|
{isLoading && <div>Processing...</div>}
|
||||||
|
|
||||||
|
<Spacer h={2} />
|
||||||
|
<AlertBox
|
||||||
|
title="Withdrawals ending"
|
||||||
|
text="Manifold is moving to a new system for handling subsidization. As part of this process, liquidity withdrawals will be disabled shortly. Feel free to withdraw any outstanding liquidity you've added now."
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
|
|
||||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||||
import { createPost } from 'web/lib/firebase/api'
|
import { createPost } from 'web/lib/firebase/api'
|
||||||
|
@ -10,6 +9,7 @@ import Router from 'next/router'
|
||||||
import { MAX_POST_TITLE_LENGTH } from 'common/post'
|
import { MAX_POST_TITLE_LENGTH } from 'common/post'
|
||||||
import { postPath } from 'web/lib/firebase/posts'
|
import { postPath } from 'web/lib/firebase/posts'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
|
import { ExpandingInput } from './expanding-input'
|
||||||
|
|
||||||
export function CreatePost(props: { group?: Group }) {
|
export function CreatePost(props: { group?: Group }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
@ -62,9 +62,8 @@ export function CreatePost(props: { group?: Group }) {
|
||||||
Title<span className={'text-red-700'}> *</span>
|
Title<span className={'text-red-700'}> *</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder="e.g. Elon Mania Post"
|
placeholder="e.g. Elon Mania Post"
|
||||||
className="input input-bordered resize-none"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={MAX_POST_TITLE_LENGTH}
|
maxLength={MAX_POST_TITLE_LENGTH}
|
||||||
value={title}
|
value={title}
|
||||||
|
@ -76,9 +75,8 @@ export function CreatePost(props: { group?: Group }) {
|
||||||
Subtitle<span className={'text-red-700'}> *</span>
|
Subtitle<span className={'text-red-700'}> *</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder="e.g. How Elon Musk is getting everyone's attention"
|
placeholder="e.g. How Elon Musk is getting everyone's attention"
|
||||||
className="input input-bordered resize-none"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={MAX_POST_TITLE_LENGTH}
|
maxLength={MAX_POST_TITLE_LENGTH}
|
||||||
value={subtitle}
|
value={subtitle}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Content,
|
Content,
|
||||||
Editor,
|
Editor,
|
||||||
mergeAttributes,
|
mergeAttributes,
|
||||||
|
Extensions,
|
||||||
} from '@tiptap/react'
|
} from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
|
@ -19,9 +20,7 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
|
||||||
import { DisplayMention } from './editor/mention'
|
import { DisplayMention } from './editor/mention'
|
||||||
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
|
|
||||||
import { DisplayContractMention } from './editor/contract-mention'
|
import { DisplayContractMention } from './editor/contract-mention'
|
||||||
import Iframe from 'common/util/tiptap-iframe'
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
import TiptapTweet from './editor/tiptap-tweet'
|
import TiptapTweet from './editor/tiptap-tweet'
|
||||||
|
@ -70,6 +69,22 @@ const DisplayLink = Link.extend({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const editorExtensions = (simple = false): Extensions => [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: simple ? false : { levels: [1, 2, 3] },
|
||||||
|
horizontalRule: simple ? false : {},
|
||||||
|
}),
|
||||||
|
simple ? DisplayImage : Image,
|
||||||
|
DisplayLink,
|
||||||
|
DisplayMention,
|
||||||
|
DisplayContractMention,
|
||||||
|
Iframe,
|
||||||
|
TiptapTweet,
|
||||||
|
TiptapSpoiler.configure({
|
||||||
|
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
const proseClass = clsx(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
'prose prose-p:my-0 prose-ul:my-0 prose-ol: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'
|
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||||
|
@ -110,29 +125,13 @@ export function useTextEditor(props: {
|
||||||
},
|
},
|
||||||
onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
|
onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
...editorExtensions(simple),
|
||||||
heading: simple ? false : { levels: [1, 2, 3] },
|
|
||||||
horizontalRule: simple ? false : {},
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
emptyEditorClass:
|
emptyEditorClass:
|
||||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({ limit: max }),
|
CharacterCount.configure({ limit: max }),
|
||||||
simple ? DisplayImage : Image,
|
|
||||||
DisplayLink,
|
|
||||||
DisplayMention.configure({
|
|
||||||
suggestion: mentionSuggestion,
|
|
||||||
}),
|
|
||||||
DisplayContractMention.configure({
|
|
||||||
suggestion: contractMentionSuggestion,
|
|
||||||
}),
|
|
||||||
Iframe,
|
|
||||||
TiptapTweet,
|
|
||||||
TiptapSpoiler.configure({
|
|
||||||
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
content: defaultValue ?? (key && content ? content : ''),
|
content: defaultValue ?? (key && content ? content : ''),
|
||||||
})
|
})
|
||||||
|
@ -355,10 +354,7 @@ export function RichContent(props: {
|
||||||
smallImage ? DisplayImage : Image,
|
smallImage ? DisplayImage : Image,
|
||||||
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
||||||
DisplayMention,
|
DisplayMention,
|
||||||
DisplayContractMention.configure({
|
DisplayContractMention,
|
||||||
// Needed to set a different PluginKey for Prosemirror
|
|
||||||
suggestion: contractMentionSuggestion,
|
|
||||||
}),
|
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
TiptapSpoiler.configure({
|
TiptapSpoiler.configure({
|
||||||
|
|
|
@ -6,16 +6,29 @@ import {
|
||||||
} from '@tiptap/react'
|
} from '@tiptap/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useContract } from 'web/hooks/use-contract'
|
import { useContract } from 'web/hooks/use-contract'
|
||||||
import { ContractMention } from '../contract/contract-mention'
|
import { ContractMention } from 'web/components/contract/contract-mention'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { contractMentionSuggestion } from './contract-mention-suggestion'
|
||||||
|
|
||||||
const name = 'contract-mention-component'
|
const name = 'contract-mention-component'
|
||||||
|
|
||||||
const ContractMentionComponent = (props: any) => {
|
const ContractMentionComponent = (props: any) => {
|
||||||
const contract = useContract(props.node.attrs.id)
|
const { label, id } = props.node.attrs
|
||||||
|
const contract = useContract(id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className={clsx(name, 'not-prose inline')}>
|
<NodeViewWrapper className={clsx(name, 'not-prose inline')}>
|
||||||
{contract && <ContractMention contract={contract} />}
|
{contract ? (
|
||||||
|
<ContractMention contract={contract} />
|
||||||
|
) : label ? (
|
||||||
|
<Link href={label}>
|
||||||
|
<a className="rounded-sm !text-indigo-700 hover:bg-indigo-50">
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'[loading...]'
|
||||||
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -29,8 +42,5 @@ export const DisplayContractMention = Mention.extend({
|
||||||
name: 'contract-mention',
|
name: 'contract-mention',
|
||||||
parseHTML: () => [{ tag: name }],
|
parseHTML: () => [{ tag: name }],
|
||||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
addNodeView: () =>
|
addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent),
|
||||||
ReactNodeViewRenderer(ContractMentionComponent, {
|
}).configure({ suggestion: contractMentionSuggestion })
|
||||||
// On desktop, render cards below half-width so you can stack two
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from '@tiptap/react'
|
} from '@tiptap/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Linkify } from '../linkify'
|
import { Linkify } from '../linkify'
|
||||||
|
import { mentionSuggestion } from './mention-suggestion'
|
||||||
|
|
||||||
const name = 'mention-component'
|
const name = 'mention-component'
|
||||||
|
|
||||||
|
@ -27,4 +28,4 @@ export const DisplayMention = Mention.extend({
|
||||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
addNodeView: () =>
|
addNodeView: () =>
|
||||||
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||||
})
|
}).configure({ suggestion: mentionSuggestion })
|
||||||
|
|
16
web/components/expanding-input.tsx
Normal file
16
web/components/expanding-input.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Textarea from 'react-expanding-textarea'
|
||||||
|
|
||||||
|
/** Expanding `<textarea>` with same style as input.tsx */
|
||||||
|
export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => {
|
||||||
|
const { className, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
className={clsx(
|
||||||
|
'textarea textarea-bordered resize-none text-[16px] md:text-[14px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: {
|
||||||
}
|
}
|
||||||
const totalAwarded = bountiesAwarded ?? 0
|
const totalAwarded = bountiesAwarded ?? 0
|
||||||
|
|
||||||
const router = useRouter()
|
const { isReady, asPath } = useRouter()
|
||||||
const highlighted = router.asPath.endsWith(`#${comment.id}`)
|
const [highlighted, setHighlighted] = useState(false)
|
||||||
const commentRef = useRef<HTMLDivElement>(null)
|
const commentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlighted && commentRef.current != null) {
|
if (isReady && asPath.endsWith(`#${comment.id}`)) {
|
||||||
|
setHighlighted(true)
|
||||||
|
}
|
||||||
|
}, [isReady, asPath, comment.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlighted && commentRef.current) {
|
||||||
commentRef.current.scrollIntoView(true)
|
commentRef.current.scrollIntoView(true)
|
||||||
}
|
}
|
||||||
}, [highlighted])
|
}, [highlighted])
|
||||||
|
@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: {
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative',
|
'relative',
|
||||||
indent ? 'ml-6' : '',
|
indent ? 'ml-6' : '',
|
||||||
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
|
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*draw a gray line from the comment to the left:*/}
|
{/*draw a gray line from the comment to the left:*/}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export function FilterSelectUsers(props: {
|
export function FilterSelectUsers(props: {
|
||||||
setSelectedUsers: (users: User[]) => void
|
setSelectedUsers: (users: User[]) => void
|
||||||
|
@ -50,13 +51,13 @@ export function FilterSelectUsers(props: {
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="user name"
|
name="user name"
|
||||||
id="user name"
|
id="user name"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
className="block w-full pl-10"
|
||||||
placeholder="Austin Chen"
|
placeholder="Austin Chen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Title } from '../title'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
|
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
|
||||||
import { createGroup } from 'web/lib/firebase/api'
|
import { createGroup } from 'web/lib/firebase/api'
|
||||||
|
import { Input } from '../input'
|
||||||
|
|
||||||
export function CreateGroupButton(props: {
|
export function CreateGroupButton(props: {
|
||||||
user: User
|
user: User
|
||||||
|
@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
|
||||||
|
|
||||||
<div className="form-control w-full">
|
<div className="form-control w-full">
|
||||||
<label className="mb-2 ml-1 mt-0">Group name</label>
|
<label className="mb-2 ml-1 mt-0">Group name</label>
|
||||||
<input
|
<Input
|
||||||
placeholder={'Your group name'}
|
placeholder={'Your group name'}
|
||||||
className="input input-bordered resize-none"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={name}
|
value={name}
|
||||||
maxLength={MAX_GROUP_NAME_LENGTH}
|
maxLength={MAX_GROUP_NAME_LENGTH}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
|
||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useMemberIds } from 'web/hooks/use-group'
|
import { useMemberIds } from 'web/hooks/use-group'
|
||||||
|
import { Input } from '../input'
|
||||||
|
|
||||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const { group, className } = props
|
const { group, className } = props
|
||||||
|
@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
<span className="mb-1">Group name</span>
|
<span className="mb-1">Group name</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<Input
|
||||||
placeholder="Your group name"
|
placeholder="Your group name"
|
||||||
className="input input-bordered resize-none"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value || '')}
|
onChange={(e) => setName(e.target.value || '')}
|
||||||
|
|
22
web/components/input.tsx
Normal file
22
web/components/input.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/** Text input. Wraps html `<input>` */
|
||||||
|
export const Input = (props: JSX.IntrinsicElements['input']) => {
|
||||||
|
const { className, ...rest } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={clsx('input input-bordered text-base md:text-sm', className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: replace daisyui style with our own. For reference:
|
||||||
|
|
||||||
|
james: text-lg placeholder:text-gray-400
|
||||||
|
inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md
|
||||||
|
austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm
|
||||||
|
*/
|
|
@ -7,12 +7,13 @@ import { User } from 'common/user'
|
||||||
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
|
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
|
||||||
import { createManalink } from 'web/lib/firebase/manalinks'
|
import { createManalink } from 'web/lib/firebase/manalinks'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
import { getManalinkUrl } from 'web/pages/links'
|
import { getManalinkUrl } from 'web/pages/links'
|
||||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||||
import { QRCode } from '../qr-code'
|
import { QRCode } from '../qr-code'
|
||||||
|
import { Input } from '../input'
|
||||||
|
import { ExpandingInput } from '../expanding-input'
|
||||||
|
|
||||||
export function CreateLinksButton(props: {
|
export function CreateLinksButton(props: {
|
||||||
user: User
|
user: User
|
||||||
|
@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
|
||||||
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
|
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
|
||||||
M$
|
M$
|
||||||
</span>
|
</span>
|
||||||
<input
|
<Input
|
||||||
className="input input-bordered w-full pl-10"
|
className="w-full pl-10"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={newManalink.amount}
|
value={newManalink.amount}
|
||||||
|
@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
|
||||||
<div className="flex flex-col gap-2 md:flex-row">
|
<div className="flex flex-col gap-2 md:flex-row">
|
||||||
<div className="form-control w-full md:w-1/2">
|
<div className="form-control w-full md:w-1/2">
|
||||||
<label className="label">Uses</label>
|
<label className="label">Uses</label>
|
||||||
<input
|
<Input
|
||||||
className="input input-bordered"
|
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={newManalink.maxUses ?? ''}
|
value={newManalink.maxUses ?? ''}
|
||||||
|
@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
|
||||||
return { ...m, maxUses: parseInt(e.target.value) }
|
return { ...m, maxUses: parseInt(e.target.value) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
></input>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control w-full md:w-1/2">
|
<div className="form-control w-full md:w-1/2">
|
||||||
<label className="label">Expires in</label>
|
<label className="label">Expires in</label>
|
||||||
|
@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control w-full">
|
<div className="form-control w-full">
|
||||||
<label className="label">Message</label>
|
<label className="label">Message</label>
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder={defaultMessage}
|
placeholder={defaultMessage}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
className="input input-bordered resize-none"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
value={newManalink.message}
|
value={newManalink.message}
|
||||||
rows="3"
|
rows="3"
|
||||||
|
|
|
@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
|
||||||
return buildArray(
|
return buildArray(
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
{ name: 'Groups', href: '/groups' },
|
{ name: 'Groups', href: '/groups' },
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
{ name: 'Refer a friend', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Labs', href: '/labs' },
|
{ name: 'Labs', href: '/labs' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
@ -215,7 +215,7 @@ function getMoreMobileNav() {
|
||||||
|
|
||||||
return buildArray<MenuItem>(
|
return buildArray<MenuItem>(
|
||||||
{ name: 'Groups', href: '/groups' },
|
{ name: 'Groups', href: '/groups' },
|
||||||
{ name: 'Referrals', href: '/referrals' },
|
{ name: 'Refer a friend', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Labs', href: '/labs' },
|
{ name: 'Labs', href: '/labs' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
|
|
@ -139,6 +139,7 @@ export function NotificationSettings(props: {
|
||||||
'loan_income',
|
'loan_income',
|
||||||
'limit_order_fills',
|
'limit_order_fills',
|
||||||
'tips_on_your_comments',
|
'tips_on_your_comments',
|
||||||
|
'badges_awarded',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const userInteractions: SectionData = {
|
const userInteractions: SectionData = {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ReactNode } from 'react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
export function NumberInput(props: {
|
export function NumberInput(props: {
|
||||||
numberString: string
|
numberString: string
|
||||||
|
@ -32,9 +33,9 @@ export function NumberInput(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group">
|
<label className="input-group">
|
||||||
<input
|
<Input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
'max-w-[200px] !text-lg',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||||
import { BucketInput } from './bucket-input'
|
import { BucketInput } from './bucket-input'
|
||||||
|
import { Input } from './input'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
|
|
||||||
|
@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group">
|
<label className="input-group">
|
||||||
<input
|
<Input
|
||||||
className={clsx(
|
className={clsx('max-w-[200px] !text-lg', inputClassName)}
|
||||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
|
||||||
inputClassName
|
|
||||||
)}
|
|
||||||
type="number"
|
type="number"
|
||||||
max={99}
|
max={99}
|
||||||
min={1}
|
min={1}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Input } from './input'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
|
|
||||||
export function ProbabilitySelector(props: {
|
export function ProbabilitySelector(props: {
|
||||||
|
@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
<label className="input-group input-group-lg text-lg">
|
<label className="input-group input-group-lg text-lg">
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={probabilityInt}
|
value={probabilityInt}
|
||||||
className="input input-bordered input-md w-28 text-lg"
|
className="input-md w-28 !text-lg"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
min={1}
|
min={1}
|
||||||
max={99}
|
max={99}
|
||||||
|
|
223
web/components/profile/badges-modal.tsx
Normal file
223
web/components/profile/badges-modal.tsx
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { PAST_BETS, User } from 'common/user'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
calculateBadgeRarity,
|
||||||
|
MarketCreatorBadge,
|
||||||
|
ProvenCorrectBadge,
|
||||||
|
rarities,
|
||||||
|
StreakerBadge,
|
||||||
|
} from 'common/badge'
|
||||||
|
import { groupBy } from 'lodash'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
|
||||||
|
import { Tooltip } from 'web/components/tooltip'
|
||||||
|
import {
|
||||||
|
bronzeClassName,
|
||||||
|
goldClassName,
|
||||||
|
silverClassName,
|
||||||
|
} from 'web/components/badge-display'
|
||||||
|
|
||||||
|
export function BadgesModal(props: {
|
||||||
|
isOpen: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
user: User
|
||||||
|
}) {
|
||||||
|
const { isOpen, setOpen, user } = props
|
||||||
|
const { provenCorrect, marketCreator, streaker } = user.achievements ?? {}
|
||||||
|
const badges = [
|
||||||
|
...(provenCorrect?.badges ?? []),
|
||||||
|
...(streaker?.badges ?? []),
|
||||||
|
...(marketCreator?.badges ?? []),
|
||||||
|
]
|
||||||
|
|
||||||
|
// group badges by their rarities
|
||||||
|
const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
|
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||||
|
<span className={clsx('text-8xl')}>🏅</span>
|
||||||
|
<span className="text-xl">{user.name + "'s"} badges</span>
|
||||||
|
|
||||||
|
<Row className={'flex-wrap gap-2'}>
|
||||||
|
<Col
|
||||||
|
className={clsx(
|
||||||
|
'min-w-full gap-2 rounded-md border-2 border-amber-900 border-opacity-40 p-2 text-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx(' ', bronzeClassName)}>Bronze</span>
|
||||||
|
<Row className={'flex-wrap justify-center gap-4'}>
|
||||||
|
{badgesByRarity['bronze'] ? (
|
||||||
|
badgesByRarity['bronze'].map((badge, i) => (
|
||||||
|
<BadgeToItem badge={badge} key={i} rarity={'bronze'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className={'text-gray-500'}>None yet</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col
|
||||||
|
className={clsx(
|
||||||
|
'min-w-full gap-2 rounded-md border-2 border-gray-500 border-opacity-40 p-2 text-center '
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx(' ', silverClassName)}>Silver</span>
|
||||||
|
<Row className={'flex-wrap justify-center gap-4'}>
|
||||||
|
{badgesByRarity['silver'] ? (
|
||||||
|
badgesByRarity['silver'].map((badge, i) => (
|
||||||
|
<BadgeToItem badge={badge} key={i} rarity={'silver'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className={'text-gray-500'}>None yet</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col
|
||||||
|
className={clsx(
|
||||||
|
'min-w-full gap-2 rounded-md border-2 border-amber-400 p-2 text-center '
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx('', goldClassName)}>Gold</span>
|
||||||
|
<Row className={'flex-wrap justify-center gap-4'}>
|
||||||
|
{badgesByRarity['gold'] ? (
|
||||||
|
badgesByRarity['gold'].map((badge, i) => (
|
||||||
|
<BadgeToItem badge={badge} key={i} rarity={'gold'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className={'text-gray-500'}>None yet</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeToItem(props: { badge: Badge; rarity: rarities }) {
|
||||||
|
const { badge, rarity } = props
|
||||||
|
if (badge.type === 'PROVEN_CORRECT')
|
||||||
|
return (
|
||||||
|
<ProvenCorrectBadgeItem
|
||||||
|
badge={badge as ProvenCorrectBadge}
|
||||||
|
rarity={rarity}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
else if (badge.type === 'STREAKER')
|
||||||
|
return <StreakerBadgeItem badge={badge as StreakerBadge} rarity={rarity} />
|
||||||
|
else if (badge.type === 'MARKET_CREATOR')
|
||||||
|
return (
|
||||||
|
<MarketCreatorBadgeItem
|
||||||
|
badge={badge as MarketCreatorBadge}
|
||||||
|
rarity={rarity}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvenCorrectBadgeItem(props: {
|
||||||
|
badge: ProvenCorrectBadge
|
||||||
|
rarity: rarities
|
||||||
|
}) {
|
||||||
|
const { badge, rarity } = props
|
||||||
|
const { betAmount, contractSlug, contractCreatorUsername } = badge.data
|
||||||
|
return (
|
||||||
|
<SiteLink
|
||||||
|
href={contractPathWithoutContract(contractCreatorUsername, contractSlug)}
|
||||||
|
>
|
||||||
|
<Col className={'text-center'}>
|
||||||
|
<Medal rarity={rarity} />
|
||||||
|
<Tooltip
|
||||||
|
text={`Make a comment attached to a winning bet worth ${betAmount}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rarity === 'gold'
|
||||||
|
? goldClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: bronzeClassName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Proven Correct
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Col>
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) {
|
||||||
|
const { badge, rarity } = props
|
||||||
|
const { totalBettingStreak } = badge.data
|
||||||
|
return (
|
||||||
|
<Col className={'cursor-default text-center'}>
|
||||||
|
<Medal rarity={rarity} />
|
||||||
|
<Tooltip
|
||||||
|
text={`Make ${PAST_BETS} ${totalBettingStreak} day${
|
||||||
|
totalBettingStreak > 1 ? 's' : ''
|
||||||
|
} in a row`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rarity === 'gold'
|
||||||
|
? goldClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: bronzeClassName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Prediction Streak
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function MarketCreatorBadgeItem(props: {
|
||||||
|
badge: MarketCreatorBadge
|
||||||
|
rarity: rarities
|
||||||
|
}) {
|
||||||
|
const { badge, rarity } = props
|
||||||
|
const { totalContractsCreated } = badge.data
|
||||||
|
return (
|
||||||
|
<Col className={'cursor-default text-center'}>
|
||||||
|
<Medal rarity={rarity} />
|
||||||
|
<Tooltip
|
||||||
|
text={`Make ${totalContractsCreated} market${
|
||||||
|
totalContractsCreated > 1 ? 's' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rarity === 'gold'
|
||||||
|
? goldClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: bronzeClassName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Market Creator
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function Medal(props: { rarity: rarities }) {
|
||||||
|
const { rarity } = props
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rarity === 'gold'
|
||||||
|
? goldClassName
|
||||||
|
: rarity === 'silver'
|
||||||
|
? silverClassName
|
||||||
|
: bronzeClassName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ import clsx from 'clsx'
|
||||||
export function BettingStreakModal(props: {
|
export function BettingStreakModal(props: {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
currentUser?: User | null
|
currentUser: User | null | undefined
|
||||||
}) {
|
}) {
|
||||||
const { isOpen, setOpen, currentUser } = props
|
const { isOpen, setOpen, currentUser } = props
|
||||||
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
|
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
|
||||||
|
|
|
@ -35,8 +35,8 @@ export function LoansModal(props: {
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-indigo-700'}>• What is an example?</span>
|
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
For example, if you bet M$1000 on "Will I become a millionare?"
|
For example, if you bet M$1000 on "Will I become a millionare?", you
|
||||||
today, you will get M$20 back tomorrow.
|
will get M$20 back tomorrow.
|
||||||
</span>
|
</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
Previous loans count against your total bet amount. So on the next
|
Previous loans count against your total bet amount. So on the next
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { CPMMBinaryContract } from 'common/contract'
|
||||||
import { Customize, USAMap } from './usa-map'
|
import { Customize, USAMap } from './usa-map'
|
||||||
import { listenForContract } from 'web/lib/firebase/contracts'
|
import { listenForContract } from 'web/lib/firebase/contracts'
|
||||||
import { interpolateColor } from 'common/util/color'
|
import { interpolateColor } from 'common/util/color'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export interface StateElectionMarket {
|
export interface StateElectionMarket {
|
||||||
creatorUsername: string
|
creatorUsername: string
|
||||||
|
@ -35,8 +36,13 @@ export function StateElectionMap(props: {
|
||||||
market.state,
|
market.state,
|
||||||
{
|
{
|
||||||
fill: probToColor(prob, market.isWinRepublican),
|
fill: probToColor(prob, market.isWinRepublican),
|
||||||
clickHandler: () =>
|
clickHandler: () => {
|
||||||
Router.push(`/${market.creatorUsername}/${market.slug}`),
|
Router.push(`/${market.creatorUsername}/${market.slug}`)
|
||||||
|
track('state election map click', {
|
||||||
|
state: market.state,
|
||||||
|
slug: market.slug,
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
export function shortenName(name: string) {
|
export function shortenName(name: string) {
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -24,10 +25,12 @@ export function UserLink(props: {
|
||||||
}) {
|
}) {
|
||||||
const { name, username, className, short, noLink } = props
|
const { name, username, className, short, noLink } = props
|
||||||
const shortName = short ? shortenName(name) : name
|
const shortName = short ? shortenName(name) : name
|
||||||
|
const { width } = useWindowSize()
|
||||||
return (
|
return (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={`/${username}`}
|
href={`/${username}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
(width ?? 0) < 450 ? ' max-w-[120px]' : 'max-w-[200px]',
|
||||||
'z-10 truncate',
|
'z-10 truncate',
|
||||||
className,
|
className,
|
||||||
noLink ? 'pointer-events-none' : ''
|
noLink ? 'pointer-events-none' : ''
|
||||||
|
@ -39,7 +42,13 @@ export function UserLink(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOT_USERNAMES = ['v', 'ArbitrageBot']
|
const BOT_USERNAMES = [
|
||||||
|
'v',
|
||||||
|
'ArbitrageBot',
|
||||||
|
'MarketManagerBot',
|
||||||
|
'Botlab',
|
||||||
|
'JuniorBot',
|
||||||
|
]
|
||||||
|
|
||||||
function BotBadge() {
|
function BotBadge() {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -31,14 +31,12 @@ import { UserFollowButton } from './follow-button'
|
||||||
import { GroupsButton } from 'web/components/groups/groups-button'
|
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import {
|
|
||||||
BettingStreakModal,
|
|
||||||
hasCompletedStreakToday,
|
|
||||||
} from 'web/components/profile/betting-streak-modal'
|
|
||||||
import { LoansModal } from './profile/loans-modal'
|
import { LoansModal } from './profile/loans-modal'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { DOMAIN } from 'common/envs/constants'
|
import { DOMAIN } from 'common/envs/constants'
|
||||||
|
import { BadgeDisplay } from 'web/components/badge-display'
|
||||||
|
|
||||||
export function UserPage(props: { user: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
|
@ -52,8 +50,8 @@ export function UserPage(props: { user: User }) {
|
||||||
setShowConfetti(claimedMana)
|
setShowConfetti(claimedMana)
|
||||||
const query = { ...router.query }
|
const query = { ...router.query }
|
||||||
if (query.claimedMana || query.show) {
|
if (query.claimedMana || query.show) {
|
||||||
delete query['claimed-mana']
|
const queriesToDelete = ['claimed-mana', 'show', 'badge']
|
||||||
delete query['show']
|
queriesToDelete.forEach((key) => delete query[key])
|
||||||
router.replace(
|
router.replace(
|
||||||
{
|
{
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
|
@ -79,6 +77,7 @@ export function UserPage(props: { user: User }) {
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className="relative">
|
<Col className="relative">
|
||||||
<Row className="relative px-4 pt-4">
|
<Row className="relative px-4 pt-4">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -101,9 +100,10 @@ export function UserPage(props: { user: User }) {
|
||||||
<span className="break-anywhere text-lg font-bold sm:text-2xl">
|
<span className="break-anywhere text-lg font-bold sm:text-2xl">
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="sm:text-md text-greyscale-4 text-sm">
|
<Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm ">
|
||||||
@{user.username}
|
<span className={' text-greyscale-4'}>@{user.username}</span>
|
||||||
</span>
|
<BadgeDisplay user={user} query={router.query} />
|
||||||
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<ProfilePrivateStats
|
<ProfilePrivateStats
|
||||||
|
@ -278,14 +278,10 @@ export function ProfilePrivateStats(props: {
|
||||||
user: User
|
user: User
|
||||||
router: NextRouter
|
router: NextRouter
|
||||||
}) {
|
}) {
|
||||||
const { currentUser, profit, user, router } = props
|
const { profit, user, router } = props
|
||||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
|
||||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showBettingStreak = router.query['show'] === 'betting-streak'
|
|
||||||
setShowBettingStreakModal(showBettingStreak)
|
|
||||||
|
|
||||||
const showLoansModel = router.query['show'] === 'loans'
|
const showLoansModel = router.query['show'] === 'loans'
|
||||||
setShowLoansModal(showLoansModel)
|
setShowLoansModal(showLoansModel)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
@ -301,23 +297,6 @@ export function ProfilePrivateStats(props: {
|
||||||
</span>
|
</span>
|
||||||
<span className="mx-auto text-xs sm:text-sm">profit</span>
|
<span className="mx-auto text-xs sm:text-sm">profit</span>
|
||||||
</Col>
|
</Col>
|
||||||
<Col
|
|
||||||
className={clsx('text-,d cursor-pointer sm:text-lg ')}
|
|
||||||
onClick={() => setShowBettingStreakModal(true)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
!hasCompletedStreakToday(user)
|
|
||||||
? 'opacity-50 grayscale'
|
|
||||||
: 'grayscale-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
🔥 {user.currentBettingStreak ?? 0}
|
|
||||||
</span>
|
|
||||||
<span className="text-greyscale-4 mx-auto text-xs sm:text-sm">
|
|
||||||
streak
|
|
||||||
</span>
|
|
||||||
</Col>
|
|
||||||
<Col
|
<Col
|
||||||
className={
|
className={
|
||||||
'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
|
'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg'
|
||||||
|
@ -330,13 +309,6 @@ export function ProfilePrivateStats(props: {
|
||||||
<span className="mx-auto text-xs sm:text-sm">next loan</span>
|
<span className="mx-auto text-xs sm:text-sm">next loan</span>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{BettingStreakModal && (
|
|
||||||
<BettingStreakModal
|
|
||||||
isOpen={showBettingStreakModal}
|
|
||||||
setOpen={setShowBettingStreakModal}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showLoansModal && (
|
{showLoansModal && (
|
||||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"lint-fix": "next lint --fix",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"verify": "(cd .. && yarn verify)",
|
"verify": "(cd .. && yarn verify)",
|
||||||
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||||
|
|
|
@ -270,7 +270,11 @@ export function ContractPageContent(
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||||
<ContractLeaderboard contract={contract} bets={bets} />
|
<ContractLeaderboard contract={contract} bets={bets} />
|
||||||
<ContractTopTrades contract={contract} bets={bets} />
|
<ContractTopTrades
|
||||||
|
contract={contract}
|
||||||
|
bets={bets}
|
||||||
|
comments={comments}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Spacer h={12} />
|
<Spacer h={12} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -192,7 +192,6 @@ export type LiteUser = {
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|
||||||
bio?: string
|
bio?: string
|
||||||
bannerUrl?: string
|
|
||||||
website?: string
|
website?: string
|
||||||
twitterHandle?: string
|
twitterHandle?: string
|
||||||
discordHandle?: string
|
discordHandle?: string
|
||||||
|
@ -223,7 +222,6 @@ export function toLiteUser(user: User): LiteUser {
|
||||||
username,
|
username,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
bio,
|
bio,
|
||||||
bannerUrl,
|
|
||||||
website,
|
website,
|
||||||
twitterHandle,
|
twitterHandle,
|
||||||
discordHandle,
|
discordHandle,
|
||||||
|
@ -241,7 +239,6 @@ export function toLiteUser(user: User): LiteUser {
|
||||||
url: `https://${ENV_CONFIG.domain}/${username}`,
|
url: `https://${ENV_CONFIG.domain}/${username}`,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
bio,
|
bio,
|
||||||
bannerUrl,
|
|
||||||
website,
|
website,
|
||||||
twitterHandle,
|
twitterHandle,
|
||||||
discordHandle,
|
discordHandle,
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
let txns = await getAllCharityTxns()
|
let txns = await getAllCharityTxns()
|
||||||
|
@ -171,11 +172,11 @@ export default function Charity(props: {
|
||||||
/>
|
/>
|
||||||
<Spacer h={10} />
|
<Spacer h={10} />
|
||||||
|
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
onChange={(e) => debouncedQuery(e.target.value)}
|
onChange={(e) => debouncedQuery(e.target.value)}
|
||||||
placeholder="Find a charity"
|
placeholder="Find a charity"
|
||||||
className="input input-bordered mb-6 w-full"
|
className="mb-6 w-full"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
urlParamStore,
|
urlParamStore,
|
||||||
} from 'web/hooks/use-persistent-state'
|
} from 'web/hooks/use-persistent-state'
|
||||||
import { PAST_BETS } from 'common/user'
|
import { PAST_BETS } from 'common/user'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
|
||||||
const MAX_CONTRACTS_RENDERED = 100
|
const MAX_CONTRACTS_RENDERED = 100
|
||||||
|
|
||||||
|
@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: {
|
||||||
<div>
|
<div>
|
||||||
{/* Show a search input next to a sort dropdown */}
|
{/* Show a search input next to a sort dropdown */}
|
||||||
<div className="mt-2 mb-8 flex justify-between gap-2">
|
<div className="mt-2 mb-8 flex justify-between gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search markets"
|
placeholder="Search markets"
|
||||||
className="input input-bordered w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered"
|
className="select select-bordered"
|
||||||
|
|
|
@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
||||||
|
@ -38,6 +37,8 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { AddFundsModal } from 'web/components/add-funds-modal'
|
import { AddFundsModal } from 'web/components/add-funds-modal'
|
||||||
import ShortToggle from 'web/components/widgets/short-toggle'
|
import ShortToggle from 'web/components/widgets/short-toggle'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
import { ExpandingInput } from 'web/components/expanding-input'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||||
|
@ -103,9 +104,8 @@ export default function Create(props: { auth: { user: User } }) {
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
|
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
|
||||||
className="input input-bordered resize-none"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={MAX_QUESTION_LENGTH}
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
value={question}
|
value={question}
|
||||||
|
@ -327,9 +327,9 @@ export function NewContract(props: {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Row className="gap-2">
|
<Row className="gap-2">
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered w-32"
|
className="w-32"
|
||||||
placeholder="LOW"
|
placeholder="LOW"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setMinString(e.target.value)}
|
onChange={(e) => setMinString(e.target.value)}
|
||||||
|
@ -338,9 +338,9 @@ export function NewContract(props: {
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={minString ?? ''}
|
value={minString ?? ''}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered w-32"
|
className="w-32"
|
||||||
placeholder="HIGH"
|
placeholder="HIGH"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setMaxString(e.target.value)}
|
onChange={(e) => setMaxString(e.target.value)}
|
||||||
|
@ -372,9 +372,8 @@ export function NewContract(props: {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Row className="gap-2">
|
<Row className="gap-2">
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered"
|
|
||||||
placeholder="Initial value"
|
placeholder="Initial value"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setInitialValueString(e.target.value)}
|
onChange={(e) => setInitialValueString(e.target.value)}
|
||||||
|
@ -444,19 +443,17 @@ export function NewContract(props: {
|
||||||
className={'col-span-4 sm:col-span-2'}
|
className={'col-span-4 sm:col-span-2'}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row className="mt-4 gap-2">
|
||||||
<input
|
<Input
|
||||||
type={'date'}
|
type={'date'}
|
||||||
className="input input-bordered mt-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseDate(e.target.value)}
|
onChange={(e) => setCloseDate(e.target.value)}
|
||||||
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={closeDate}
|
value={closeDate}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
type={'time'}
|
type={'time'}
|
||||||
className="input input-bordered mt-4 ml-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||||
min={'00:00'}
|
min={'00:00'}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import Router from 'next/router'
|
import Router from 'next/router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
|
|
||||||
import { DateDoc } from 'common/post'
|
import { DateDoc } from 'common/post'
|
||||||
import { useTextEditor, TextEditor } from 'web/components/editor'
|
import { useTextEditor, TextEditor } from 'web/components/editor'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
|
@ -17,6 +15,8 @@ import { MAX_QUESTION_LENGTH } from 'common/contract'
|
||||||
import { NoSEO } from 'web/components/NoSEO'
|
import { NoSEO } from 'web/components/NoSEO'
|
||||||
import ShortToggle from 'web/components/widgets/short-toggle'
|
import ShortToggle from 'web/components/widgets/short-toggle'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
import { ExpandingInput } from 'web/components/expanding-input'
|
||||||
|
|
||||||
export default function CreateDateDocPage() {
|
export default function CreateDateDocPage() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -94,9 +94,8 @@ export default function CreateDateDocPage() {
|
||||||
<Col className="gap-8">
|
<Col className="gap-8">
|
||||||
<Col className="max-w-[160px] justify-start gap-4">
|
<Col className="max-w-[160px] justify-start gap-4">
|
||||||
<div className="">Birthday</div>
|
<div className="">Birthday</div>
|
||||||
<input
|
<Input
|
||||||
type={'date'}
|
type={'date'}
|
||||||
className="input input-bordered"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setBirthday(e.target.value)}
|
onChange={(e) => setBirthday(e.target.value)}
|
||||||
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||||
|
@ -122,8 +121,7 @@ export default function CreateDateDocPage() {
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Col className="gap-2">
|
<Col className="gap-2">
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
className="input input-bordered resize-none"
|
|
||||||
maxLength={MAX_QUESTION_LENGTH}
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e) => setQuestion(e.target.value || '')}
|
onChange={(e) => setQuestion(e.target.value || '')}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { getUser, User } from 'web/lib/firebase/users'
|
||||||
import { DateDocPost } from './[username]'
|
import { DateDocPost } from './[username]'
|
||||||
import { NoSEO } from 'web/components/NoSEO'
|
import { NoSEO } from 'web/components/NoSEO'
|
||||||
import { useDateDocs } from 'web/hooks/use-post'
|
import { useDateDocs } from 'web/hooks/use-post'
|
||||||
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const dateDocs = await getDateDocs()
|
const dateDocs = await getDateDocs()
|
||||||
|
@ -40,6 +41,7 @@ export default function DatePage(props: {
|
||||||
|
|
||||||
const dateDocs = useDateDocs() ?? props.dateDocs
|
const dateDocs = useDateDocs() ?? props.dateDocs
|
||||||
const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
|
const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
|
||||||
|
useTracking('view date docs page')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO'
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from 'next'
|
||||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
const creds = await authenticateOnServer(ctx)
|
const creds = await authenticateOnServer(ctx)
|
||||||
|
@ -106,12 +107,12 @@ export default function Groups(props: {
|
||||||
title: 'All',
|
title: 'All',
|
||||||
content: (
|
content: (
|
||||||
<Col>
|
<Col>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
onChange={(e) => debouncedQuery(e.target.value)}
|
onChange={(e) => debouncedQuery(e.target.value)}
|
||||||
placeholder="Search groups"
|
placeholder="Search groups"
|
||||||
value={query}
|
value={query}
|
||||||
className="input input-bordered mb-4 w-full"
|
className="mb-4 w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
@ -134,12 +135,12 @@ export default function Groups(props: {
|
||||||
title: 'My Groups',
|
title: 'My Groups',
|
||||||
content: (
|
content: (
|
||||||
<Col>
|
<Col>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => debouncedQuery(e.target.value)}
|
onChange={(e) => debouncedQuery(e.target.value)}
|
||||||
placeholder="Search your groups"
|
placeholder="Search your groups"
|
||||||
className="input input-bordered mb-4 w-full"
|
className="mb-4 w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
} from 'web/hooks/use-contracts'
|
} from 'web/hooks/use-contracts'
|
||||||
import { ProfitBadge } from 'web/components/profit-badge'
|
import { ProfitBadge } from 'web/components/profit-badge'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -99,10 +100,10 @@ export default function Home() {
|
||||||
<Row
|
<Row
|
||||||
className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
|
className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={'Search'}
|
placeholder={'Search'}
|
||||||
className="input input-bordered w-full"
|
className="w-full"
|
||||||
onClick={() => Router.push('/search')}
|
onClick={() => Router.push('/search')}
|
||||||
/>
|
/>
|
||||||
<CustomizeButton justIcon />
|
<CustomizeButton justIcon />
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
StateElectionMarket,
|
StateElectionMarket,
|
||||||
StateElectionMap,
|
StateElectionMap,
|
||||||
} from 'web/components/usa-map/state-election-map'
|
} from 'web/components/usa-map/state-election-map'
|
||||||
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
||||||
|
|
||||||
const senateMidterms: StateElectionMarket[] = [
|
const senateMidterms: StateElectionMarket[] = [
|
||||||
|
@ -203,6 +204,8 @@ const App = (props: {
|
||||||
}) => {
|
}) => {
|
||||||
const { senateContracts, governorContracts } = props
|
const { senateContracts, governorContracts } = props
|
||||||
|
|
||||||
|
useTracking('view midterms 2022')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="">
|
<Page className="">
|
||||||
<Col className="items-center justify-center">
|
<Col className="items-center justify-center">
|
||||||
|
|
|
@ -13,11 +13,7 @@ import { Page } from 'web/components/page'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { doc, updateDoc } from 'firebase/firestore'
|
import { doc, updateDoc } from 'firebase/firestore'
|
||||||
import { db } from 'web/lib/firebase/init'
|
import { db } from 'web/lib/firebase/init'
|
||||||
import {
|
import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user'
|
||||||
MANIFOLD_AVATAR_URL,
|
|
||||||
MANIFOLD_USERNAME,
|
|
||||||
PrivateUser,
|
|
||||||
} from 'common/user'
|
|
||||||
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'
|
||||||
|
@ -469,8 +465,11 @@ function IncomeNotificationItem(props: {
|
||||||
simple ? (
|
simple ? (
|
||||||
<span className={'ml-1 font-bold'}>🏦 Loan</span>
|
<span className={'ml-1 font-bold'}>🏦 Loan</span>
|
||||||
) : (
|
) : (
|
||||||
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
|
<SiteLink
|
||||||
🏦 Loan
|
className={'relative ml-1 font-bold'}
|
||||||
|
href={`/${sourceUserUsername}/?show=loans`}
|
||||||
|
>
|
||||||
|
🏦 Loan <span className="font-normal">(learn more)</span>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)
|
)
|
||||||
) : sourceType === 'betting_streak_bonus' ? (
|
) : sourceType === 'betting_streak_bonus' ? (
|
||||||
|
@ -478,8 +477,8 @@ function IncomeNotificationItem(props: {
|
||||||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||||
) : (
|
) : (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
className={'ml-1 font-bold'}
|
className={'relative ml-1 font-bold'}
|
||||||
href={'/betting-streak-bonus'}
|
href={`/${sourceUserUsername}/?show=betting-streak`}
|
||||||
>
|
>
|
||||||
{bettingStreakText}
|
{bettingStreakText}
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
@ -736,6 +735,24 @@ function NotificationItem(props: {
|
||||||
justSummary={justSummary}
|
justSummary={justSummary}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (sourceType === 'badge') {
|
||||||
|
return (
|
||||||
|
<BadgeNotification
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
justSummary={justSummary}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||||
|
return (
|
||||||
|
<MarketClosedNotification
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
justSummary={justSummary}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// TODO Add new notification components here
|
// TODO Add new notification components here
|
||||||
|
|
||||||
|
@ -809,9 +826,18 @@ function NotificationFrame(props: {
|
||||||
subtitle: string
|
subtitle: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
isChildOfGroup?: boolean
|
isChildOfGroup?: boolean
|
||||||
|
hideUserName?: boolean
|
||||||
|
hideLinkToGroupOrQuestion?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { notification, isChildOfGroup, highlighted, subtitle, children } =
|
const {
|
||||||
props
|
notification,
|
||||||
|
isChildOfGroup,
|
||||||
|
highlighted,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
hideUserName,
|
||||||
|
hideLinkToGroupOrQuestion,
|
||||||
|
} = props
|
||||||
const {
|
const {
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceUserName,
|
sourceUserName,
|
||||||
|
@ -822,7 +848,7 @@ function NotificationFrame(props: {
|
||||||
sourceUserUsername,
|
sourceUserUsername,
|
||||||
sourceText,
|
sourceText,
|
||||||
} = notification
|
} = notification
|
||||||
const questionNeedsResolution = sourceUpdateType == 'closed'
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const isMobile = (width ?? 0) < 600
|
const isMobile = (width ?? 0) < 600
|
||||||
return (
|
return (
|
||||||
|
@ -852,16 +878,10 @@ function NotificationFrame(props: {
|
||||||
/>
|
/>
|
||||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||||
<Avatar
|
<Avatar
|
||||||
avatarUrl={
|
avatarUrl={sourceUserAvatarUrl}
|
||||||
questionNeedsResolution
|
|
||||||
? MANIFOLD_AVATAR_URL
|
|
||||||
: sourceUserAvatarUrl
|
|
||||||
}
|
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
className={'z-10 mr-2'}
|
className={'z-10 mr-2'}
|
||||||
username={
|
username={sourceUserUsername}
|
||||||
questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className={'flex w-full flex-row pl-1 sm:pl-0'}>
|
<div className={'flex w-full flex-row pl-1 sm:pl-0'}>
|
||||||
<div
|
<div
|
||||||
|
@ -870,17 +890,21 @@ function NotificationFrame(props: {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
{!hideUserName && (
|
||||||
<UserLink
|
<UserLink
|
||||||
name={sourceUserName || ''}
|
name={sourceUserName || ''}
|
||||||
username={sourceUserUsername || ''}
|
username={sourceUserUsername || ''}
|
||||||
className={'relative mr-1 flex-shrink-0'}
|
className={'relative mr-1 flex-shrink-0'}
|
||||||
short={isMobile}
|
short={isMobile}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{subtitle}
|
{subtitle}
|
||||||
{isChildOfGroup ? (
|
{isChildOfGroup ? (
|
||||||
<RelativeTimestamp time={notification.createdTime} />
|
<RelativeTimestamp time={notification.createdTime} />
|
||||||
) : (
|
) : (
|
||||||
|
!hideLinkToGroupOrQuestion && (
|
||||||
<QuestionOrGroupLink notification={notification} />
|
<QuestionOrGroupLink notification={notification} />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -964,6 +988,66 @@ function BetFillNotification(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarketClosedNotification(props: {
|
||||||
|
notification: Notification
|
||||||
|
highlighted: boolean
|
||||||
|
justSummary: boolean
|
||||||
|
isChildOfGroup?: boolean
|
||||||
|
}) {
|
||||||
|
const { notification, isChildOfGroup, highlighted } = props
|
||||||
|
notification.sourceUserAvatarUrl = MANIFOLD_AVATAR_URL
|
||||||
|
return (
|
||||||
|
<NotificationFrame
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
subtitle={'Please resolve'}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<span>
|
||||||
|
{`Your market has closed. Please resolve it to pay out ${PAST_BETS}.`}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeNotification(props: {
|
||||||
|
notification: Notification
|
||||||
|
highlighted: boolean
|
||||||
|
justSummary: boolean
|
||||||
|
isChildOfGroup?: boolean
|
||||||
|
}) {
|
||||||
|
const { notification, isChildOfGroup, highlighted, justSummary } = props
|
||||||
|
const { sourceText } = notification
|
||||||
|
const subtitle = 'You earned a new badge!'
|
||||||
|
notification.sourceUserAvatarUrl = '/award.svg'
|
||||||
|
if (justSummary) {
|
||||||
|
return (
|
||||||
|
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
|
||||||
|
<Row className={'line-clamp-1'}>
|
||||||
|
<span>{sourceText} 🎉</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationSummaryFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationFrame
|
||||||
|
notification={notification}
|
||||||
|
isChildOfGroup={isChildOfGroup}
|
||||||
|
highlighted={highlighted}
|
||||||
|
subtitle={subtitle}
|
||||||
|
hideUserName={true}
|
||||||
|
hideLinkToGroupOrQuestion={true}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<span>{sourceText} 🎉</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ContractResolvedNotification(props: {
|
function ContractResolvedNotification(props: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
highlighted: boolean
|
highlighted: boolean
|
||||||
|
@ -1134,6 +1218,11 @@ function getSourceUrl(notification: Notification) {
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
sourceType
|
sourceType
|
||||||
)}`
|
)}`
|
||||||
|
else if (sourceSlug)
|
||||||
|
return `/${sourceSlug}#${getSourceIdForLinkComponent(
|
||||||
|
sourceId ?? '',
|
||||||
|
sourceType
|
||||||
|
)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceIdForLinkComponent(
|
function getSourceIdForLinkComponent(
|
||||||
|
@ -1233,7 +1322,6 @@ function getReasonForShowingNotification(
|
||||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||||
else if (sourceUpdateType === 'resolved')
|
else if (sourceUpdateType === 'resolved')
|
||||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||||
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
|
|
||||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||||
break
|
break
|
||||||
case 'answer':
|
case 'answer':
|
||||||
|
|
|
@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user'
|
||||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
|
||||||
import { ConfirmationButton } from 'web/components/confirmation-button'
|
import { ConfirmationButton } from 'web/components/confirmation-button'
|
||||||
|
import { ExpandingInput } from 'web/components/expanding-input'
|
||||||
|
import { Input } from 'web/components/input'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
|
@ -27,7 +28,7 @@ export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||||
|
|
||||||
function EditUserField(props: {
|
function EditUserField(props: {
|
||||||
user: User
|
user: User
|
||||||
field: 'bio' | 'website' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
|
field: 'bio' | 'website' | 'twitterHandle' | 'discordHandle'
|
||||||
label: string
|
label: string
|
||||||
}) {
|
}) {
|
||||||
const { user, field, label } = props
|
const { user, field, label } = props
|
||||||
|
@ -43,16 +44,15 @@ function EditUserField(props: {
|
||||||
<label className="label">{label}</label>
|
<label className="label">{label}</label>
|
||||||
|
|
||||||
{field === 'bio' ? (
|
{field === 'bio' ? (
|
||||||
<Textarea
|
<ExpandingInput
|
||||||
className="textarea textarea-bordered w-full resize-none"
|
className="w-full"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onBlur={updateField}
|
onBlur={updateField}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered"
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value || '')}
|
onChange={(e) => setValue(e.target.value || '')}
|
||||||
onBlur={updateField}
|
onBlur={updateField}
|
||||||
|
@ -152,10 +152,9 @@ export default function ProfilePage(props: {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Display name</label>
|
<label className="label">Display name</label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Display name"
|
placeholder="Display name"
|
||||||
className="input input-bordered"
|
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value || '')}
|
onChange={(e) => setName(e.target.value || '')}
|
||||||
onBlur={updateDisplayName}
|
onBlur={updateDisplayName}
|
||||||
|
@ -164,10 +163,9 @@ export default function ProfilePage(props: {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Username</label>
|
<label className="label">Username</label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
className="input input-bordered"
|
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value || '')}
|
onChange={(e) => setUsername(e.target.value || '')}
|
||||||
onBlur={updateUsername}
|
onBlur={updateUsername}
|
||||||
|
@ -199,10 +197,9 @@ export default function ProfilePage(props: {
|
||||||
<div>
|
<div>
|
||||||
<label className="label">API key</label>
|
<label className="label">API key</label>
|
||||||
<div className="input-group w-full">
|
<div className="input-group w-full">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Click refresh to generate key"
|
placeholder="Click refresh to generate key"
|
||||||
className="input input-bordered w-full"
|
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { InfoBox } from 'web/components/info-box'
|
import { InfoBox } from 'web/components/info-box'
|
||||||
import { QRCode } from 'web/components/qr-code'
|
import { QRCode } from 'web/components/qr-code'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||||
|
|
||||||
|
@ -23,15 +24,17 @@ export default function ReferralsPage() {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<SEO
|
<SEO
|
||||||
title="Referrals"
|
title="Refer a friend"
|
||||||
description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
|
description={`Invite new users to Manifold and get ${formatMoney(
|
||||||
|
REFERRAL_AMOUNT
|
||||||
|
)} if they
|
||||||
sign up!`}
|
sign up!`}
|
||||||
url="/referrals"
|
url="/referrals"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Col className="items-center">
|
<Col className="items-center">
|
||||||
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
|
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
|
||||||
<Title className="!mt-0" text="Referrals" />
|
<Title className="!mt-0" text="Refer a friend" />
|
||||||
<img
|
<img
|
||||||
className="mb-6 block -scale-x-100 self-center"
|
className="mb-6 block -scale-x-100 self-center"
|
||||||
src="/logo-flapping-with-money.gif"
|
src="/logo-flapping-with-money.gif"
|
||||||
|
@ -40,8 +43,8 @@ export default function ReferralsPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={'mb-4'}>
|
<div className={'mb-4'}>
|
||||||
Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
|
Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '}
|
||||||
sign up!
|
if they sign up!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CopyLinkButton
|
<CopyLinkButton
|
||||||
|
|
28
web/public/award.svg
Normal file
28
web/public/award.svg
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="color">
|
||||||
|
<polyline fill="#92d3f5" stroke="#92d3f5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="54.9988,4.0221 43,16.0208 36,16.0208 30.9584,10.9792 37.9207,4.0169 54.9988,4.0169"/>
|
||||||
|
<polyline fill="#ea5a47" stroke="#ea5a47" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="23.9831,4.0039 36,16.0208 29,16.0208 16.9675,3.9883 23.9831,3.9883"/>
|
||||||
|
<polyline fill="#fcea2b" stroke="none" points="28,22.4271 28,17 44,17 44,22.4271"/>
|
||||||
|
<circle cx="36" cy="45.0208" r="23" fill="#fcea2b" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<polygon fill="#f1b31c" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/>
|
||||||
|
</g>
|
||||||
|
<g id="hair"/>
|
||||||
|
<g id="skin"/>
|
||||||
|
<g id="skin-shadow"/>
|
||||||
|
<g id="line">
|
||||||
|
<circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="29" x2="29" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="43" x2="43" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="29" x2="43" y1="16.0208" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="25.9896" x2="16.9675" y1="13.0104" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="31.9896" x2="23.9831" y1="12.0104" y2="4.0039" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="34" x2="37.9207" y1="8" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="46" x2="54.9988" y1="13" y2="4.0221" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="16.9675" x2="23.9831" y1="3.9883" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<line x1="37.9207" x2="54.9988" y1="4.0169" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
|
<polygon fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
Loading…
Reference in New Issue
Block a user