Referrals (#592)

* add trigger for updated user

* Add referral bonuses and notifications for them

* Cleanup

* Add share group button, cleanup

* Cleanup

* Add referrals list to user profile

* Remove unused

* Referral bonus => constant

* Refactor

* Add referral txn to helper fn

* Move reads into firebase transaction

* Use effects to write referral info

* Flex-wrap profile objects

* Small ui changes

* Restrict referral user to one update

* Remove rogue semicolon

* Note about group referral query details

* Track referrals, add them to settings list
This commit is contained in:
Ian Philips 2022-07-01 07:47:19 -06:00 committed by GitHub
parent b0b8c6e98b
commit 3165e42119
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 602 additions and 185 deletions

View File

@ -33,6 +33,7 @@ export type notification_source_types =
| 'tip'
| 'admin_message'
| 'group'
| 'user'
export type notification_source_update_types =
| 'created'
@ -53,3 +54,5 @@ export type notification_reason_types =
| 'on_new_follow'
| 'you_follow_user'
| 'added_you_to_group'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip | Manalink
type AnyTxnType = Donation | Tip | Manalink | Referral
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,7 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET'
// Any extra data
data?: { [key: string]: any }
@ -46,6 +46,13 @@ type Manalink = {
category: 'MANALINK'
}
type Referral = {
fromType: 'BANK'
toType: 'USER'
category: 'REFERRAL'
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
export type ReferralTxn = Txn & Referral

View File

@ -33,11 +33,14 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
referredByUserId?: string
referredByContractId?: string
}
export const STARTING_BALANCE = 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
export const REFERRAL_AMOUNT = 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User

View File

@ -20,7 +20,12 @@ service cloud.firestore {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
// only one referral allowed per user
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId'])
&& !("referredByUserId" in resource.data);
}
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {

View File

@ -68,6 +68,7 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question,
// TODO: move away from sourceContractSlug to sourceSlug
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
@ -252,44 +253,62 @@ export const createNotification = async (
}
}
const notifyUserReceivedReferralBonus = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
// If the referrer is the market creator, just tell them they joined to bet on their market
reason:
sourceContract?.creatorId === relatedUserId
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceContract) {
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
}
} else if (sourceType === 'follow' && relatedUserId) {
if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
}
return userToReasonTexts
}

View File

@ -27,6 +27,7 @@ export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
// v2
export * from './health'

View File

@ -0,0 +1,107 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { REFERRAL_AMOUNT, User } from '../../common/user'
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore
.document('users/{userId}')
.onUpdate(async (change, context) => {
const prevUser = change.before.data() as User
const user = change.after.data() as User
const { eventId } = context
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
// Only create a referral txn if the user has a referredByUserId
if (!user.referredByUserId) {
console.log(`Not set: referredByUserId ${user.referredByUserId}`)
return
}
const referredByUserId = user.referredByUserId
await firestore.runTransaction(async (transaction) => {
// get user that referred this user
const referredByUserDoc = firestore.doc(`users/${referredByUserId}`)
const referredByUserSnap = await transaction.get(referredByUserDoc)
if (!referredByUserSnap.exists) {
console.log(`User ${referredByUserId} not found`)
return
}
const referredByUser = referredByUserSnap.data() as User
let referredByContract: Contract | undefined = undefined
if (user.referredByContractId) {
const referredByContractDoc = firestore.doc(
`contracts/${user.referredByContractId}`
)
referredByContract = await transaction
.get(referredByContractDoc)
.then((snap) => snap.data() as Contract)
}
console.log(`referredByContract: ${referredByContract}`)
const txns = (
await firestore
.collection('txns')
.where('toId', '==', referredByUserId)
.where('category', '==', 'REFERRAL')
.get()
).docs.map((txn) => txn.ref)
const referralTxns = await transaction.getAll(...txns).catch((err) => {
console.error('error getting txns:', err)
throw err
})
// If the referring user already has a referral txn due to referring this user, halt
if (referralTxns.map((txn) => txn.data()?.description).includes(user.id)) {
console.log('found referral txn with the same details, aborting')
return
}
console.log('creating referral txns')
const fromId = HOUSE_LIQUIDITY_PROVIDER_ID
// if they're updating their referredId, create a txn for both
const txn: ReferralTxn = {
id: eventId,
createdTime: Date.now(),
fromId,
fromType: 'BANK',
toId: referredByUserId,
toType: 'USER',
amount: REFERRAL_AMOUNT,
token: 'M$',
category: 'REFERRAL',
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
}
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
await transaction.set(txnDoc, txn)
console.log('created referral with txn id:', txn.id)
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
transaction.update(referredByUserDoc, {
balance: referredByUser.balance + REFERRAL_AMOUNT,
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
})
await createNotification(
user.id,
'user',
'updated',
user,
eventId,
txn.amount.toString(),
referredByContract,
'user',
referredByUser.id,
referredByContract?.slug,
referredByContract?.question
)
})
}

View File

@ -29,6 +29,8 @@ import { groupPath } from 'web/lib/firebase/groups'
import { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time'
import { useGroupsWithContract } from 'web/hooks/use-group'
import { ShareIconButton } from 'web/components/share-icon-button'
import { useUser } from 'web/hooks/use-user'
export type ShowTime = 'resolve-date' | 'close-date'
@ -130,6 +132,7 @@ export function ContractDetails(props: {
const { volumeLabel, resolvedDate } = contractMetrics(contract)
// Find a group that this contract id is in
const groups = useGroupsWithContract(contract.id)
const user = useUser()
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
@ -192,6 +195,11 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
contract={contract}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</Row>

View File

@ -13,7 +13,6 @@ import {
getBinaryProbPercent,
} from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { CopyLinkButton } from '../copy-link-button'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
@ -23,6 +22,9 @@ import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -48,13 +50,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
return (
<>
<button
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"
className={contractDetailsButtonClassName}
onClick={() => setOpen(true)}
>
<DotsHorizontalIcon
className={clsx(
'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500'
)}
className={clsx('h-6 w-6 flex-shrink-0')}
aria-hidden="true"
/>
</button>
@ -66,10 +66,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<div>Share</div>
<Row className="justify-start gap-4">
<CopyLinkButton
contract={contract}
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
/>
<TweetButton
className="self-start"
tweetText={getTweetText(contract, false)}

View File

@ -46,7 +46,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<div className={clsx('flex p-1', className)}>
<div
className={clsx(
'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white'
'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700'
)}
onClick={() => updateOpen(!open)}
>

View File

@ -91,6 +91,9 @@ export function GroupChat(props: {
setReplyToUsername('')
inputRef?.focus()
}
function focusInput() {
inputRef?.focus()
}
return (
<Col className={'flex-1'}>
@ -117,7 +120,13 @@ export function GroupChat(props: {
))}
{messages.length === 0 && (
<div className="p-2 text-gray-500">
No messages yet. 🦗... Why not say something?
No messages yet. Why not{' '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={() => focusInput()}
>
add one?
</button>
</div>
)}
</Col>

View File

@ -0,0 +1,93 @@
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { prefetchUsers, useUserById } from 'web/hooks/use-user'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs'
import { TextButton } from './text-button'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { useReferrals } from 'web/hooks/use-referrals'
export function ReferralsButton(props: { user: User }) {
const { user } = props
const [isOpen, setIsOpen] = useState(false)
const referralIds = useReferrals(user.id)
return (
<>
<TextButton onClick={() => setIsOpen(true)}>
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
Referrals
</TextButton>
<ReferralsDialog
user={user}
referralIds={referralIds ?? []}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</>
)
}
function ReferralsDialog(props: {
user: User
referralIds: string[]
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}) {
const { user, referralIds, isOpen, setIsOpen } = props
useEffect(() => {
prefetchUsers(referralIds)
}, [referralIds])
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs
tabs={[
{
title: 'Referrals',
content: <ReferralsList userIds={referralIds} />,
},
]}
/>
</Col>
</Modal>
)
}
function ReferralsList(props: { userIds: string[] }) {
const { userIds } = props
return (
<Col className="gap-2">
{userIds.length === 0 && (
<div className="text-gray-500">No users yet...</div>
)}
{userIds.map((userId) => (
<UserReferralItem key={userId} userId={userId} />
))}
</Col>
)
}
function UserReferralItem(props: { userId: string; className?: string }) {
const { userId, className } = props
const user = useUserById(userId)
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="items-center gap-2">
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} />
{user && <UserLink name={user.name} username={user.username} />}
</Row>
</Row>
)
}

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
import { Group } from 'common/group'
import { groupPath } from 'web/lib/firebase/groups'
function copyContractWithReferral(contract: Contract, username?: string) {
const postFix =
username && contract.creatorUsername !== username
? '?referrer=' + username
: ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
)
}
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
function copyGroupWithReferral(group: Group, username?: string) {
const postFix = username ? '?referrer=' + username : ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
)
}
export function ShareIconButton(props: {
contract?: Contract
group?: Group
buttonClassName?: string
toastClassName?: string
username?: string
children?: React.ReactNode
}) {
const {
contract,
buttonClassName,
toastClassName,
username,
group,
children,
} = props
const [showToast, setShowToast] = useState(false)
return (
<div className="relative z-10 flex-shrink-0">
<button
className={clsx(contractDetailsButtonClassName, buttonClassName)}
onClick={() => {
if (contract) copyContractWithReferral(contract, username)
if (group) copyGroupWithReferral(group, username)
track('copy share link')
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}}
>
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
{children}
</button>
{showToast && <ToastClipboard className={toastClassName} />}
</div>
)
}

View File

@ -36,6 +36,7 @@ import { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button'
import { PortfolioMetrics } from 'common/user'
import { ReferralsButton } from 'web/components/referrals-button'
import { GroupsButton } from 'web/components/groups/groups-button'
export function UserLink(props: {
@ -194,10 +195,11 @@ export function UserPage(props: {
</>
)}
<Col className="gap-2 sm:flex-row sm:items-center sm:gap-4">
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
<Row className="gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} />
<GroupsButton user={user} />
</Row>

View File

@ -117,7 +117,7 @@ function getAppropriateNotifications(
return notifications.filter(
(n) =>
n.reason &&
// Show all contract notifications
// Show all contract notifications and any that aren't in the above list:
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
)
if (notificationPreferences === 'none') return []

View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
import { listenForReferrals } from 'web/lib/firebase/users'
export const useReferrals = (userId: string | null | undefined) => {
const [referralIds, setReferralIds] = useState<string[] | undefined>()
useEffect(() => {
if (userId) return listenForReferrals(userId, setReferralIds)
}, [userId])
return referralIds
}

View File

@ -6,7 +6,7 @@ import {
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy } from 'lodash'
import { sortBy, uniq } from 'lodash'
import { Group } from 'common/group'
import { getContractFromId } from './contracts'
import {
@ -95,6 +95,16 @@ export async function getGroupsWithContractId(
setGroups(await getValues<Group>(q))
}
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
// get group to get the member ids
const group = await getGroupBySlug(groupSlug)
if (!group) {
console.error(`Group not found: ${groupSlug}`)
return
}
return await joinGroup(group, userId)
}
export async function joinGroup(group: Group, userId: string): Promise<Group> {
const { memberIds } = group
if (memberIds.includes(userId)) {
@ -102,7 +112,7 @@ export async function joinGroup(group: Group, userId: string): Promise<Group> {
}
const newMemberIds = [...memberIds, userId]
const newGroup = { ...group, memberIds: newMemberIds }
await updateGroup(newGroup, { memberIds: newMemberIds })
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
return newGroup
}
export async function leaveGroup(group: Group, userId: string): Promise<Group> {
@ -112,6 +122,6 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> {
}
const newMemberIds = memberIds.filter((id) => id !== userId)
const newGroup = { ...group, memberIds: newMemberIds }
await updateGroup(newGroup, { memberIds: newMemberIds })
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
return newGroup
}

View File

@ -36,6 +36,10 @@ import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
import { addUserToGroupViaSlug } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object'
import dayjs from 'dayjs'
import { track } from '@amplitude/analytics-browser'
export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users')
@ -90,12 +94,92 @@ export function listenForPrivateUser(
}
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY'
// used to avoid weird race condition
let createUserPromise: Promise<User | null> | undefined = undefined
const warmUpCreateUser = throttle(createUser, 5000 /* ms */)
export function writeReferralInfo(
defaultReferrerUsername: string,
contractId?: string,
referralUsername?: string,
groupSlug?: string
) {
const local = safeLocalStorage()
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
// Write the first referral username we see.
if (!cachedReferralUser)
local?.setItem(
CACHED_REFERRAL_USERNAME_KEY,
referralUsername || defaultReferrerUsername
)
// If an explicit referral query is passed, overwrite the cached referral username.
if (referralUsername)
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
// Always write the most recent explicit group invite query value
if (groupSlug) local?.setItem(CACHED_REFERRAL_GROUP_SLUG_KEY, groupSlug)
// Write the first contract id that we see.
const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
if (!cachedReferralContract && contractId)
local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId)
}
async function setCachedReferralInfoForUser(user: User | null) {
if (!user || user.referredByUserId) return
// if the user wasn't created in the last minute, don't bother
const now = dayjs().utc()
const userCreatedTime = dayjs(user.createdTime)
if (now.diff(userCreatedTime, 'minute') > 1) return
const local = safeLocalStorage()
const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
const cachedReferralContractId = local?.getItem(
CACHED_REFERRAL_CONTRACT_ID_KEY
)
const cachedReferralGroupSlug = local?.getItem(CACHED_REFERRAL_GROUP_SLUG_KEY)
// get user via username
if (cachedReferralUsername)
getUserByUsername(cachedReferralUsername).then((referredByUser) => {
if (!referredByUser) return
// update user's referralId
updateUser(
user.id,
removeUndefinedProps({
referredByUserId: referredByUser.id,
referredByContractId: cachedReferralContractId
? cachedReferralContractId
: undefined,
})
)
.catch((err) => {
console.log('error setting referral details', err)
})
.then(() => {
track('Referral', {
userId: user.id,
referredByUserId: referredByUser.id,
referredByContractId: cachedReferralContractId,
referredByGroupSlug: cachedReferralGroupSlug,
})
})
})
if (cachedReferralGroupSlug)
addUserToGroupViaSlug(cachedReferralGroupSlug, user.id)
local?.removeItem(CACHED_REFERRAL_GROUP_SLUG_KEY)
local?.removeItem(CACHED_REFERRAL_USERNAME_KEY)
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
}
export function listenForLogin(onUser: (user: User | null) => void) {
const local = safeLocalStorage()
const cachedUser = local?.getItem(CACHED_USER_KEY)
@ -119,6 +203,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
} else {
// User logged out; reset to null
onUser(null)
@ -279,3 +364,22 @@ export function listenForFollowers(
}
)
}
export function listenForReferrals(
userId: string,
setReferralIds: (referralIds: string[]) => void
) {
const referralsQuery = query(
collection(db, 'users'),
where('referredByUserId', '==', userId)
)
return onSnapshot(
referralsQuery,
{ includeMetadataChanges: true },
(snapshot) => {
if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.ref.id)
setReferralIds(filterDefined(values))
}
)
}

View File

@ -10,7 +10,7 @@ import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer'
import { listUsers, User } from 'web/lib/firebase/users'
import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users'
import {
Contract,
getContractFromSlug,
@ -42,6 +42,7 @@ import { useBets } from 'web/hooks/use-bets'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useRouter } from 'next/router'
import { useLiquidity } from 'web/hooks/use-liquidity'
export const getStaticProps = fromPropz(getStaticPropz)
@ -150,6 +151,16 @@ export function ContractPageContent(
const ogCardProps = getOpenGraphProps(contract)
const router = useRouter()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(contract.creatorUsername, contract.id, referrer)
}, [user, contract, router])
const rightSidebar = hasSidePanel ? (
<Col className="gap-4">
{allowTrade &&

View File

@ -13,7 +13,12 @@ import {
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import {
firebaseLogin,
getUser,
User,
writeReferralInfo,
} from 'web/lib/firebase/users'
import { Spacer } from 'web/components/layout/spacer'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
@ -40,6 +45,9 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import ShortToggle from 'web/components/widgets/short-toggle'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -150,6 +158,14 @@ export default function GroupPage(props: {
}, [group])
const user = useUser()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(creator.username, undefined, referrer, group?.slug)
}, [user, creator, group, router])
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
@ -257,7 +273,13 @@ export default function GroupPage(props: {
</>
) : (
<div className="p-2 text-gray-500">
No questions yet. 🦗... Why not add one?
No questions yet. Why not{' '}
<SiteLink
href={`/create/?groupId=${group.id}`}
className={'font-bold text-gray-700'}
>
add one?
</SiteLink>
</div>
)
) : (
@ -321,18 +343,17 @@ function GroupOverview(props: {
return (
<Col>
<Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
<Row className="flex-1 justify-start">About {group.name}</Row>
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
</Row>
<Col className="gap-2 rounded-b bg-white p-4">
<Row>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
<Row className={'flex-wrap justify-between'}>
<div className={'inline-flex items-center'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</div>
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
</Row>
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
@ -352,6 +373,20 @@ function GroupOverview(props: {
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}>
<span className={'text-gray-500'}>Sharing</span>
<ShareIconButton
group={group}
username={user.username}
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'}
>
<span className={'mx-2'}>
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up!
</span>
</ShareIconButton>
</Row>
)}
</Col>
</Col>
)

View File

@ -14,9 +14,6 @@ import { Title } from 'web/components/title'
import { doc, updateDoc } from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Answer } from 'common/answer'
import { Comment } from 'web/lib/firebase/comments'
import { getValue } from 'web/lib/firebase/utils'
import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-page'
import { notification_subscribe_types, PrivateUser } from 'common/user'
@ -38,7 +35,6 @@ import {
NotificationGroup,
usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
@ -182,7 +178,7 @@ function NotificationGroupItem(props: {
className?: string
}) {
const { notificationGroup, className } = props
const { sourceContractId, notifications } = notificationGroup
const { notifications } = notificationGroup
const {
sourceContractTitle,
sourceContractSlug,
@ -191,28 +187,6 @@ function NotificationGroupItem(props: {
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
const [contract, setContract] = useState<Contract | undefined>(undefined)
useEffect(() => {
if (
sourceContractTitle &&
sourceContractSlug &&
sourceContractCreatorUsername
)
return
if (sourceContractId) {
getContractFromId(sourceContractId)
.then((contract) => {
if (contract) setContract(contract)
})
.catch((e) => console.log(e))
}
}, [
sourceContractCreatorUsername,
sourceContractId,
sourceContractSlug,
sourceContractTitle,
])
useEffect(() => {
setNotificationsAsSeen(notifications)
@ -240,20 +214,20 @@ function NotificationGroupItem(props: {
onClick={() => setExpanded(!expanded)}
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
>
{sourceContractTitle || contract ? (
{sourceContractTitle ? (
<span>
{'Activity on '}
<a
href={
sourceContractCreatorUsername
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
: `/${contract?.creatorUsername}/${contract?.slug}`
: ''
}
className={
'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
}
>
{sourceContractTitle || contract?.question}
{sourceContractTitle}
</a>
</span>
) : (
@ -306,6 +280,7 @@ function NotificationGroupItem(props: {
)
}
// TODO: where should we put referral bonus notifications?
function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
@ -455,6 +430,10 @@ function NotificationSettings() {
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
@ -515,7 +494,6 @@ function NotificationItem(props: {
const { notification, justSummary } = props
const {
sourceType,
sourceContractId,
sourceId,
sourceUserName,
sourceUserAvatarUrl,
@ -534,60 +512,15 @@ function NotificationItem(props: {
const [defaultNotificationText, setDefaultNotificationText] =
useState<string>('')
const [contract, setContract] = useState<Contract | null>(null)
useEffect(() => {
if (
!sourceContractId ||
(sourceContractSlug && sourceContractCreatorUsername)
)
return
getContractFromId(sourceContractId)
.then((contract) => {
if (contract) setContract(contract)
})
.catch((e) => console.log(e))
}, [
sourceContractCreatorUsername,
sourceContractId,
sourceContractSlug,
sourceContractTitle,
])
useEffect(() => {
if (sourceText) {
setDefaultNotificationText(sourceText)
} else if (!contract || !sourceContractId || !sourceId) return
else if (
sourceType === 'answer' ||
sourceType === 'comment' ||
sourceType === 'contract'
) {
try {
parseOldStyleNotificationText(
sourceId,
sourceContractId,
sourceType,
sourceUpdateType,
setDefaultNotificationText,
contract
)
} catch (err) {
console.error(err)
}
} else if (reasonText) {
// Handle arbitrary notifications with reason text here.
setDefaultNotificationText(reasonText)
}
}, [
contract,
reasonText,
sourceContractId,
sourceId,
sourceText,
sourceType,
sourceUpdateType,
])
}, [reasonText, sourceText])
useEffect(() => {
setNotificationsAsSeen([notification])
@ -596,14 +529,16 @@ function NotificationItem(props: {
function getSourceUrl() {
if (sourceType === 'follow') return `/${sourceUserUsername}`
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
if (
sourceContractCreatorUsername &&
sourceContractSlug &&
sourceType === 'user'
)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? ''
)}`
if (!contract) return ''
return `/${contract.creatorUsername}/${
contract.slug
}#${getSourceIdForLinkComponent(sourceId ?? '')}`
}
function getSourceIdForLinkComponent(sourceId: string) {
@ -619,38 +554,6 @@ function NotificationItem(props: {
}
}
async function parseOldStyleNotificationText(
sourceId: string,
sourceContractId: string,
sourceType: 'answer' | 'comment' | 'contract',
sourceUpdateType: notification_source_update_types | undefined,
setText: (text: string) => void,
contract: Contract
) {
if (sourceType === 'contract') {
if (
isNotificationAboutContractResolution(
sourceType,
sourceUpdateType,
contract
) &&
contract.resolution
)
setText(contract.resolution)
else setText(contract.question)
} else if (sourceType === 'answer') {
const answer = await getValue<Answer>(
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
)
setText(answer?.text ?? '')
} else {
const comment = await getValue<Comment>(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
)
setText(comment?.text ?? '')
}
}
if (justSummary) {
return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
@ -669,13 +572,13 @@ function NotificationItem(props: {
sourceType,
reason,
sourceUpdateType,
contract,
undefined,
true
).replace(' on', '')}
</span>
<div className={'ml-1 text-black'}>
<NotificationTextLabel
contract={contract}
contract={null}
defaultText={defaultNotificationText}
className={'line-clamp-1'}
notification={notification}
@ -717,7 +620,9 @@ function NotificationItem(props: {
sourceType,
reason,
sourceUpdateType,
contract
undefined,
false,
sourceSlug
)}
<a
href={
@ -725,13 +630,13 @@ function NotificationItem(props: {
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
: sourceType === 'group' && sourceSlug
? `${groupPath(sourceSlug)}`
: `/${contract?.creatorUsername}/${contract?.slug}`
: ''
}
className={
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
}
>
{contract?.question || sourceContractTitle || sourceTitle}
{sourceContractTitle || sourceTitle}
</a>
</div>
)}
@ -752,7 +657,7 @@ function NotificationItem(props: {
</Row>
<div className={'mt-1 ml-1 md:text-base'}>
<NotificationTextLabel
contract={contract}
contract={null}
defaultText={defaultNotificationText}
notification={notification}
/>
@ -811,6 +716,16 @@ function NotificationTextLabel(props: {
</span>
)
}
} else if (sourceType === 'user' && sourceText) {
return (
<span>
As a thank you, we sent you{' '}
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>
!
</span>
)
} else if (sourceType === 'liquidity' && sourceText) {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
@ -829,7 +744,8 @@ function getReasonForShowingNotification(
reason: notification_reason_types,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | undefined | null,
simple?: boolean
simple?: boolean,
sourceSlug?: string
) {
let reasonText: string
switch (source) {
@ -883,6 +799,12 @@ function getReasonForShowingNotification(
case 'group':
reasonText = 'added you to the group'
break
case 'user':
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
reasonText = 'joined to bet on your market'
else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you'
break
default:
reasonText = ''
}