Merge branch 'main' into editor-embed
This commit is contained in:
commit
0b2e83f75f
|
@ -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
|
||||
|
|
77
web/components/auth-context.tsx
Normal file
77
web/components/auth-context.tsx
Normal file
|
@ -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<AuthUser>(null)
|
||||
|
||||
export function AuthProvider({ children }: any) {
|
||||
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(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 (
|
||||
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
||||
)
|
||||
}
|
|
@ -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 <LoadingIndicator />
|
||||
|
|
|
@ -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<string | undefined>(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 })}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
|
@ -204,7 +210,7 @@ export function ContractSearch(props: {
|
|||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort')}
|
||||
onBlur={trackCallback('select search sort', { sort })}
|
||||
/>
|
||||
)}
|
||||
<Configure
|
||||
|
@ -222,14 +228,14 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'all'}
|
||||
selected={pillFilter === undefined}
|
||||
onSelect={() => setPillFilter(undefined)}
|
||||
onSelect={selectFilter(undefined)}
|
||||
>
|
||||
All
|
||||
</PillButton>
|
||||
<PillButton
|
||||
key={'personal'}
|
||||
selected={pillFilter === 'personal'}
|
||||
onSelect={() => setPillFilter('personal')}
|
||||
onSelect={selectFilter('personal')}
|
||||
>
|
||||
For you
|
||||
</PillButton>
|
||||
|
@ -237,7 +243,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'your-bets'}
|
||||
selected={pillFilter === 'your-bets'}
|
||||
onSelect={() => setPillFilter('your-bets')}
|
||||
onSelect={selectFilter('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
</PillButton>
|
||||
|
@ -247,7 +253,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={slug}
|
||||
selected={pillFilter === slug}
|
||||
onSelect={() => setPillFilter(slug)}
|
||||
onSelect={selectFilter(slug)}
|
||||
>
|
||||
{name}
|
||||
</PillButton>
|
||||
|
|
|
@ -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: {
|
|||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
<ShareIconButton
|
||||
contract={contract}
|
||||
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||
user?.username && contract.creatorUsername !== user?.username
|
||||
? '?referrer=' + user?.username
|
||||
: ''
|
||||
}`}
|
||||
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||
username={user?.username}
|
||||
/>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -70,7 +70,7 @@ export function FeedCommentThread(props: {
|
|||
if (showReply && inputRef) inputRef.focus()
|
||||
}, [inputRef, showReply])
|
||||
return (
|
||||
<div className={'w-full flex-col pr-1'}>
|
||||
<Col className={'w-full gap-3 pr-1'}>
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
|
@ -86,7 +86,7 @@ export function FeedCommentThread(props: {
|
|||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||
/>
|
||||
{showReply && (
|
||||
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
|
||||
<Col className={'-pb-2 ml-6'}>
|
||||
<span
|
||||
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
|
@ -106,9 +106,9 @@ export function FeedCommentThread(props: {
|
|||
setReplyToUsername('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<div className={'flex gap-2'}>
|
||||
|
@ -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}
|
||||
|
|
30
web/components/info-box.tsx
Normal file
30
web/components/info-box.tsx
Normal file
|
@ -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 (
|
||||
<div className={clsx('rounded-md bg-gray-50 p-4', className)}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-black">{title}</h3>
|
||||
<div className="mt-2 text-sm text-black">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<div
|
||||
<Col>
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br shadow-lg transition-all',
|
||||
getManalinkGradient(info.amount)
|
||||
)}
|
||||
>
|
||||
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
|
||||
<div>
|
||||
{maxUses != null
|
||||
? `${maxUses - uses}/${maxUses} uses left`
|
||||
: `Unlimited use`}
|
||||
</div>
|
||||
<div>
|
||||
{expiresTime != null
|
||||
? `Expires ${fromNow(expiresTime)}`
|
||||
: 'Never expires'}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<img
|
||||
className={clsx(
|
||||
'block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12',
|
||||
preview ? 'my-2' : 'w-1/2 md:mb-6 md:h-1/2'
|
||||
)}
|
||||
src="/logo-white.svg"
|
||||
/>
|
||||
<Row className="rounded-b-xl bg-white p-4">
|
||||
<Col>
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-1 text-xl text-indigo-500',
|
||||
getManalinkAmountColor(amount)
|
||||
)}
|
||||
>
|
||||
{formatMoney(amount)}
|
||||
</div>
|
||||
<div>{message}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Col
|
||||
className={clsx(
|
||||
'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
|
||||
className,
|
||||
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
|
||||
link.slug === highlightedSlug ? 'animate-pulse' : ''
|
||||
)}
|
||||
>
|
||||
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
|
||||
<div>
|
||||
{maxUses != null
|
||||
? `${maxUses - uses}/${maxUses} uses left`
|
||||
: `Unlimited use`}
|
||||
</div>
|
||||
<div>
|
||||
{expiresTime != null
|
||||
? `Expires ${fromNow(expiresTime)}`
|
||||
: 'Never expires'}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<img
|
||||
className="mb-6 block self-center transition-all group-hover:rotate-12"
|
||||
src="/logo-white.svg"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<Row className="justify-end rounded-b-xl bg-white p-4">
|
||||
<Col>
|
||||
<div className="mb-1 text-xl text-indigo-500">
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex flex-col rounded-t-lg bg-gradient-to-br transition-all',
|
||||
getManalinkGradient(link.amount)
|
||||
)}
|
||||
onClick={() => setDetails(!details)}
|
||||
>
|
||||
{details && (
|
||||
<ClaimsList
|
||||
className="absolute h-full w-full bg-white opacity-90"
|
||||
link={link}
|
||||
/>
|
||||
)}
|
||||
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
|
||||
<div>
|
||||
{maxUses != null
|
||||
? `${maxUses - claims.length}/${maxUses} uses left`
|
||||
: `Unlimited use`}
|
||||
</div>
|
||||
<div>
|
||||
{expiresTime != null
|
||||
? `Expires ${fromNow(expiresTime)}`
|
||||
: 'Never expires'}
|
||||
</div>
|
||||
</Col>
|
||||
<img
|
||||
className={clsx('my-auto block w-1/3 select-none self-center py-3')}
|
||||
src="/logo-white.svg"
|
||||
/>
|
||||
</div>
|
||||
<Col className="w-full rounded-b-lg bg-white px-4 py-2 text-lg">
|
||||
<Row className="relative gap-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'my-auto mb-1 w-full',
|
||||
getManalinkAmountColor(amount)
|
||||
)}
|
||||
>
|
||||
{formatMoney(amount)}
|
||||
</div>
|
||||
<div>{message}</div>
|
||||
</Col>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button onClick={onClaim} disabled={isClaiming}>
|
||||
{user ? 'Claim' : 'Login'}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
<ShareIconButton
|
||||
toastClassName={'-left-48 min-w-[250%]'}
|
||||
buttonClassName={'transition-colors'}
|
||||
onCopyButtonClassName={
|
||||
'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600'
|
||||
}
|
||||
copyPayload={getManalinkUrl(link.slug)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setDetails(!details)}
|
||||
className={clsx(
|
||||
contractDetailsButtonClassName,
|
||||
details
|
||||
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-[24px] w-5" />
|
||||
</button>
|
||||
</Row>
|
||||
<div className="my-2 text-xs md:text-sm">{message || '\n\n'}</div>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
|
||||
)}
|
||||
>
|
||||
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
|
||||
<div>
|
||||
{maxUses != null
|
||||
? `${maxUses - uses}/${maxUses} uses left`
|
||||
: `Unlimited use`}
|
||||
<>
|
||||
<Col className={clsx('px-4 py-2', className)}>
|
||||
<div className="text-md mb-1 mt-2 w-full font-semibold">
|
||||
Claimed by...
|
||||
</div>
|
||||
<div>
|
||||
{expiresTime != null
|
||||
? `Expires ${fromNow(expiresTime)}`
|
||||
: 'Never expires'}
|
||||
<div className="overflow-auto">
|
||||
{link.claims.length > 0 ? (
|
||||
<>
|
||||
{link.claims.map((claim) => (
|
||||
<Row key={claim.txnId}>
|
||||
<Claim claim={claim} />
|
||||
</Row>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
No one has claimed this manalink yet! Share your manalink to start
|
||||
spreading the wealth.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<img
|
||||
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12"
|
||||
src="/logo-white.svg"
|
||||
/>
|
||||
<Row className="rounded-b-lg bg-white p-2">
|
||||
<Col className="text-md">
|
||||
<div className="mb-1 text-indigo-500">{formatMoney(amount)}</div>
|
||||
<div className="text-xs">{message}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Claim(props: { claim: Claim }) {
|
||||
const { claim } = props
|
||||
const who = useUserById(claim.toId)
|
||||
return (
|
||||
<Row className="my-1 gap-2 text-xs">
|
||||
<div>{who?.name || 'Loading...'}</div>
|
||||
<div className="text-gray-500">{fromNow(claim.claimedTime)}</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
@ -191,7 +192,7 @@ function CreateManalinkForm(props: {
|
|||
{finishedCreating && (
|
||||
<>
|
||||
<Title className="!my-0" text="Manalink Created!" />
|
||||
<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',
|
||||
|
|
|
@ -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: '#',
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
|
||||
|
|
27
web/hooks/use-save-referral.ts
Normal file
27
web/hooks/use-save-referral.ts
Normal file
|
@ -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])
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
57
web/pages/referrals.tsx
Normal file
57
web/pages/referrals.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
BIN
web/public/logo-flapping-with-money.gif
Normal file
BIN
web/public/logo-flapping-with-money.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 287 KiB |
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user