From f26ba1c4a292a1383fc2ca9742d6e39171161507 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 10 Oct 2022 14:32:29 -0600 Subject: [PATCH] Award badges for market creation, betting streaks, proven correct (#891) * Award badges for market creation, betting streaks, proven correct * Styling * Add minimum unique bettors for proven correct * Add name, refactor * Add notifications for badge awards * Correct styling * Need at least 3 unique bettors for market maker badge * Lint * Switch to badges_awarded * Don't include n/a resolutions in market creator badge * Add badges by rarities to profile * Show badges on profile, soon on market page * Add achievements to new user * Backfill all users badges --- common/badge.ts | 123 ++++++++++ common/notification.ts | 7 +- common/scoring.ts | 56 ++++- common/user-notification-preferences.ts | 3 +- common/user.ts | 18 +- functions/src/create-notification.ts | 49 +++- functions/src/create-user.ts | 1 + functions/src/on-create-bet.ts | 45 +++- functions/src/on-create-contract.ts | 52 +++- functions/src/on-update-contract.ts | 75 +++++- .../add-new-notification-preference.ts | 13 +- functions/src/scripts/backfill-badges.ts | 101 ++++++++ functions/src/utils.ts | 6 + web/components/badge-display.tsx | 62 +++++ web/components/contract/contract-details.tsx | 19 +- .../contract/contract-leaderboard.tsx | 59 ++--- web/components/notification-settings.tsx | 1 + web/components/profile/badges-modal.tsx | 223 ++++++++++++++++++ .../profile/betting-streak-modal.tsx | 2 +- web/components/user-page.tsx | 44 +--- web/pages/[username]/[contractSlug].tsx | 6 +- web/pages/notifications.tsx | 126 ++++++++-- web/public/award.svg | 28 +++ 23 files changed, 990 insertions(+), 129 deletions(-) create mode 100644 common/badge.ts create mode 100644 functions/src/scripts/backfill-badges.ts create mode 100644 web/components/badge-display.tsx create mode 100644 web/components/profile/badges-modal.tsx create mode 100644 web/public/award.svg diff --git a/common/badge.ts b/common/badge.ts new file mode 100644 index 00000000..c20b1f03 --- /dev/null +++ b/common/badge.ts @@ -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 +} diff --git a/common/notification.ts b/common/notification.ts index b75e3d4a..436393a5 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -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: diff --git a/common/scoring.ts b/common/scoring.ts index 4ef46534..a8f62631 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -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 = {} + 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, + } +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index de01a6cb..6b5a448d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -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 } diff --git a/common/user.ts b/common/user.ts index 233fe4cc..f00dfc89 100644 --- a/common/user.ts +++ b/common/user.ts @@ -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 @@ -51,6 +52,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 +94,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. diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9bd73d05..a0134634 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -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 } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c3b7ba1d..d22b8a2e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -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) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index b2451c62..7496db03 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -12,6 +12,7 @@ import { revalidateStaticProps, } from './utils' import { + createBadgeAwardedNotification, createBetFillNotification, createBettingStreakBonusNotification, createUniqueBettorBonusNotification, @@ -33,6 +34,10 @@ import { APIError } from '../../common/api' import { User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' +import { + StreakerBadge, + streakerBadgeRarityThresholds, +} from '../../common/badge' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -143,7 +148,7 @@ const updateBettingStreak = async ( log('message:', result.message) return } - if (result.txn) + if (result.txn) { await createBettingStreakBonusNotification( user, result.txn.id, @@ -153,6 +158,8 @@ const updateBettingStreak = async ( newBettingStreak, eventId ) + await handleBettingStreakBadgeAward(user, newBettingStreak) + } } const updateUniqueBettorsAndGiveCreatorBonus = async ( @@ -296,3 +303,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 + ) + + 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) + } +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index b613142b..13a84575 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -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( + 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) + } +} diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 1e3418fa..f0aa0252 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -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( + firestore.collection(`contracts/${contract.id}/bets`) + ) + + // get comments on this contract + const comments = await getValues( + 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, diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index a9d3baef..f72692f7 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -1,29 +1,24 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { filterDefined } from 'common/lib/util/array' -import { getPrivateUser } from '../utils' +import { getAllPrivateUsers } from 'functions/src/utils' initAdmin() const firestore = admin.firestore() async function main() { - // const privateUsers = await getAllPrivateUsers() - const privateUsers = filterDefined([ - await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), - ]) + const privateUsers = await getAllPrivateUsers() await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() - if (privateUser.notificationPreferences.opt_out_all === undefined) { - console.log('updating opt out all', privateUser.id) + if (privateUser.notificationPreferences.badges_awarded === undefined) { return firestore .collection('private-users') .doc(privateUser.id) .update({ notificationPreferences: { ...privateUser.notificationPreferences, - opt_out_all: [], + badges_awarded: ['browser'], }, }) } diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts new file mode 100644 index 00000000..648467cf --- /dev/null +++ b/functions/src/scripts/backfill-badges.ts @@ -0,0 +1,101 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllUsers, getValues } from '../utils' +import { Contract } from 'common/contract' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, + StreakerBadge, + streakerBadgeRarityThresholds, +} from 'common/badge' +import { User } from 'common/user' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const users = await getAllUsers() + // const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian + // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian + await Promise.all( + users.map(async (user) => { + // console.log('Added achievements to user', user.id) + if (!user.id) return + if (user.achievements === undefined) { + await firestore.collection('users').doc(user.id).update({ + achievements: {}, + }) + user.achievements = {} + } + user.achievements = await awardMarketCreatorBadges(user) + user.achievements = await awardBettingStreakBadges(user) + // going to ignore backfilling the proven correct badges for now + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) + +async function awardMarketCreatorBadges(user: User) { + // Award market maker badges + const contracts = await getValues( + 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 +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 91f4b293..e0cd269a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -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 diff --git a/web/components/badge-display.tsx b/web/components/badge-display.tsx new file mode 100644 index 00000000..983956cd --- /dev/null +++ b/web/components/badge-display.tsx @@ -0,0 +1,62 @@ +import { User } from 'common/user' +import { NextRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { getBadgesByRarity } from 'common/badge' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { BadgesModal } from 'web/components/profile/badges-modal' + +export const goldClassName = 'text-amber-400' +export const silverClassName = 'text-gray-500' +export const bronzeClassName = 'text-amber-900' + +export function BadgeDisplay(props: { + user: User | undefined | null + router?: NextRouter +}) { + const { user, router } = props + const [showBadgesModal, setShowBadgesModal] = useState(false) + + useEffect(() => { + if (!router) return + const showBadgesModal = router.query['show'] === 'badges' + setShowBadgesModal(showBadgesModal) + }, [router]) + // get number of badges of each rarity type + const badgesByRarity = getBadgesByRarity(user) + const badgesByRarityItems = Object.entries(badgesByRarity).map( + ([rarity, numBadges]) => { + return ( + + + {numBadges} + + ) + } + ) + return ( + setShowBadgesModal(true)} + > + {badgesByRarityItems} + {user && ( + + )} + + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index d2734ab5..ed7374b1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -154,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 ( @@ -178,12 +178,15 @@ export function MarketSubheader(props: { {disabled ? ( creatorName ) : ( - + + + {/**/} + )} {correctResolutionPercentage != null && correctResolutionPercentage < BAD_CREATOR_THRESHOLD && ( diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index f984e3b6..b4669156 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -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 = {} - 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 (
- {topComment && profitById[topComment.id] > 0 && ( + {topCommentBetId && profitById[topCommentBetId] > 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"> diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index ad9adbdf..166653e2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -131,6 +131,7 @@ export function NotificationSettings(props: { 'betting_streaks', 'referral_bonuses', 'unique_bettors_on_your_contract', + 'badges_awarded', ], } const otherBalances: SectionData = { diff --git a/web/components/profile/badges-modal.tsx b/web/components/profile/badges-modal.tsx new file mode 100644 index 00000000..96e0fa9d --- /dev/null +++ b/web/components/profile/badges-modal.tsx @@ -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> + ) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 4d1d63be..306a839c 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -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) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index dea7036d..f0ad0569 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -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 @@ -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} router={router} /> + </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} /> )} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1de472c5..70026e5e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -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} /> </> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 34218911..f7e4bc84 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -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,16 @@ function NotificationFrame(props: { subtitle: string children: React.ReactNode isChildOfGroup?: boolean + showUserName?: boolean }) { - const { notification, isChildOfGroup, highlighted, subtitle, children } = - props + const { + notification, + isChildOfGroup, + highlighted, + subtitle, + children, + showUserName, + } = props const { sourceType, sourceUserName, @@ -825,7 +846,7 @@ function NotificationFrame(props: { sourceUserUsername, sourceText, } = notification - const questionNeedsResolution = sourceUpdateType == 'closed' + const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 return ( @@ -855,16 +876,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,12 +888,14 @@ function NotificationFrame(props: { } > <div> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'relative mr-1 flex-shrink-0'} - short={isMobile} - /> + {showUserName && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'relative mr-1 flex-shrink-0'} + short={isMobile} + /> + )} {subtitle} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> @@ -967,6 +984,65 @@ function BetFillNotification(props: { ) } +function MarketClosedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted } = props + notification.sourceUserAvatarUrl = MANIFOLD_AVATAR_URL + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={'Please resolve'} + > + <Row> + <span> + {`Your market has closed. Please resolve it to pay out ${PAST_BETS}.`} + </span> + </Row> + </NotificationFrame> + ) +} + +function BadgeNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText } = notification + const subtitle = 'You earned a new badge!' + notification.sourceUserAvatarUrl = '/award.svg' + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}> + <span>{sourceText} 🎉</span> + </Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + showUserName={false} + > + <Row> + <span>{sourceText} 🎉</span> + </Row> + </NotificationFrame> + ) +} + function ContractResolvedNotification(props: { notification: Notification highlighted: boolean @@ -1137,6 +1213,11 @@ function getSourceUrl(notification: Notification) { sourceId ?? '', sourceType )}` + else if (sourceSlug) + return `/${sourceSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` } function getSourceIdForLinkComponent( @@ -1236,7 +1317,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': diff --git a/web/public/award.svg b/web/public/award.svg new file mode 100644 index 00000000..3140c5b2 --- /dev/null +++ b/web/public/award.svg @@ -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>