diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 46885668..f399aa5a 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' -export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 +export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx new file mode 100644 index 00000000..fcc3de39 --- /dev/null +++ b/web/components/auth-context.tsx @@ -0,0 +1,77 @@ +import { createContext, useEffect } from 'react' +import { User } from 'common/user' +import { onIdTokenChanged } from 'firebase/auth' +import { + auth, + listenForUser, + getUser, + setCachedReferralInfoForUser, +} from 'web/lib/firebase/users' +import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth' +import { createUser } from 'web/lib/firebase/api' +import { randomString } from 'common/util/random' +import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' + +// Either we haven't looked up the logged in user yet (undefined), or we know +// the user is not logged in (null), or we know the user is logged in (User). +type AuthUser = undefined | null | User + +const CACHED_USER_KEY = 'CACHED_USER_KEY' + +const ensureDeviceToken = () => { + let deviceToken = localStorage.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + localStorage.setItem('device-token', deviceToken) + } + return deviceToken +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: any) { + const [authUser, setAuthUser] = useStateCheckEquality(undefined) + + useEffect(() => { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + }, [setAuthUser]) + + useEffect(() => { + return onIdTokenChanged(auth, async (fbUser) => { + if (fbUser) { + let user = await getUser(fbUser.uid) + if (!user) { + const deviceToken = ensureDeviceToken() + user = (await createUser({ deviceToken })) as User + } + setAuthUser(user) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + } else { + // User logged out; reset to null + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) + deleteAuthCookies() + } + }) + }, [setAuthUser]) + + const authUserId = authUser?.id + const authUsername = authUser?.username + useEffect(() => { + if (authUserId && authUsername) { + identifyUser(authUserId) + setUserProperty('username', authUsername) + return listenForUser(authUserId, setAuthUser) + } + }, [authUserId, authUsername, setAuthUser]) + + return ( + {children} + ) +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2114ec2b..a306a020 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -78,10 +78,10 @@ export function BetsList(props: { const getTime = useTimeSinceFirstRender() useEffect(() => { - if (bets && contractsById) { - trackLatency('portfolio', getTime()) + if (bets && contractsById && signedInUser) { + trackLatency(signedInUser.id, 'portfolio', getTime()) } - }, [bets, contractsById, getTime]) + }, [signedInUser, bets, contractsById, getTime]) if (!bets || !contractsById) { return diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 730b113f..8eb7df6e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -22,7 +22,7 @@ import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useUser } from 'web/hooks/use-user' import { useFollows } from 'web/hooks/use-follows' -import { trackCallback } from 'web/lib/service/analytics' +import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' @@ -111,8 +111,14 @@ export function ContractSearch(props: { querySortOptions?.defaultFilter ?? 'open' ) const pillsEnabled = !additionalFilter + const [pillFilter, setPillFilter] = useState(undefined) + const selectFilter = (pill: string | undefined) => () => { + setPillFilter(pill) + track('select search category', { category: pill ?? 'all' }) + } + const { filters, numericFilters } = useMemo(() => { let filters = [ filter === 'open' ? 'isResolved:false' : '', @@ -191,7 +197,7 @@ export function ContractSearch(props: { className="!select !select-bordered" value={filter} onChange={(e) => setFilter(e.target.value as filter)} - onBlur={trackCallback('select search filter')} + onBlur={trackCallback('select search filter', { filter })} > @@ -204,7 +210,7 @@ export function ContractSearch(props: { classNames={{ select: '!select !select-bordered', }} - onBlur={trackCallback('select search sort')} + onBlur={trackCallback('select search sort', { sort })} /> )} setPillFilter(undefined)} + onSelect={selectFilter(undefined)} > All setPillFilter('personal')} + onSelect={selectFilter('personal')} > For you @@ -237,7 +243,7 @@ export function ContractSearch(props: { setPillFilter('your-bets')} + onSelect={selectFilter('your-bets')} > Your bets @@ -247,7 +253,7 @@ export function ContractSearch(props: { setPillFilter(slug)} + onSelect={selectFilter(slug)} > {name} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index b4d67520..036311fe 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -11,6 +11,7 @@ import { UserLink } from '../user-page' import { Contract, contractMetrics, + contractPath, contractPool, updateContract, } from 'web/lib/firebase/contracts' @@ -33,6 +34,7 @@ import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' +import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -222,9 +224,12 @@ 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 a0c7fcc9..d976253f 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,7 +19,7 @@ import { InfoTooltip } from '../info-tooltip' import { DuplicateContractButton } from '../copy-contract-button' 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' + '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 diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index d5accef0..f4c6eb74 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -70,7 +70,7 @@ export function FeedCommentThread(props: { if (showReply && inputRef) inputRef.focus() }, [inputRef, showReply]) return ( -
+
+ ) } diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index ff5f5440..ea8302b8 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -23,6 +23,7 @@ import BetRow from '../bet-row' import { Avatar } from '../avatar' import { ActivityItem } from './activity-items' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' +import { useUser } from 'web/hooks/use-user' import { trackClick } from 'web/lib/firebase/tracking' import { DAY_MS } from 'common/util/time' import NewContractBadge from '../new-contract-badge' @@ -118,6 +119,7 @@ export function FeedQuestion(props: { const { volumeLabel } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY' const isNew = createdTime > Date.now() - DAY_MS && !isResolved + const user = useUser() return (
@@ -149,7 +151,7 @@ export function FeedQuestion(props: { href={ props.contractPath ? props.contractPath : contractPath(contract) } - onClick={() => trackClick(contract.id)} + onClick={() => user && trackClick(user.id, contract.id)} className="text-lg text-indigo-700 sm:text-xl" > {question} diff --git a/web/components/info-box.tsx b/web/components/info-box.tsx new file mode 100644 index 00000000..34f65089 --- /dev/null +++ b/web/components/info-box.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx' +import { InformationCircleIcon } from '@heroicons/react/solid' + +import { Linkify } from './linkify' + +export function InfoBox(props: { + title: string + text: string + className?: string +}) { + const { title, text, className } = props + return ( +
+
+
+
+
+

{title}

+
+ +
+
+
+
+ ) +} diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index b5a79091..b49e1621 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -3,9 +3,13 @@ import { formatMoney } from 'common/util/format' import { fromNow } from 'web/lib/util/time' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' -import { User } from 'web/lib/firebase/users' -import { Button } from './button' - +import { Claim, Manalink } from 'common/manalink' +import { useState } from 'react' +import { ShareIconButton } from './share-icon-button' +import { DotsHorizontalIcon } from '@heroicons/react/solid' +import { contractDetailsButtonClassName } from './contract/contract-info-dialog' +import { useUserById } from 'web/hooks/use-user' +import getManalinkUrl from 'web/get-manalink-url' export type ManalinkInfo = { expiresTime: number | null maxUses: number | null @@ -15,94 +19,202 @@ export type ManalinkInfo = { } export function ManalinkCard(props: { - user: User | null | undefined - className?: string info: ManalinkInfo - isClaiming: boolean - onClaim?: () => void + className?: string + preview?: boolean }) { - const { user, className, isClaiming, info, onClaim } = props + const { className, info, preview = false } = props const { expiresTime, maxUses, uses, amount, message } = info return ( -
+
+ +
+ {maxUses != null + ? `${maxUses - uses}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ + + + + +
+ {formatMoney(amount)} +
+
{message}
+ +
+
+ + ) +} + +export function ManalinkCardFromView(props: { + className?: string + link: Manalink + highlightedSlug: string +}) { + const { className, link, highlightedSlug } = props + const { message, amount, expiresTime, maxUses, claims } = link + const [details, setDetails] = useState(false) + + return ( + - -
- {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} -
-
- {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} -
- - - - - -
+
setDetails(!details)} + > + {details && ( + + )} + +
+ {maxUses != null + ? `${maxUses - claims.length}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ + +
+ + +
{formatMoney(amount)}
-
{message}
- - -
- -
-
-
+ + +
+
{message || '\n\n'}
+ + ) } -export function ManalinkCardPreview(props: { - className?: string - info: ManalinkInfo -}) { - const { className, info } = props - const { expiresTime, maxUses, uses, amount, message } = info +function ClaimsList(props: { link: Manalink; className: string }) { + const { link, className } = props return ( -
- -
- {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} + <> + +
+ Claimed by...
-
- {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} +
+ {link.claims.length > 0 ? ( + <> + {link.claims.map((claim) => ( + + + + ))} + + ) : ( +
+ No one has claimed this manalink yet! Share your manalink to start + spreading the wealth. +
+ )}
- - - - -
{formatMoney(amount)}
-
{message}
- -
-
+ ) } + +function Claim(props: { claim: Claim }) { + const { claim } = props + const who = useUserById(claim.toId) + return ( + +
{who?.name || 'Loading...'}
+
{fromNow(claim.claimedTime)}
+
+ ) +} + +function getManalinkGradient(amount: number) { + if (amount < 20) { + return 'from-indigo-200 via-indigo-500 to-indigo-800' + } else if (amount >= 20 && amount < 50) { + return 'from-fuchsia-200 via-fuchsia-500 to-fuchsia-800' + } else if (amount >= 50 && amount < 100) { + return 'from-rose-100 via-rose-400 to-rose-700' + } else if (amount >= 100) { + return 'from-amber-200 via-amber-500 to-amber-700' + } +} + +function getManalinkAmountColor(amount: number) { + if (amount < 20) { + return 'text-indigo-500' + } else if (amount >= 20 && amount < 50) { + return 'text-fuchsia-600' + } else if (amount >= 50 && amount < 100) { + return 'text-rose-600' + } else if (amount >= 100) { + return 'text-amber-600' + } +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 0d1d603e..25b51bb2 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -4,7 +4,7 @@ import { Col } from '../layout/col' import { Row } from '../layout/row' import { Title } from '../title' import { User } from 'common/user' -import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card' +import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' import Textarea from 'react-expanding-textarea' @@ -37,6 +37,7 @@ export function CreateLinksButton(props: { message: newManalink.message, }) setHighlightedSlug(slug || '') + setTimeout(() => setHighlightedSlug(''), 3700) }} /> @@ -191,7 +192,7 @@ function CreateManalinkForm(props: { {finishedCreating && ( <> - <ManalinkCardPreview className="my-4" info={newManalink} /> + <ManalinkCard className="my-4" info={newManalink} preview /> <Row className={clsx( 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index ff740540..e6ce8575 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -40,6 +40,8 @@ function getNavigation() { icon: NotificationsIcon, }, + { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, + ...(IS_PRIVATE_MANIFOLD ? [] : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), @@ -53,7 +55,6 @@ function getMoreNavigation(user?: User | null) { if (!user) { return [ - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, @@ -62,9 +63,9 @@ function getMoreNavigation(user?: User | null) { } return [ - { name: 'Send M$', href: '/links' }, - { name: 'Leaderboards', href: '/leaderboards' }, + { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { @@ -78,7 +79,6 @@ function getMoreNavigation(user?: User | null) { const signedOutNavigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, - { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'About', href: 'https://docs.manifold.markets/$how-to', @@ -98,6 +98,7 @@ const signedOutMobileNavigation = [ ] const signedInMobileNavigation = [ + { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, ...(IS_PRIVATE_MANIFOLD ? [] : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), @@ -113,11 +114,11 @@ function getMoreMobileNav() { ...(IS_PRIVATE_MANIFOLD ? [] : [ - { name: 'Send M$', href: '/links' }, + { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, ]), - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Sign out', href: '#', diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 069ebda7..3f4108bc 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,9 +1,12 @@ +import clsx from 'clsx' + export function Pagination(props: { page: number itemsPerPage: number totalItems: number setPage: (page: number) => void scrollToTop?: boolean + className?: string nextTitle?: string prevTitle?: string }) { @@ -15,13 +18,17 @@ export function Pagination(props: { scrollToTop, nextTitle, prevTitle, + className, } = props const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 return ( <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + className={clsx( + 'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6', + className + )} aria-label="Pagination" > <div className="hidden sm:block"> diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx index 507d90c2..4db192a9 100644 --- a/web/components/share-icon-button.tsx +++ b/web/components/share-icon-button.tsx @@ -2,65 +2,48 @@ 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 + onCopyButtonClassName?: string toastClassName?: string - username?: string children?: React.ReactNode + iconClassName?: string + copyPayload: string }) { const { - contract, buttonClassName, + onCopyButtonClassName, toastClassName, - username, - group, children, + iconClassName, + copyPayload, } = props const [showToast, setShowToast] = useState(false) return ( <div className="relative z-10 flex-shrink-0"> <button - className={clsx(contractDetailsButtonClassName, buttonClassName)} + className={clsx( + contractDetailsButtonClassName, + buttonClassName, + showToast ? onCopyButtonClassName : '' + )} onClick={() => { - if (contract) copyContractWithReferral(contract, username) - if (group) copyGroupWithReferral(group, username) + copyToClipboard(copyPayload) track('copy share link') setShowToast(true) setTimeout(() => setShowToast(false), 2000) }} > - <ShareIcon className="h-[24px] w-5" aria-hidden="true" /> + <ShareIcon + className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} + aria-hidden="true" + /> {children} </button> diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index fde50e80..e195936f 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -25,7 +25,7 @@ export const useAlgoFeed = ( getDefaultFeed().then((feed) => setAllFeed(feed)) } else setAllFeed(feed) - trackLatency('feed', getTime()) + trackLatency(user.id, 'feed', getTime()) console.log('"all" feed load time', getTime()) }) diff --git a/web/hooks/use-save-referral.ts b/web/hooks/use-save-referral.ts new file mode 100644 index 00000000..788268b0 --- /dev/null +++ b/web/hooks/use-save-referral.ts @@ -0,0 +1,27 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { User, writeReferralInfo } from 'web/lib/firebase/users' + +export const useSaveReferral = ( + user?: User | null, + options?: { + defaultReferrer?: string + contractId?: string + groupId?: string + } +) => { + const router = useRouter() + + useEffect(() => { + const { referrer } = router.query as { + referrer?: string + } + + const actualReferrer = referrer || options?.defaultReferrer + + if (!user && router.isReady && actualReferrer) { + writeReferralInfo(actualReferrer, options?.contractId, options?.groupId) + } + }, [user, router, options]) +} diff --git a/web/hooks/use-seen-contracts.ts b/web/hooks/use-seen-contracts.ts index 501e7b0c..d21ca84c 100644 --- a/web/hooks/use-seen-contracts.ts +++ b/web/hooks/use-seen-contracts.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Contract } from 'common/contract' import { trackView } from 'web/lib/firebase/tracking' import { useIsVisible } from './use-is-visible' +import { useUser } from './use-user' export const useSeenContracts = () => { const [seenContracts, setSeenContracts] = useState<{ @@ -21,18 +22,19 @@ export const useSaveSeenContract = ( contract: Contract ) => { const isVisible = useIsVisible(elem) + const user = useUser() useEffect(() => { - if (isVisible) { + if (isVisible && user) { const newSeenContracts = { ...getSeenContracts(), [contract.id]: Date.now(), } localStorage.setItem(key, JSON.stringify(newSeenContracts)) - trackView(contract.id) + trackView(user.id, contract.id) } - }, [isVisible, contract]) + }, [isVisible, user, contract]) } const key = 'feed-seen-contracts' diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index e04a69ca..4c492d6c 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' @@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, - listenForLogin, listenForPrivateUser, - listenForUser, User, users, } from 'web/lib/firebase/users' -import { useStateCheckEquality } from './use-state-check-equality' -import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { AuthContext } from 'web/components/auth-context' export const useUser = () => { - const [user, setUser] = useStateCheckEquality<User | null | undefined>( - undefined - ) - - useEffect(() => listenForLogin(setUser), [setUser]) - - useEffect(() => { - if (user) { - identifyUser(user.id) - setUserProperty('username', user.username) - - return listenForUser(user.id, setUser) - } - }, [user, setUser]) - - return user + return useContext(AuthContext) } export const usePrivateUser = (userId?: string) => { diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts index f6ad3aa8..d1828e01 100644 --- a/web/lib/firebase/tracking.ts +++ b/web/lib/firebase/tracking.ts @@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore' import { db } from './init' import { ClickEvent, LatencyEvent, View } from 'common/tracking' -import { listenForLogin, User } from './users' -let user: User | null = null -if (typeof window !== 'undefined') { - listenForLogin((u) => (user = u)) -} - -export async function trackView(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'views')) +export async function trackView(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'views')) const view: View = { contractId, @@ -21,9 +14,8 @@ export async function trackView(contractId: string) { return await setDoc(ref, view) } -export async function trackClick(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'events')) +export async function trackClick(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'events')) const clickEvent: ClickEvent = { type: 'click', @@ -35,11 +27,11 @@ export async function trackClick(contractId: string) { } export async function trackLatency( + userId: string, type: 'feed' | 'portfolio', latency: number ) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'latency')) + const ref = doc(collection(db, 'private-users', userId, 'latency')) const latencyEvent: LatencyEvent = { type, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 89852851..481f86de 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -15,15 +15,10 @@ import { } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' -import { - onIdTokenChanged, - GoogleAuthProvider, - signInWithPopup, -} from 'firebase/auth' +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { createUser } from './api' import { coll, getValue, @@ -37,13 +32,11 @@ import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { removeUndefinedProps } from 'common/util/object' -import { randomString } from 'common/util/random' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { track } from '@amplitude/analytics-browser' -import { deleteAuthCookies, setAuthCookies } from './auth' export const users = coll<User>('users') export const privateUsers = coll<PrivateUser>('private-users') @@ -97,7 +90,6 @@ export function listenForPrivateUser( return listenForValue<PrivateUser>(userRef, setPrivateUser) } -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_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY' @@ -130,7 +122,7 @@ export function writeReferralInfo( local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId) } -async function setCachedReferralInfoForUser(user: User | null) { +export 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() @@ -181,46 +173,6 @@ async function setCachedReferralInfoForUser(user: User | null) { local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) } -// used to avoid weird race condition -let createUserPromise: Promise<User> | undefined = undefined - -export function listenForLogin(onUser: (user: User | null) => void) { - const local = safeLocalStorage() - const cachedUser = local?.getItem(CACHED_USER_KEY) - onUser(cachedUser && JSON.parse(cachedUser)) - - return onIdTokenChanged(auth, async (fbUser) => { - if (fbUser) { - let user: User | null = await getUser(fbUser.uid) - if (!user) { - if (createUserPromise == null) { - const local = safeLocalStorage() - let deviceToken = local?.getItem('device-token') - if (!deviceToken) { - deviceToken = randomString() - local?.setItem('device-token', deviceToken) - } - createUserPromise = createUser({ deviceToken }).then((r) => r as User) - } - user = await createUserPromise - } - onUser(user) - - // 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) - setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) - } else { - // User logged out; reset to null - onUser(null) - createUserPromise = undefined - local?.removeItem(CACHED_USER_KEY) - deleteAuthCookies() - } - }) -} - export async function firebaseLogin() { const provider = new GoogleAuthProvider() return signInWithPopup(auth, provider) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 17453770..11d9af9c 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, writeReferralInfo } from 'web/lib/firebase/users' +import { listUsers, User } from 'web/lib/firebase/users' import { Contract, getContractFromSlug, @@ -43,9 +43,9 @@ import { CPMMBinaryContract } from 'common/contract' 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' import { richTextToString } from 'common/util/parse' +import { useSaveReferral } from 'web/hooks/use-save-referral' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -157,15 +157,10 @@ 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]) + useSaveReferral(user, { + defaultReferrer: contract.creatorUsername, + contractId: contract.id, + }) const rightSidebar = hasSidePanel ? ( <Col className="gap-4"> diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index d081bc9a..52316eb0 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -5,6 +5,7 @@ import Head from 'next/head' import Script from 'next/script' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' +import { AuthProvider } from 'web/components/auth-context' function firstLine(msg: string) { return msg.replace(/\r?\n.*/s, '') @@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) { /> </Head> - <QueryClientProvider client={queryClient}> - <Component {...pageProps} /> - </QueryClientProvider> + <AuthProvider> + <QueryClientProvider client={queryClient}> + <Component {...pageProps} /> + </QueryClientProvider> + </AuthProvider> </> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 0d38580c..90f39e83 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -14,12 +14,7 @@ import { } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' -import { - firebaseLogin, - getUser, - User, - writeReferralInfo, -} from 'web/lib/firebase/users' +import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' @@ -34,7 +29,7 @@ import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' import { CreateQuestionButton } from 'web/components/create-question-button' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' @@ -53,6 +48,7 @@ import { searchInAny } from 'common/util/parse' import { useWindowSize } from 'web/hooks/use-window-size' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' +import { useSaveReferral } from 'web/hooks/use-save-referral' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -155,13 +151,11 @@ export default function GroupPage(props: { const messages = useCommentsOnGroup(group?.id) const user = useUser() - useEffect(() => { - const { referrer } = router.query as { - referrer?: string - } - if (!user && router.isReady) - writeReferralInfo(creator.username, undefined, referrer, group?.id) - }, [user, creator, group, router]) + + useSaveReferral(user, { + defaultReferrer: creator.username, + groupId: group?.id, + }) const { width } = useWindowSize() const chatDisabled = !group || group.chatDisabled diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 53bb6ec9..61003895 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -12,6 +12,7 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { useSaveReferral } from 'web/hooks/use-save-referral' export const getServerSideProps = redirectIfLoggedOut('/') @@ -21,6 +22,8 @@ const Home = () => { const router = useRouter() useTracking('view home') + useSaveReferral() + return ( <> <Page suspend={!!contract}> diff --git a/web/pages/index.tsx b/web/pages/index.tsx index d9ff7f51..fd5cf382 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -7,6 +7,7 @@ import { LandingPagePanel } from 'web/components/landing-page-panel' import { Col } from 'web/components/layout/col' import { ManifoldLogo } from 'web/components/nav/manifold-logo' import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth' +import { useSaveReferral } from 'web/hooks/use-save-referral' export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => { // These hardcoded markets will be shown in the frontpage for signed-out users: @@ -32,6 +33,9 @@ export default function Home(props: { hotContracts: Contract[] }) { // on this page and they log in -- in the future we will make some cleaner way const user = useUser() const router = useRouter() + + useSaveReferral() + useEffect(() => { if (user != null) { router.replace('/home') diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 8ad9850f..119fec77 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -7,6 +7,8 @@ import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' export default function ClaimPage() { const user = useUser() @@ -28,34 +30,42 @@ export default function ClaimPage() { description="Send mana to anyone via link!" url="/send" /> - <div className="mx-auto max-w-xl"> - <Title text={`Claim M$${manalink.amount} mana`} /> - <ManalinkCard - user={user} - info={info} - isClaiming={claiming} - onClaim={async () => { - setClaiming(true) - try { - if (user == null) { - await firebaseLogin() + <div className="mx-auto max-w-xl px-2"> + <Row className="items-center justify-between"> + <Title text={`Claim M$${manalink.amount} mana`} /> + <div className="my-auto"> + <Button + onClick={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + setClaiming(false) + return + } + if (user?.id == manalink.fromId) { + throw new Error("You can't claim your own manalink.") + } + await claimManalink({ slug: manalink.slug }) + user && router.push(`/${user.username}?claimed-mana=yes`) + } catch (e) { + console.log(e) + const message = + e && e instanceof Object + ? e.toString() + : 'An error occurred.' + setError(message) + } setClaiming(false) - return - } - if (user?.id == manalink.fromId) { - throw new Error("You can't claim your own manalink.") - } - await claimManalink({ slug: manalink.slug }) - user && router.push(`/${user.username}?claimed-mana=yes`) - } catch (e) { - console.log(e) - const message = - e && e instanceof Object ? e.toString() : 'An error occurred.' - setError(message) - } - setClaiming(false) - }} - /> + }} + disabled={claiming} + size="lg" + > + {user ? 'Claim' : 'Login'} + </Button> + </div> + </Row> + <ManalinkCard info={info} /> {error && ( <section className="my-5 text-red-500"> <p>Failed to claim manalink.</p> diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 490f1878..8a2e6767 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -1,7 +1,4 @@ -import clsx from 'clsx' import { useState } from 'react' -import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' -import { Claim, Manalink } from 'common/manalink' import { formatMoney } from 'common/util/format' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -11,7 +8,6 @@ import { Title } from 'web/components/title' import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' import { useUserManalinks } from 'web/lib/firebase/manalinks' -import { fromNow } from 'web/lib/util/time' import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' import { Avatar } from 'web/components/avatar' @@ -22,8 +18,11 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' +import { ManalinkCardFromView } from 'web/components/manalink-card' +import { Pagination } from 'web/components/pagination' dayjs.extend(customParseFormat) +const LINKS_PER_PAGE = 24 export const getServerSideProps = redirectIfLoggedOut('/') export function getManalinkUrl(slug: string) { @@ -40,6 +39,10 @@ export default function LinkPage() { (l.maxUses == null || l.claimedUserIds.length < l.maxUses) && (l.expiresTime == null || l.expiresTime > Date.now()) ) + const [page, setPage] = useState(0) + const start = page * LINKS_PER_PAGE + const end = start + LINKS_PER_PAGE + const displayedLinks = unclaimedLinks.slice(start, end) if (user == null) { return null @@ -68,12 +71,30 @@ export default function LinkPage() { don't yet have a Manifold account. </p> <Subtitle text="Your Manalinks" /> - <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> + <Col className="grid w-full gap-4 md:grid-cols-2"> + {displayedLinks.map((link) => { + return ( + <ManalinkCardFromView + link={link} + highlightedSlug={highlightedSlug} + /> + ) + })} + </Col> + <Pagination + page={page} + itemsPerPage={LINKS_PER_PAGE} + totalItems={unclaimedLinks.length} + setPage={setPage} + className="mt-4 bg-transparent" + scrollToTop + /> </Col> </Page> ) } +// TODO: either utilize this or get rid of it export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -121,127 +142,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) { </div> ) } - -function ClaimTableRow(props: { claim: Claim }) { - const { claim } = props - const who = useUserById(claim.toId) - return ( - <tr> - <td className="px-5 py-2">{who?.name || 'Loading...'}</td> - <td className="px-5 py-2">{`${new Date( - claim.claimedTime - ).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td> - </tr> - ) -} - -function LinkDetailsTable(props: { link: Manalink }) { - const { link } = props - return ( - <table className="w-full divide-y divide-gray-300 border border-gray-400"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th className="px-5 py-2">Claimed by</th> - <th className="px-5 py-2">Time</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> - {link.claims.length ? ( - link.claims.map((claim) => <ClaimTableRow claim={claim} />) - ) : ( - <tr> - <td className="px-5 py-2" colSpan={2}> - No claims yet. - </td> - </tr> - )} - </tbody> - </table> - ) -} - -function LinkTableRow(props: { link: Manalink; highlight: boolean }) { - const { link, highlight } = props - const [expanded, setExpanded] = useState(false) - return ( - <> - <LinkSummaryRow - link={link} - highlight={highlight} - expanded={expanded} - onToggle={() => setExpanded((exp) => !exp)} - /> - {expanded && ( - <tr> - <td className="bg-gray-100 p-3" colSpan={5}> - <LinkDetailsTable link={link} /> - </td> - </tr> - )} - </> - ) -} - -function LinkSummaryRow(props: { - link: Manalink - highlight: boolean - expanded: boolean - onToggle: () => void -}) { - const { link, highlight, expanded, onToggle } = props - const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', - highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' - ) - return ( - <tr id={link.slug} key={link.slug} className={className}> - <td className="py-4 pl-5" onClick={onToggle}> - {expanded ? ( - <ChevronUpIcon className="h-5 w-5" /> - ) : ( - <ChevronDownIcon className="h-5 w-5" /> - )} - </td> - - <td className="px-5 py-4 font-medium text-gray-900"> - {formatMoney(link.amount)} - </td> - <td className="px-5 py-4">{getManalinkUrl(link.slug)}</td> - <td className="px-5 py-4">{link.claimedUserIds.length}</td> - <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> - <td className="px-5 py-4"> - {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} - </td> - </tr> - ) -} - -function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { - const { links, highlightedSlug } = props - return links.length == 0 ? ( - <p>You don't currently have any outstanding manalinks.</p> - ) : ( - <div className="overflow-scroll"> - <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th></th> - <th className="px-5 py-3.5">Amount</th> - <th className="px-5 py-3.5">Link</th> - <th className="px-5 py-3.5">Uses</th> - <th className="px-5 py-3.5">Max Uses</th> - <th className="px-5 py-3.5">Expires</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white"> - {links.map((link) => ( - <LinkTableRow - link={link} - highlight={link.slug === highlightedSlug} - /> - ))} - </tbody> - </table> - </div> - ) -} diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx new file mode 100644 index 00000000..c879afaa --- /dev/null +++ b/web/pages/referrals.tsx @@ -0,0 +1,57 @@ +import { Col } from 'web/components/layout/col' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { Page } from 'web/components/page' +import { useTracking } from 'web/hooks/use-tracking' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { REFERRAL_AMOUNT } from 'common/user' +import { CopyLinkButton } from 'web/components/copy-link-button' +import { ENV_CONFIG } from 'common/envs/constants' +import { InfoBox } from 'web/components/info-box' + +export const getServerSideProps = redirectIfLoggedOut('/') + +export default function ReferralsPage() { + const user = useUser() + + useTracking('view referrals') + + const url = `https://${ENV_CONFIG.domain}?referrer=${user?.username}` + + return ( + <Page> + <SEO title="Referrals" description="" url="/add-funds" /> + + <Col className="items-center"> + <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> + <Title className="!mt-0" text="Referrals" /> + <img + className="mb-6 block -scale-x-100 self-center" + src="/logo-flapping-with-money.gif" + width={200} + height={200} + /> + + <div className={'mb-4'}> + Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + sign up! + </div> + + <CopyLinkButton + url={url} + tracking="copy referral link" + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + + <InfoBox + title="FYI" + className="mt-4 max-w-md" + text="You can also earn the referral bonus from sharing the link to any market or group you've created!" + /> + </Col> + </Col> + </Page> + ) +} diff --git a/web/public/logo-flapping-with-money.gif b/web/public/logo-flapping-with-money.gif new file mode 100644 index 00000000..0ef936a4 Binary files /dev/null and b/web/public/logo-flapping-with-money.gif differ diff --git a/web/public/mtg/index.html b/web/public/mtg/index.html index 5fd31966..62849462 100644 --- a/web/public/mtg/index.html +++ b/web/public/mtg/index.html @@ -152,7 +152,7 @@ <details id="addl-options"> <summary> <img - src="http://mythicspoiler.com/images/buttons/ustset.png" + src="https://mythicspoiler.com/images/buttons/ustset.png" style="width: 32px; vertical-align: top" /> Options