Merge branch 'main' into make-liquidity-great-again

This commit is contained in:
mantikoros 2022-10-10 18:36:48 -05:00
commit ca03245d91
63 changed files with 1421 additions and 418 deletions

123
common/badge.ts Normal file
View 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
}

View File

@ -1,11 +1,17 @@
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
import { calculatePayout } from './calculate'
import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet, LimitBet } from './bet'
import { Contract, CPMMContract, DPMContract } from './contract'
import {
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = (
bets: Bet[],
@ -35,8 +41,7 @@ export const computeInvestmentValueCustomProb = (
const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares
const value = payout - (bet.loanAmount ?? 0)
const value = betP * shares
if (isNaN(value)) return 0
return value
})
@ -194,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
startingPortfolio: PortfolioMetrics | undefined,
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
@ -216,33 +216,88 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
portfolioHistory: Record<
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
allTime: allTimeProfit,
}
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,
}
}

View File

@ -215,7 +215,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
}
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 hasShares = Object.values(totalShares).some(

View File

@ -63,6 +63,7 @@ export function getNewContract(
tags: [],
lowercaseTags: [],
visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false,
createdTime: Date.now(),
closeTime,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string
userId: string
reasonText?: string
reason?: notification_reason_types
reason?: notification_reason_types | notification_preference
createdTime: number
viewTime?: number
isSeen: boolean
@ -46,6 +46,7 @@ export type notification_source_types =
| 'loan'
| 'like'
| 'tip_and_like'
| 'badge'
export type notification_source_update_types =
| 'created'
@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets 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: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:

View File

@ -1,8 +1,9 @@
import { groupBy, sumBy, mapValues } from 'lodash'
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import { getContractBetMetrics, resolvedPayout } from './calculate'
import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
}
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, bet => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
const betsByUser = groupBy(bets, (bet) => bet.userId)
return mapValues(
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
}
export function addUserScores(
@ -43,3 +47,47 @@ export function addUserScores(
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,
}
}

View File

@ -53,7 +53,7 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[]
// 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),
opt_out_all: [],
badges_awarded: constructPref(true, false),
}
return defaults
}
@ -178,31 +179,44 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference
) => {
const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
try {
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${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: '',
}
}
}

View File

@ -1,5 +1,6 @@
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 = {
id: string
@ -11,7 +12,6 @@ export type User = {
// For their user page
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
@ -51,6 +51,18 @@ export type User = {
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
}
export type PrivateUser = {
@ -81,7 +93,8 @@ export type PortfolioMetrics = {
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'
// TODO: remove. Hardcoding the strings would be better.

View File

@ -72,7 +72,6 @@ export const exhibitExts = [
Image,
Link,
Mention,
Mention.extend({ name: 'contract-mention' }),
Iframe,
TiptapTweet,

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& 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
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()

3
functions/.env.dev Normal file
View File

@ -0,0 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=DEV

View File

@ -5,7 +5,7 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"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",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",

View File

@ -6,7 +6,12 @@ import {
Notification,
notification_reason_types,
} 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 { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
@ -30,6 +35,7 @@ import {
import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore()
type recipients_to_reason_texts = {
@ -1087,6 +1093,43 @@ export const createBountyNotification = async (
sourceTitle: contract.question,
}
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
}

View File

@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
achievements: {},
}
await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
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 { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser,
sendTime: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User,
privateUser: PrivateUser
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
if (!privateUser || !privateUser.email) return
const { name } = user
const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'thank_you_for_purchases'
)
if (!sendToEmail) return
return await sendTemplateEmail(
privateUser.email,
'Thanks for your Manifold purchase',
@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[],
deliveryTime?: string
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'trending_markets'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData
) => {
if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl } = getNotificationDestinationsForUser(
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'profit_loss_updates'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update',
templateData
)
log('Sent portfolio update email to', privateUser.email)
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet'
export * from './on-create-comment-on-contract'
export * from './on-view'
export { updateMetrics } from './update-metrics'
export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'

View File

@ -12,6 +12,7 @@ import {
revalidateStaticProps,
} from './utils'
import {
createBadgeAwardedNotification,
createBetFillNotification,
createBettingStreakBonusNotification,
createUniqueBettorBonusNotification,
@ -35,6 +36,10 @@ import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -145,7 +150,7 @@ const updateBettingStreak = async (
log('message:', result.message)
return
}
if (result.txn)
if (result.txn) {
await createBettingStreakBonusNotification(
user,
result.txn.id,
@ -155,6 +160,8 @@ const updateBettingStreak = async (
newBettingStreak,
eventId
)
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -307,3 +314,39 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => {
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)
}
}

View File

@ -1,11 +1,20 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNewContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
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
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -28,4 +37,43 @@ export const onCreateContract = functions
richTextToString(desc),
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)
}
}

View File

@ -1,9 +1,19 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { getUser, getValues } from './utils'
import {
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract'
import { GroupContractDoc } from '../../common/group'
import { Bet } from '../../common/bet'
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
.document('contracts/{contractId}')
@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore
if (!previousContract.isResolved && contract.isResolved) {
// No need to notify users of resolution, that's handled in resolve-market
return
return await handleResolvedContract(contract)
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract)
} 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(
previousContract: Contract,
contract: Contract,

View File

@ -11,15 +11,18 @@ async function main() {
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
opt_out_all: [],
},
})
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
badges_awarded: ['browser'],
},
})
}
return
})
)
}

View 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
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
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'
@ -15,6 +15,7 @@ import {
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume,
} from '../../common/calculate-metrics'
@ -23,13 +24,15 @@ import { Group } from '../../common/group'
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()
export const updateMetrics = functions.pubsub
export const scheduleUpdateMetrics = functions.pubsub
.schedule('every 15 minutes')
.onRun(async () => {
const response = await fetch(getFunctionUrl('updatemetrics'), {
const url = getFunctionUrl('updatemetrics')
console.log('Scheduling update metrics', url)
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
@ -59,11 +62,7 @@ export async function updateMetricsCore() {
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
const userPortfolioHistory = await loadPortfolioHistory(users)
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
@ -140,11 +139,10 @@ export async function updateMetricsCore() {
)
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const portfolioHistory = userPortfolioHistory[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics(
@ -152,14 +150,20 @@ export async function updateMetricsCore() {
contractsById,
currentBets
)
const lastPortfolio = last(portfolioHistory)
const currPortfolio = portfolioHistory.current
const didPortfolioChange =
lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
currPortfolio === undefined ||
currPortfolio.balance !== newPortfolio.balance ||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
currPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const metricsByContract = calculateMetricsByContract(
currentBets,
contractsById
)
const contractRatios = userContracts
.map((contract) => {
if (
@ -190,6 +194,7 @@ export async function updateMetricsCore() {
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
metricsByContract,
}
})
@ -205,63 +210,61 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}) => {
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: didPortfolioChange ? newPortfolio : {},
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
}
}
)
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
await writeAsync(firestore, userUpdates)
const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: newPortfolio,
}
: null
})
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'set'
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, contractMetricsUpdates, 'set')
log(`Updated metrics for ${users.length} users.`)
try {
const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = groupContractIds
.map((e) => contractsById[e.contractId])
.filter((e) => e !== undefined) as Contract[]
const bets = groupContracts.map((e) => {
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const groupContracts = filterDefined(
groupContractIds.map((e) => contractsById[e.contractId])
)
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets)
@ -295,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
type GroupContractDoc = { contractId: string; createdTime: number }
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)
}

View File

@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
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) => {
const firestore = admin.firestore()
const snap = await firestore

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
)
)
)
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id)
// 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 contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit)
).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0
) {
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({
weeklyPortfolioUpdateEmailSent: true,
})
return
return await setEmailFlagAsSent(privateUser.id)
}
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
await sendWeeklyPortfolioUpdateEmail(
user,
privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
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 = {
questionTitle: string
questionUrl: string

View File

@ -6,6 +6,7 @@ import { Col } from './layout/col'
import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
export function AmountInput(props: {
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">
{label}
</span>
<input
<Input
className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
'pl-9',
error && 'input-error',
'w-24 md:w-auto',
inputClassName

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: {
answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered w-24 justify-self-end text-2xl"
<Input
className="w-24 justify-self-end !text-2xl"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash'
import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
<ExpandingInput
value={text}
onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
className="w-full"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: {
answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '}
<Textarea
<ExpandingInput
value={answer}
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..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}

View 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>
)
}

View File

@ -20,11 +20,11 @@ import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = {
amount: number
@ -153,9 +153,9 @@ function CreateChallengeForm(props: {
{contract ? (
<span className="underline">{contract.question}</span>
) : (
<Textarea
<ExpandingInput
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}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}

View File

@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button'
import { Modal } from './layout/modal'
import { Title } from './title'
import { Input } from './input'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
@ -438,13 +439,13 @@ function ContractSearchControls(props: {
return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2">
<input
<Input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query: query })}
placeholder={'Search'}
className="input input-bordered w-full"
placeholder="Search"
className="w-full"
autoFocus={autoFocus}
/>
{!isMobile && !query && (

View File

@ -1,8 +1,6 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin'
@ -15,6 +13,7 @@ import { Button } from '../button'
import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react'
import { insertContent } from '../editor/utils'
import { ExpandingInput } from '../expanding-input'
export function ContractDescription(props: {
contract: Contract
@ -138,8 +137,8 @@ function EditQuestion(props: {
return editing ? (
<div className="mt-4">
<Textarea
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
<ExpandingInput
className="mb-1 h-24 w-full"
rows={2}
value={text}
onChange={(e) => setText(e.target.value || '')}

View File

@ -40,6 +40,7 @@ import {
BountiedContractBadge,
BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge'
import { Input } from '../input'
export type ShowTime = 'resolve-date' | 'close-date'
@ -153,8 +154,8 @@ export function MarketSubheader(props: {
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract)
const user = useUser()
const correctResolutionPercentage =
useUserById(creatorId)?.fractionResolvedCorrectly
const creator = useUserById(creatorId)
const correctResolutionPercentage = creator?.fractionResolvedCorrectly
const isCreator = user?.id === creatorId
const isMobile = useIsMobile()
return (
@ -177,12 +178,14 @@ export function MarketSubheader(props: {
{disabled ? (
creatorName
) : (
<UserLink
className="my-auto whitespace-nowrap"
name={creatorName}
username={creatorUsername}
short={isMobile}
/>
<Row className={'gap-2'}>
<UserLink
className="my-auto whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
{/*<BadgeDisplay user={creator} />*/}
</Row>
)}
{correctResolutionPercentage != null &&
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
@ -445,17 +448,17 @@ function EditableCloseDate(props: {
<Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit market close time" />
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
<input
<Input
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()}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
value={closeDate}
/>
<input
<Input
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()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"

View File

@ -2,15 +2,17 @@ import { Bet } from 'common/bet'
import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { memo } from 'react'
import { useComments } from 'web/hooks/use-comments'
import { groupBy, mapValues, sumBy } from 'lodash'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer'
import { Leaderboard } from '../leaderboard'
import { Title } from '../title'
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: {
contract: Contract
@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
) : null
})
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
// todo: this stuff should be calced in DB at resolve time
const comments = useComments(contract.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 comment with the highest profit
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
}) {
const { contract, bets, comments } = props
const {
topBetId,
topBettor,
profitById,
betsById,
topCommentId,
commentsById,
topCommentBetId,
} = scoreCommentorsAndBettors(contract, bets, comments)
return (
<div className="mt-12 max-w-sm">
{topComment && profitById[topComment.id] > 0 && (
{topCommentBetId && profitById[topCommentBetId] > 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">
<FeedComment contract={contract} comment={topComment} />
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
/>
</div>
<Spacer h={16} />
</>
)}
{/* 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" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">

View File

@ -1,7 +1,6 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
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 { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group'
import { ExpandingInput } from './expanding-input'
export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('')
@ -60,9 +60,8 @@ export function CreatePost(props: { group?: Group }) {
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
<ExpandingInput
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
@ -74,9 +73,8 @@ export function CreatePost(props: { group?: Group }) {
Subtitle<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
<ExpandingInput
placeholder="e.g. How Elon Musk is getting everyone's attention"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={subtitle}

View 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}
/>
)
}

View File

@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { searchInAny } from 'common/util/parse'
import { UserLink } from 'web/components/user-link'
import { Input } from './input'
export function FilterSelectUsers(props: {
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">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
<Input
type="text"
name="user name"
id="user name"
value={query}
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"
/>
</div>

View File

@ -8,6 +8,7 @@ import { Title } from '../title'
import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api'
import { Input } from '../input'
export function CreateGroupButton(props: {
user: User
@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
<div className="form-control w-full">
<label className="mb-2 ml-1 mt-0">Group name</label>
<input
<Input
placeholder={'Your group name'}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}

View File

@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
import { useMemberIds } from 'web/hooks/use-group'
import { Input } from '../input'
export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props
@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<span className="mb-1">Group name</span>
</label>
<input
<Input
placeholder="Your group name"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
onChange={(e) => setName(e.target.value || '')}

22
web/components/input.tsx Normal file
View 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
*/

View File

@ -7,12 +7,13 @@ import { User } from 'common/user'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea'
import dayjs from 'dayjs'
import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code'
import { Input } from '../input'
import { ExpandingInput } from '../expanding-input'
export function CreateLinksButton(props: {
user: User
@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$
</span>
<input
className="input input-bordered w-full pl-10"
<Input
className="w-full pl-10"
type="number"
min="1"
value={newManalink.amount}
@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
<div className="flex flex-col gap-2 md:flex-row">
<div className="form-control w-full md:w-1/2">
<label className="label">Uses</label>
<input
className="input input-bordered"
<Input
type="number"
min="1"
value={newManalink.maxUses ?? ''}
@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
return { ...m, maxUses: parseInt(e.target.value) }
})
}
></input>
/>
</div>
<div className="form-control w-full md:w-1/2">
<label className="label">Expires in</label>
@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
</div>
<div className="form-control w-full">
<label className="label">Message</label>
<Textarea
<ExpandingInput
placeholder={defaultMessage}
maxLength={200}
className="input input-bordered resize-none"
autoFocus
value={newManalink.message}
rows="3"

View File

@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -215,7 +215,7 @@ function getMoreMobileNav() {
return buildArray<MenuItem>(
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },

View File

@ -139,6 +139,7 @@ export function NotificationSettings(props: {
'loan_income',
'limit_order_fills',
'tips_on_your_comments',
'badges_awarded',
],
}
const userInteractions: SectionData = {

View File

@ -4,6 +4,7 @@ import { ReactNode } from 'react'
import React from 'react'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { Input } from './input'
export function NumberInput(props: {
numberString: string
@ -32,9 +33,9 @@ export function NumberInput(props: {
return (
<Col className={className}>
<label className="input-group">
<input
<Input
className={clsx(
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
'max-w-[200px] !text-lg',
error && 'input-error',
inputClassName
)}

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getPseudoProbability } from 'common/pseudo-numeric'
import { BucketInput } from './bucket-input'
import { Input } from './input'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
return (
<Col className={className}>
<label className="input-group">
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
inputClassName
)}
<Input
className={clsx('max-w-[200px] !text-lg', inputClassName)}
type="number"
max={99}
min={1}

View File

@ -1,3 +1,4 @@
import { Input } from './input'
import { Row } from './layout/row'
export function ProbabilitySelector(props: {
@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
return (
<Row className="items-center gap-2">
<label className="input-group input-group-lg text-lg">
<input
<Input
type="number"
value={probabilityInt}
className="input input-bordered input-md w-28 text-lg"
className="input-md w-28 !text-lg"
disabled={isSubmitting}
min={1}
max={99}

View 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>
)
}

View File

@ -13,7 +13,7 @@ import clsx from 'clsx'
export function BettingStreakModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
currentUser?: User | null
currentUser: User | null | undefined
}) {
const { isOpen, setOpen, currentUser } = props
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)

View File

@ -1,5 +1,6 @@
import { SiteLink } from 'web/components/site-link'
import clsx from 'clsx'
import { useWindowSize } from 'web/hooks/use-window-size'
export function shortenName(name: string) {
const firstName = name.split(' ')[0]
@ -24,10 +25,12 @@ export function UserLink(props: {
}) {
const { name, username, className, short, noLink } = props
const shortName = short ? shortenName(name) : name
const { width } = useWindowSize()
return (
<SiteLink
href={`/${username}`}
className={clsx(
(width ?? 0) < 450 ? ' max-w-[120px]' : 'max-w-[200px]',
'z-10 truncate',
className,
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() {
return (

View File

@ -31,14 +31,12 @@ import { UserFollowButton } from './follow-button'
import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { formatMoney } from 'common/util/format'
import {
BettingStreakModal,
hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal'
import { LoansModal } from './profile/loans-modal'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { DOMAIN } from 'common/envs/constants'
import { BadgeDisplay } from 'web/components/badge-display'
export function UserPage(props: { user: User }) {
const { user } = props
@ -52,8 +50,8 @@ export function UserPage(props: { user: User }) {
setShowConfetti(claimedMana)
const query = { ...router.query }
if (query.claimedMana || query.show) {
delete query['claimed-mana']
delete query['show']
const queriesToDelete = ['claimed-mana', 'show', 'badge']
queriesToDelete.forEach((key) => delete query[key])
router.replace(
{
pathname: router.pathname,
@ -79,6 +77,7 @@ export function UserPage(props: { user: User }) {
{showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
)}
<Col className="relative">
<Row className="relative px-4 pt-4">
<Avatar
@ -101,9 +100,10 @@ export function UserPage(props: { user: User }) {
<span className="break-anywhere text-lg font-bold sm:text-2xl">
{user.name}
</span>
<span className="sm:text-md text-greyscale-4 text-sm">
@{user.username}
</span>
<Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm ">
<span className={' text-greyscale-4'}>@{user.username}</span>
<BadgeDisplay user={user} query={router.query} />
</Row>
</Col>
{isCurrentUser && (
<ProfilePrivateStats
@ -278,14 +278,10 @@ export function ProfilePrivateStats(props: {
user: User
router: NextRouter
}) {
const { currentUser, profit, user, router } = props
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const { profit, user, router } = props
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => {
const showBettingStreak = router.query['show'] === 'betting-streak'
setShowBettingStreakModal(showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -301,23 +297,6 @@ export function ProfilePrivateStats(props: {
</span>
<span className="mx-auto text-xs sm:text-sm">profit</span>
</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
className={
'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>
</Col>
</Row>
{BettingStreakModal && (
<BettingStreakModal
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
currentUser={currentUser}
/>
)}
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
)}

View File

@ -270,7 +270,11 @@ export function ContractPageContent(
<>
<div className="grid grid-cols-1 sm:grid-cols-2">
<ContractLeaderboard contract={contract} bets={bets} />
<ContractTopTrades contract={contract} bets={bets} />
<ContractTopTrades
contract={contract}
bets={bets}
comments={comments}
/>
</div>
<Spacer h={12} />
</>

View File

@ -192,7 +192,6 @@ export type LiteUser = {
avatarUrl?: string
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
@ -223,7 +222,6 @@ export function toLiteUser(user: User): LiteUser {
username,
avatarUrl,
bio,
bannerUrl,
website,
twitterHandle,
discordHandle,
@ -241,7 +239,6 @@ export function toLiteUser(user: User): LiteUser {
url: `https://${ENV_CONFIG.domain}/${username}`,
avatarUrl,
bio,
bannerUrl,
website,
twitterHandle,
discordHandle,

View File

@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link'
import { User } from 'common/user'
import { SEO } from 'web/components/SEO'
import { Input } from 'web/components/input'
export async function getStaticProps() {
let txns = await getAllCharityTxns()
@ -171,11 +172,11 @@ export default function Charity(props: {
/>
<Spacer h={10} />
<input
<Input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Find a charity"
className="input input-bordered mb-6 w-full"
className="mb-6 w-full"
/>
</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">

View File

@ -9,6 +9,7 @@ import {
urlParamStore,
} from 'web/hooks/use-persistent-state'
import { PAST_BETS } from 'common/user'
import { Input } from 'web/components/input'
const MAX_CONTRACTS_RENDERED = 100
@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: {
<div>
{/* Show a search input next to a sort dropdown */}
<div className="mt-2 mb-8 flex justify-between gap-2">
<input
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search markets"
className="input input-bordered w-full"
className="w-full"
/>
<select
className="select select-bordered"

View File

@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import dayjs from 'dayjs'
import Textarea from 'react-expanding-textarea'
import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts'
@ -39,6 +38,8 @@ import { SiteLink } from 'web/components/site-link'
import { Button } from 'web/components/button'
import { AddFundsModal } from 'web/components/add-funds-modal'
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) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -104,9 +105,8 @@ export default function Create(props: { auth: { user: User } }) {
</span>
</label>
<Textarea
<ExpandingInput
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_QUESTION_LENGTH}
value={question}
@ -329,9 +329,9 @@ export function NewContract(props: {
</label>
<Row className="gap-2">
<input
<Input
type="number"
className="input input-bordered w-32"
className="w-32"
placeholder="LOW"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)}
@ -340,9 +340,9 @@ export function NewContract(props: {
disabled={isSubmitting}
value={minString ?? ''}
/>
<input
<Input
type="number"
className="input input-bordered w-32"
className="w-32"
placeholder="HIGH"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)}
@ -374,9 +374,8 @@ export function NewContract(props: {
</label>
<Row className="gap-2">
<input
<Input
type="number"
className="input input-bordered"
placeholder="Initial value"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setInitialValueString(e.target.value)}
@ -446,19 +445,17 @@ export function NewContract(props: {
className={'col-span-4 sm:col-span-2'}
/>
</Row>
<Row>
<input
<Row className="mt-4 gap-2">
<Input
type={'date'}
className="input input-bordered mt-4"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)}
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
disabled={isSubmitting}
value={closeDate}
/>
<input
<Input
type={'time'}
className="input input-bordered mt-4 ml-2"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min={'00:00'}

View File

@ -1,7 +1,5 @@
import Router from 'next/router'
import { useEffect, useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { DateDoc } from 'common/post'
import { useTextEditor, TextEditor } from 'web/components/editor'
import { Page } from 'web/components/page'
@ -17,6 +15,8 @@ import { MAX_QUESTION_LENGTH } from 'common/contract'
import { NoSEO } from 'web/components/NoSEO'
import ShortToggle from 'web/components/widgets/short-toggle'
import { removeUndefinedProps } from 'common/util/object'
import { Input } from 'web/components/input'
import { ExpandingInput } from 'web/components/expanding-input'
export default function CreateDateDocPage() {
const user = useUser()
@ -94,9 +94,8 @@ export default function CreateDateDocPage() {
<Col className="gap-8">
<Col className="max-w-[160px] justify-start gap-4">
<div className="">Birthday</div>
<input
<Input
type={'date'}
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setBirthday(e.target.value)}
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
@ -122,8 +121,7 @@ export default function CreateDateDocPage() {
</Row>
<Col className="gap-2">
<Textarea
className="input input-bordered resize-none"
<ExpandingInput
maxLength={MAX_QUESTION_LENGTH}
value={question}
onChange={(e) => setQuestion(e.target.value || '')}

View File

@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO'
import { GetServerSideProps } from 'next'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useUser } from 'web/hooks/use-user'
import { Input } from 'web/components/input'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
@ -106,12 +107,12 @@ export default function Groups(props: {
title: 'All',
content: (
<Col>
<input
<Input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
value={query}
className="input input-bordered mb-4 w-full"
className="mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
@ -134,12 +135,12 @@ export default function Groups(props: {
title: 'My Groups',
content: (
<Col>
<input
<Input
type="text"
value={query}
onChange={(e) => debouncedQuery(e.target.value)}
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">

View File

@ -48,6 +48,7 @@ import {
} from 'web/hooks/use-contracts'
import { ProfitBadge } from 'web/components/profit-badge'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Input } from 'web/components/input'
export default function Home() {
const user = useUser()
@ -99,10 +100,10 @@ export default function Home() {
<Row
className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
>
<input
<Input
type="text"
placeholder={'Search'}
className="input input-bordered w-full"
className="w-full"
onClick={() => Router.push('/search')}
/>
<CustomizeButton justIcon />

View File

@ -13,11 +13,7 @@ import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { doc, updateDoc } from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
} from 'common/user'
import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user'
import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify'
@ -739,6 +735,24 @@ function NotificationItem(props: {
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
@ -812,9 +826,18 @@ function NotificationFrame(props: {
subtitle: string
children: React.ReactNode
isChildOfGroup?: boolean
hideUserName?: boolean
hideLinkToGroupOrQuestion?: boolean
}) {
const { notification, isChildOfGroup, highlighted, subtitle, children } =
props
const {
notification,
isChildOfGroup,
highlighted,
subtitle,
children,
hideUserName,
hideLinkToGroupOrQuestion,
} = props
const {
sourceType,
sourceUserName,
@ -825,7 +848,7 @@ function NotificationFrame(props: {
sourceUserUsername,
sourceText,
} = notification
const questionNeedsResolution = sourceUpdateType == 'closed'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
return (
@ -855,16 +878,10 @@ function NotificationFrame(props: {
/>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar
avatarUrl={
questionNeedsResolution
? MANIFOLD_AVATAR_URL
: sourceUserAvatarUrl
}
avatarUrl={sourceUserAvatarUrl}
size={'sm'}
className={'z-10 mr-2'}
username={
questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername
}
username={sourceUserUsername}
/>
<div className={'flex w-full flex-row pl-1 sm:pl-0'}>
<div
@ -873,17 +890,21 @@ function NotificationFrame(props: {
}
>
<div>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'}
short={isMobile}
/>
{!hideUserName && (
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'}
short={isMobile}
/>
)}
{subtitle}
{isChildOfGroup ? (
<RelativeTimestamp time={notification.createdTime} />
) : (
<QuestionOrGroupLink notification={notification} />
!hideLinkToGroupOrQuestion && (
<QuestionOrGroupLink notification={notification} />
)
)}
</div>
</div>
@ -967,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: {
notification: Notification
highlighted: boolean
@ -1137,6 +1218,11 @@ function getSourceUrl(notification: Notification) {
sourceId ?? '',
sourceType
)}`
else if (sourceSlug)
return `/${sourceSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
sourceType
)}`
}
function getSourceIdForLinkComponent(
@ -1236,7 +1322,6 @@ function getReasonForShowingNotification(
reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved`
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
else reasonText = justSummary ? 'updated the question' : `updated`
break
case 'answer':

View File

@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import Link from 'next/link'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
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 { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
@ -27,7 +28,7 @@ export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
function EditUserField(props: {
user: User
field: 'bio' | 'website' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
field: 'bio' | 'website' | 'twitterHandle' | 'discordHandle'
label: string
}) {
const { user, field, label } = props
@ -43,16 +44,15 @@ function EditUserField(props: {
<label className="label">{label}</label>
{field === 'bio' ? (
<Textarea
className="textarea textarea-bordered w-full resize-none"
<ExpandingInput
className="w-full"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={updateField}
/>
) : (
<input
<Input
type="text"
className="input input-bordered"
value={value}
onChange={(e) => setValue(e.target.value || '')}
onBlur={updateField}
@ -152,10 +152,9 @@ export default function ProfilePage(props: {
<div>
<label className="label">Display name</label>
<input
<Input
type="text"
placeholder="Display name"
className="input input-bordered"
value={name}
onChange={(e) => setName(e.target.value || '')}
onBlur={updateDisplayName}
@ -164,10 +163,9 @@ export default function ProfilePage(props: {
<div>
<label className="label">Username</label>
<input
<Input
type="text"
placeholder="Username"
className="input input-bordered"
value={username}
onChange={(e) => setUsername(e.target.value || '')}
onBlur={updateUsername}
@ -199,10 +197,9 @@ export default function ProfilePage(props: {
<div>
<label className="label">API key</label>
<div className="input-group w-full">
<input
<Input
type="text"
placeholder="Click refresh to generate key"
className="input input-bordered w-full"
value={apiKey}
readOnly
/>

View File

@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code'
import { REFERRAL_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
export const getServerSideProps = redirectIfLoggedOut('/')
@ -23,15 +24,17 @@ export default function ReferralsPage() {
return (
<Page>
<SEO
title="Referrals"
description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
title="Refer a friend"
description={`Invite new users to Manifold and get ${formatMoney(
REFERRAL_AMOUNT
)} if they
sign up!`}
url="/referrals"
/>
<Col className="items-center">
<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
className="mb-6 block -scale-x-100 self-center"
src="/logo-flapping-with-money.gif"
@ -40,8 +43,8 @@ export default function ReferralsPage() {
/>
<div className={'mb-4'}>
Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
sign up!
Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '}
if they sign up!
</div>
<CopyLinkButton

28
web/public/award.svg Normal file
View 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