Award badges for market creation, betting streaks, proven correct (#891)
* Award badges for market creation, betting streaks, proven correct * Styling * Add minimum unique bettors for proven correct * Add name, refactor * Add notifications for badge awards * Correct styling * Need at least 3 unique bettors for market maker badge * Lint * Switch to badges_awarded * Don't include n/a resolutions in market creator badge * Add badges by rarities to profile * Show badges on profile, soon on market page * Add achievements to new user * Backfill all users badges
This commit is contained in:
parent
cdc64c6475
commit
f26ba1c4a2
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
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -51,6 +52,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 +94,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.
|
||||||
|
|
|
@ -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,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
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
|
@ -1,29 +1,24 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import { filterDefined } from 'common/lib/util/array'
|
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||||
import { getPrivateUser } from '../utils'
|
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// const privateUsers = await getAllPrivateUsers()
|
const privateUsers = await getAllPrivateUsers()
|
||||||
const privateUsers = filterDefined([
|
|
||||||
await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'),
|
|
||||||
])
|
|
||||||
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.opt_out_all === undefined) {
|
if (privateUser.notificationPreferences.badges_awarded === undefined) {
|
||||||
console.log('updating opt out all', privateUser.id)
|
|
||||||
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'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
101
functions/src/scripts/backfill-badges.ts
Normal file
101
functions/src/scripts/backfill-badges.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
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) => {
|
||||||
|
// console.log('Added achievements to user', user.id)
|
||||||
|
if (!user.id) return
|
||||||
|
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)
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
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 { NextRouter } from 'next/router'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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
|
||||||
|
router?: NextRouter
|
||||||
|
}) {
|
||||||
|
const { user, router } = props
|
||||||
|
const [showBadgesModal, setShowBadgesModal] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router) return
|
||||||
|
const showBadgesModal = router.query['show'] === 'badges'
|
||||||
|
setShowBadgesModal(showBadgesModal)
|
||||||
|
}, [router])
|
||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -154,8 +154,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 (
|
||||||
|
@ -178,12 +178,15 @@ export function MarketSubheader(props: {
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
creatorName
|
creatorName
|
||||||
) : (
|
) : (
|
||||||
<UserLink
|
<Row className={'gap-2'}>
|
||||||
className="my-auto whitespace-nowrap"
|
<UserLink
|
||||||
name={creatorName}
|
className="my-auto whitespace-nowrap"
|
||||||
username={creatorUsername}
|
name={creatorName}
|
||||||
short={isMobile}
|
username={creatorUsername}
|
||||||
/>
|
short={isMobile}
|
||||||
|
/>
|
||||||
|
{/*<BadgeDisplay user={creator} />*/}
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
{correctResolutionPercentage != null &&
|
{correctResolutionPercentage != null &&
|
||||||
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
|
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -131,6 +131,7 @@ export function NotificationSettings(props: {
|
||||||
'betting_streaks',
|
'betting_streaks',
|
||||||
'referral_bonuses',
|
'referral_bonuses',
|
||||||
'unique_bettors_on_your_contract',
|
'unique_bettors_on_your_contract',
|
||||||
|
'badges_awarded',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const otherBalances: SectionData = {
|
const otherBalances: SectionData = {
|
||||||
|
|
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)
|
||||||
|
|
|
@ -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
|
||||||
|
@ -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} router={router} />
|
||||||
|
</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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -739,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
|
||||||
|
|
||||||
|
@ -812,9 +826,16 @@ function NotificationFrame(props: {
|
||||||
subtitle: string
|
subtitle: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
isChildOfGroup?: boolean
|
isChildOfGroup?: boolean
|
||||||
|
showUserName?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { notification, isChildOfGroup, highlighted, subtitle, children } =
|
const {
|
||||||
props
|
notification,
|
||||||
|
isChildOfGroup,
|
||||||
|
highlighted,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
showUserName,
|
||||||
|
} = props
|
||||||
const {
|
const {
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceUserName,
|
sourceUserName,
|
||||||
|
@ -825,7 +846,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 (
|
||||||
|
@ -855,16 +876,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
|
||||||
|
@ -873,12 +888,14 @@ function NotificationFrame(props: {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<UserLink
|
{showUserName && (
|
||||||
name={sourceUserName || ''}
|
<UserLink
|
||||||
username={sourceUserUsername || ''}
|
name={sourceUserName || ''}
|
||||||
className={'relative mr-1 flex-shrink-0'}
|
username={sourceUserUsername || ''}
|
||||||
short={isMobile}
|
className={'relative mr-1 flex-shrink-0'}
|
||||||
/>
|
short={isMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{subtitle}
|
{subtitle}
|
||||||
{isChildOfGroup ? (
|
{isChildOfGroup ? (
|
||||||
<RelativeTimestamp time={notification.createdTime} />
|
<RelativeTimestamp time={notification.createdTime} />
|
||||||
|
@ -967,6 +984,65 @@ 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}
|
||||||
|
showUserName={false}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<span>{sourceText} 🎉</span>
|
||||||
|
</Row>
|
||||||
|
</NotificationFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ContractResolvedNotification(props: {
|
function ContractResolvedNotification(props: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
highlighted: boolean
|
highlighted: boolean
|
||||||
|
@ -1137,6 +1213,11 @@ function getSourceUrl(notification: Notification) {
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
sourceType
|
sourceType
|
||||||
)}`
|
)}`
|
||||||
|
else if (sourceSlug)
|
||||||
|
return `/${sourceSlug}#${getSourceIdForLinkComponent(
|
||||||
|
sourceId ?? '',
|
||||||
|
sourceType
|
||||||
|
)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceIdForLinkComponent(
|
function getSourceIdForLinkComponent(
|
||||||
|
@ -1236,7 +1317,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':
|
||||||
|
|
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