diff --git a/common/notification.ts b/common/notification.ts index 919cf917..64a00a36 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -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' diff --git a/common/txn.ts b/common/txn.ts index 25d4a1c3..0e772e0d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -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 = { @@ -16,7 +16,7 @@ export type Txn = { 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 diff --git a/common/user.ts b/common/user.ts index 298fee56..0a8565dd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -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 diff --git a/firestore.rules b/firestore.rules index 176cc71e..50df415a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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} { diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index daf7e9d7..a32ed3bc 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -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 } diff --git a/functions/src/index.ts b/functions/src/index.ts index 726aba15..b643ff5e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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' diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts new file mode 100644 index 00000000..2e5e2145 --- /dev/null +++ b/functions/src/on-update-user.ts @@ -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 + ) + }) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 03925a35..3512efa2 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -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 ( @@ -192,6 +195,11 @@ export function ContractDetails(props: {
{volumeLabel}
+ {!disabled && }
diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7027d06a..12fd8dd9 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -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 ( <> @@ -66,10 +66,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
Share
-
updateOpen(!open)} > diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 5dedbc8f..114a9003 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -91,6 +91,9 @@ export function GroupChat(props: { setReplyToUsername('') inputRef?.focus() } + function focusInput() { + inputRef?.focus() + } return ( @@ -117,7 +120,13 @@ export function GroupChat(props: { ))} {messages.length === 0 && (
- No messages yet. 🦗... Why not say something? + No messages yet. Why not{' '} +
)} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx new file mode 100644 index 00000000..c23958fc --- /dev/null +++ b/web/components/referrals-button.tsx @@ -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 ( + <> + setIsOpen(true)}> + {referralIds?.length ?? ''}{' '} + Referrals + + + + + ) +} + +function ReferralsDialog(props: { + user: User + referralIds: string[] + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) { + const { user, referralIds, isOpen, setIsOpen } = props + + useEffect(() => { + prefetchUsers(referralIds) + }, [referralIds]) + + return ( + + +
{user.name}
+
@{user.username}
+ , + }, + ]} + /> + +
+ ) +} + +function ReferralsList(props: { userIds: string[] }) { + const { userIds } = props + + return ( + + {userIds.length === 0 && ( +
No users yet...
+ )} + {userIds.map((userId) => ( + + ))} + + ) +} + +function UserReferralItem(props: { userId: string; className?: string }) { + const { userId, className } = props + const user = useUserById(userId) + + return ( + + + + {user && } + + + ) +} diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx new file mode 100644 index 00000000..507d90c2 --- /dev/null +++ b/web/components/share-icon-button.tsx @@ -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 ( +
+ + + {showToast && } +
+ ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 246ed2aa..ac9fe8fd 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -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: { )} - + + diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 051d6cbb..c947e8d0 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -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 [] diff --git a/web/hooks/use-referrals.ts b/web/hooks/use-referrals.ts new file mode 100644 index 00000000..0feba62c --- /dev/null +++ b/web/hooks/use-referrals.ts @@ -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() + + useEffect(() => { + if (userId) return listenForReferrals(userId, setReferralIds) + }, [userId]) + + return referralIds +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 36b05452..506849ad 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -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(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 { const { memberIds } = group if (memberIds.includes(userId)) { @@ -102,7 +112,7 @@ export async function joinGroup(group: Group, userId: string): Promise { } 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 { @@ -112,6 +122,6 @@ export async function leaveGroup(group: Group, userId: string): Promise { } 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 } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 40be6741..e72fe141 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -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('users') export const privateUsers = coll('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 | 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)) + } + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 24982b4f..413de725 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -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 ? ( {allowTrade && diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a3b99128..3a3db14d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -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 } @@ -257,7 +273,13 @@ export default function GroupPage(props: { ) : (
- No questions yet. 🦗... Why not add one? + No questions yet. Why not{' '} + + add one? +
) ) : ( @@ -321,18 +343,17 @@ function GroupOverview(props: { return ( - - About {group.name} - {isCreator && } - - -
Created by
- + +
+
Created by
+ +
+ {isCreator && }
Membership @@ -352,6 +373,20 @@ function GroupOverview(props: { )} + {anyoneCanJoin && user && ( + + Sharing + + + Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! + + + + )} ) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a3af0a9a..9b0216b6 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -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(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 ? ( {'Activity on '} - {sourceContractTitle || contract?.question} + {sourceContractTitle} ) : ( @@ -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"} /> + ('') - const [contract, setContract] = useState(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( - doc(db, `contracts/${sourceContractId}/answers/`, sourceId) - ) - setText(answer?.text ?? '') - } else { - const comment = await getValue( - doc(db, `contracts/${sourceContractId}/comments/`, sourceId) - ) - setText(comment?.text ?? '') - } - } - if (justSummary) { return ( @@ -669,13 +572,13 @@ function NotificationItem(props: { sourceType, reason, sourceUpdateType, - contract, + undefined, true ).replace(' on', '')}
- {contract?.question || sourceContractTitle || sourceTitle} + {sourceContractTitle || sourceTitle}
)} @@ -752,7 +657,7 @@ function NotificationItem(props: {
@@ -811,6 +716,16 @@ function NotificationTextLabel(props: { ) } + } else if (sourceType === 'user' && sourceText) { + return ( + + As a thank you, we sent you{' '} + + {formatMoney(parseInt(sourceText))} + + ! + + ) } else if (sourceType === 'liquidity' && sourceText) { return ( {formatMoney(parseInt(sourceText))} @@ -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 = '' }