Merge branch 'main' into challenges

This commit is contained in:
Ian Philips 2022-07-21 15:35:16 -06:00 committed by GitHub
commit 87df5a0783
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2145 additions and 707 deletions

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash' import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = { export const CATEGORIES = {
politics: 'Politics', politics: 'Politics',
technology: 'Technology', technology: 'Technology',
@ -37,3 +38,8 @@ export const EXCLUDED_CATEGORIES: category[] = [
] ]
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

View File

@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 export const UNIQUE_BETTOR_BONUS_AMOUNT = 10

View File

@ -1,3 +1,4 @@
# Ignore Next artifacts # Ignore Next artifacts
.next/ .next/
out/ out/
public/**/*.json

View 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>
)
}

View File

@ -78,10 +78,10 @@ export function BetsList(props: {
const getTime = useTimeSinceFirstRender() const getTime = useTimeSinceFirstRender()
useEffect(() => { useEffect(() => {
if (bets && contractsById) { if (bets && contractsById && signedInUser) {
trackLatency('portfolio', getTime()) trackLatency(signedInUser.id, 'portfolio', getTime())
} }
}, [bets, contractsById, getTime]) }, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) { if (!bets || !contractsById) {
return <LoadingIndicator /> return <LoadingIndicator />

View File

@ -13,7 +13,7 @@ export function PillButton(props: {
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full',
selected selected
? ['text-white', color ?? 'bg-gray-700'] ? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200', : 'bg-gray-100 hover:bg-gray-200',

View File

@ -22,12 +22,13 @@ import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows' 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 ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { toPairs } from 'lodash' import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -49,13 +50,6 @@ const sortIndexes = [
export const DEFAULT_SORT = 'score' export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
const filterOptions: { [label: string]: filter } = {
All: 'all',
Open: 'open',
Closed: 'closed',
Resolved: 'resolved',
'For you': 'personal',
}
export function ContractSearch(props: { export function ContractSearch(props: {
querySortOptions?: { querySortOptions?: {
@ -86,9 +80,24 @@ export function ContractSearch(props: {
} = props } = props
const user = useUser() const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
?.map((g) => g.slug) (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) )
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions) const { initialSort } = useInitialQueryAndSort(querySortOptions)
@ -101,24 +110,20 @@ export function ContractSearch(props: {
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' 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(() => { const { filters, numericFilters } = useMemo(() => {
let filters = [ let filters = [
filter === 'open' ? 'isResolved:false' : '', filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal'
? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
)
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '',
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
: '', : '',
@ -126,6 +131,26 @@ export function ContractSearch(props: {
additionalFilter?.groupSlug additionalFilter?.groupSlug
? `groupSlugs:${additionalFilter.groupSlug}` ? `groupSlugs:${additionalFilter.groupSlug}`
: '', : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupSlugs:${pillFilter}`
: '',
pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupSlugs:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f) ].filter((f) => f)
// Hack to make Algolia work. // Hack to make Algolia work.
filters = ['', ...filters] filters = ['', ...filters]
@ -139,8 +164,9 @@ export function ContractSearch(props: {
}, [ }, [
filter, filter,
Object.values(additionalFilter ?? {}).join(','), Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','), memberGroupSlugs.join(','),
(follows ?? []).join(','), (follows ?? []).join(','),
pillFilter,
]) ])
const indexName = `${indexPrefix}contracts-${sort}` const indexName = `${indexPrefix}contracts-${sort}`
@ -167,13 +193,24 @@ export function ContractSearch(props: {
}} }}
/> />
{/*// TODO track WHICH filter users are using*/} {/*// TODO track WHICH filter users are using*/}
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter', { filter })}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && ( {!hideOrderSelector && (
<SortBy <SortBy
items={sortIndexes} items={sortIndexes}
classNames={{ classNames={{
select: '!select !select-bordered', select: '!select !select-bordered',
}} }}
onBlur={trackCallback('select search sort')} onBlur={trackCallback('select search sort', { sort })}
/> />
)} )}
<Configure <Configure
@ -186,25 +223,50 @@ export function ContractSearch(props: {
<Spacer h={3} /> <Spacer h={3} />
<Row className="gap-2"> {pillsEnabled && (
{toPairs<filter>(filterOptions).map(([label, f]) => { <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectFilter(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectFilter('personal')}
>
For you
</PillButton>
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectFilter('your-bets')}
>
Your bets
</PillButton>
{pillGroups.map(({ name, slug }) => {
return ( return (
<PillButton <PillButton
key={f} key={slug}
selected={filter === f} selected={pillFilter === slug}
onSelect={() => setFilter(f)} onSelect={selectFilter(slug)}
> >
{label} {name}
</PillButton> </PillButton>
) )
})} })}
</Row> </Row>
)}
<Spacer h={3} /> <Spacer h={3} />
{filter === 'personal' && {filter === 'personal' &&
(follows ?? []).length === 0 && (follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? ( memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</> <>You're not following anyone, nor in any of your own groups yet.</>
) : ( ) : (
<ContractSearchInner <ContractSearchInner

View File

@ -11,6 +11,7 @@ import { UserLink } from '../user-page'
import { import {
Contract, Contract,
contractMetrics, contractMetrics,
contractPath,
contractPool, contractPool,
updateContract, updateContract,
} from 'web/lib/firebase/contracts' } 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 { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
import { ENV_CONFIG } from 'common/envs/constants'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -222,9 +224,12 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div> <div className="whitespace-nowrap">{volumeLabel}</div>
</Row> </Row>
<ShareIconButton <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%]'} toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/> />
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />} {!disabled && <ContractInfoDialog contract={contract} bets={bets} />}

View File

@ -16,7 +16,6 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title' import { Title } from '../title'
import { TweetButton } from '../tweet-button' import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip' import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody> </tbody>
</table> </table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} /> <LiquidityPanel contract={contract} />
)} )}

View File

@ -2,26 +2,37 @@ import React, { Fragment, useEffect } from 'react'
import { LinkIcon } from '@heroicons/react/outline' import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
export function CopyLinkButton(props: { export function CopyLinkButton(props: {
link: string url: string
onCopy?: () => void displayUrl?: string
tracking?: string
buttonClassName?: string buttonClassName?: string
toastClassName?: string toastClassName?: string
icon?: React.ComponentType<{ className?: string }> icon?: React.ComponentType<{ className?: string }>
label?: string label?: string
}) { }) {
const { onCopy, link, buttonClassName, toastClassName, label } = props const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return ( return (
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={displayUrl ?? url}
/>
<Menu <Menu
as="div" as="div"
className="relative z-10 flex-shrink-0" className="relative z-10 flex-shrink-0"
onMouseUp={() => { onMouseUp={() => {
copyToClipboard(link) copyToClipboard(url)
onCopy?.() track(tracking ?? 'copy share link')
}} }}
> >
<Menu.Button <Menu.Button
@ -30,11 +41,8 @@ export function CopyLinkButton(props: {
buttonClassName buttonClassName
)} )}
> >
{!props.icon && (
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
)} Copy link
{props.icon && <props.icon className={'h-4 w-4'} />}
{label ?? 'Copy link'}
</Menu.Button> </Menu.Button>
<Transition <Transition
@ -53,5 +61,6 @@ export function CopyLinkButton(props: {
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
</Row>
) )
} }

View File

@ -1,18 +1,13 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import { import {
CommentInput, CommentInput,
CommentRepliesList, CommentRepliesList,
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
import { groupBy } from 'lodash' import { groupBy } from 'lodash'
import { User } from 'common/user' import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: { export function FeedAnswerCommentGroup(props: {
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('') const [replyToUsername, setReplyToUsername] = useState('')
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
const commentsList = comments.filter( const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString() (comment) => comment.answerOutcome === answer.number.toString()
) )
const thisAnswerProb = getDpmOutcomeProbability(
contract.totalShares,
answer.id
)
const probPercent = formatPercent(thisAnswerProb)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser const isFreeResponseContractPage = !!commentsByCurrentUser
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath]) }, [answerElementId, router.asPath])
return ( return (
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}> <Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<Row <Row
className={clsx( className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000', 'flex gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)} )}
id={answerElementId} id={answerElementId}
> >
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} /> <Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1"> <Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered <UserLink username={username} name={name} /> answered
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
/> />
</div> </div>
<Col className="align-items justify-between gap-4 sm:flex-row"> <Col className="align-items justify-between gap-2 sm:flex-row">
<span className="whitespace-pre-line text-lg"> <span className="whitespace-pre-line text-lg">
<Linkify text={text} /> <Linkify text={text} />
</span> </span>
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && ( {isFreeResponseContractPage && (
<div className={'sm:hidden'}> <div className={'sm:hidden'}>
<button <button
className={ className={'text-xs font-bold text-gray-500 hover:underline'}
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => scrollAndOpenReplyInput(undefined, answer)} onClick={() => scrollAndOpenReplyInput(undefined, answer)}
> >
Reply Reply
</button> </button>
</div> </div>
)} )}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col> </Col>
{isFreeResponseContractPage && ( {isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}> <div className={'justify-initial hidden sm:block'}>
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
/> />
{showReply && ( {showReply && (
<div className={'ml-6 pt-4'}> <div className={'ml-6'}>
<span <span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <CommentInput

View File

@ -93,6 +93,24 @@ export function BetStatusText(props: {
bet.fills?.some((fill) => fill.matchedBetId === null)) ?? bet.fills?.some((fill) => fill.matchedBetId === null)) ??
false false
const fromProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probBefore, contract)
: formatPercent(bet.probBefore)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract)
: formatPercent(bet.limitProb ?? bet.probBefore)
const toProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probAfter, contract)
: formatPercent(bet.probAfter)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract)
: formatPercent(bet.limitProb ?? bet.probAfter)
return ( return (
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{bettor ? ( {bettor ? (
@ -112,14 +130,9 @@ export function BetStatusText(props: {
contract={contract} contract={contract}
truncate="short" truncate="short"
/>{' '} />{' '}
{isPseudoNumeric {fromProb === toProb
? ' than ' + formatNumericProbability(bet.probAfter, contract) ? `at ${fromProb}`
: ' at ' + : `from ${fromProb} to ${toProb}`}
formatPercent(
hadPoolMatch || isFreeResponse
? bet.probAfter
: bet.limitProb ?? bet.probAfter
)}
</> </>
)} )}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />

View File

@ -70,7 +70,7 @@ export function FeedCommentThread(props: {
if (showReply && inputRef) inputRef.focus() if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply]) }, [inputRef, showReply])
return ( return (
<div className={'w-full flex-col pr-1'}> <Col className={'w-full gap-3 pr-1'}>
<span <span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true" aria-hidden="true"
@ -86,7 +86,7 @@ export function FeedCommentThread(props: {
scrollAndOpenReplyInput={scrollAndOpenReplyInput} scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/> />
{showReply && ( {showReply && (
<div className={'-pb-2 ml-6 flex flex-col pt-5'}> <Col className={'-pb-2 ml-6'}>
<span <span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
@ -106,9 +106,9 @@ export function FeedCommentThread(props: {
setReplyToUsername('') setReplyToUsername('')
}} }}
/> />
</div> </Col>
)} )}
</div> </Col>
) )
} }
@ -142,7 +142,7 @@ export function CommentRepliesList(props: {
id={comment.id} id={comment.id}
className={clsx( className={clsx(
'relative', 'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6' !treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
)} )}
> >
{/*draw a gray line from the comment to the left:*/} {/*draw a gray line from the comment to the left:*/}

View File

@ -23,6 +23,7 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items' import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking' import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
@ -118,6 +119,7 @@ export function FeedQuestion(props: {
const { volumeLabel } = contractMetrics(contract) const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const user = useUser()
return ( return (
<div className={'flex gap-2'}> <div className={'flex gap-2'}>
@ -149,7 +151,7 @@ export function FeedQuestion(props: {
href={ href={
props.contractPath ? props.contractPath : contractPath(contract) 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" className="text-lg text-indigo-700 sm:text-xl"
> >
{question} {question}

View 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>
)
}

View File

@ -3,9 +3,13 @@ import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { User } from 'web/lib/firebase/users' import { Claim, Manalink } from 'common/manalink'
import { Button } from './button' 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 = { export type ManalinkInfo = {
expiresTime: number | null expiresTime: number | null
maxUses: number | null maxUses: number | null
@ -15,19 +19,19 @@ export type ManalinkInfo = {
} }
export function ManalinkCard(props: { export function ManalinkCard(props: {
user: User | null | undefined
className?: string
info: ManalinkInfo info: ManalinkInfo
isClaiming: boolean className?: string
onClaim?: () => void preview?: boolean
}) { }) {
const { user, className, isClaiming, info, onClaim } = props const { className, info, preview = false } = props
const { expiresTime, maxUses, uses, amount, message } = info const { expiresTime, maxUses, uses, amount, message } = info
return ( return (
<Col>
<div <div
className={clsx( className={clsx(
className, 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' '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"> <Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
@ -44,46 +48,64 @@ export function ManalinkCard(props: {
</Col> </Col>
<img <img
className="mb-6 block self-center transition-all group-hover:rotate-12" 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" src="/logo-white.svg"
width={200}
height={200}
/> />
<Row className="justify-end rounded-b-xl bg-white p-4"> <Row className="rounded-b-xl bg-white p-4">
<Col> <Col>
<div className="mb-1 text-xl text-indigo-500"> <div
className={clsx(
'mb-1 text-xl text-indigo-500',
getManalinkAmountColor(amount)
)}
>
{formatMoney(amount)} {formatMoney(amount)}
</div> </div>
<div>{message}</div> <div>{message}</div>
</Col> </Col>
<div className="ml-auto">
<Button onClick={onClaim} disabled={isClaiming}>
{user ? 'Claim' : 'Login'}
</Button>
</div>
</Row> </Row>
</div> </div>
</Col>
) )
} }
export function ManalinkCardPreview(props: { export function ManalinkCardFromView(props: {
className?: string className?: string
info: ManalinkInfo link: Manalink
highlightedSlug: string
}) { }) {
const { className, info } = props const { className, link, highlightedSlug } = props
const { expiresTime, maxUses, uses, amount, message } = info const { message, amount, expiresTime, maxUses, claims } = link
const [details, setDetails] = useState(false)
return ( return (
<div <Col
className={clsx( className={clsx(
'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
className, className,
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all' link.slug === highlightedSlug ? 'animate-pulse' : ''
)} )}
> >
<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"> <Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div> <div>
{maxUses != null {maxUses != null
? `${maxUses - uses}/${maxUses} uses left` ? `${maxUses - claims.length}/${maxUses} uses left`
: `Unlimited use`} : `Unlimited use`}
</div> </div>
<div> <div>
@ -92,17 +114,107 @@ export function ManalinkCardPreview(props: {
: 'Never expires'} : 'Never expires'}
</div> </div>
</Col> </Col>
<img <img
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12" className={clsx('my-auto block w-1/3 select-none self-center py-3')}
src="/logo-white.svg" 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> </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>
<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>
) )
} }
function ClaimsList(props: { link: Manalink; className: string }) {
const { link, className } = props
return (
<>
<Col className={clsx('px-4 py-2', className)}>
<div className="text-md mb-1 mt-2 w-full font-semibold">
Claimed by...
</div>
<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>
</>
)
}
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'
}
}

View File

@ -4,7 +4,7 @@ import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Title } from '../title' import { Title } from '../title'
import { User } from 'common/user' 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 { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
@ -37,6 +37,7 @@ export function CreateLinksButton(props: {
message: newManalink.message, message: newManalink.message,
}) })
setHighlightedSlug(slug || '') setHighlightedSlug(slug || '')
setTimeout(() => setHighlightedSlug(''), 3700)
}} }}
/> />
</Col> </Col>
@ -191,7 +192,7 @@ function CreateManalinkForm(props: {
{finishedCreating && ( {finishedCreating && (
<> <>
<Title className="!my-0" text="Manalink Created!" /> <Title className="!my-0" text="Manalink Created!" />
<ManalinkCardPreview className="my-4" info={newManalink} /> <ManalinkCard className="my-4" info={newManalink} preview />
<Row <Row
className={clsx( className={clsx(
'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700',

View File

@ -63,10 +63,11 @@ function getMoreNavigation(user?: User | null) {
} }
return [ return [
{ name: 'Send M$', href: '/links' },
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Challenges', href: '/challenges' }, { name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{ {
@ -112,15 +113,17 @@ const signedInMobileNavigation = [
function getMoreMobileNav() { function getMoreMobileNav() {
return [ return [
{ name: 'Leaderboards', href: '/leaderboards' },
...(IS_PRIVATE_MANIFOLD ...(IS_PRIVATE_MANIFOLD
? [] ? []
: [ : [
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Challenges', href: '/challenges' }, { name: 'Challenges', href: '/challenges' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
]), ]),
{ name: 'Leaderboards', href: '/leaderboards' },
{ {
name: 'Sign out', name: 'Sign out',
href: '#', href: '#',
@ -238,7 +241,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
)} )}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}
@ -259,11 +265,7 @@ export default function Sidebar(props: { className?: string }) {
/> />
{/* Spacer if there are any groups */} {/* Spacer if there are any groups */}
{memberItems.length > 0 && ( {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
{privateUser && ( {privateUser && (
<GroupsList <GroupsList
currentPage={router.asPath} currentPage={router.asPath}

View File

@ -1,9 +1,12 @@
import clsx from 'clsx'
export function Pagination(props: { export function Pagination(props: {
page: number page: number
itemsPerPage: number itemsPerPage: number
totalItems: number totalItems: number
setPage: (page: number) => void setPage: (page: number) => void
scrollToTop?: boolean scrollToTop?: boolean
className?: string
nextTitle?: string nextTitle?: string
prevTitle?: string prevTitle?: string
}) { }) {
@ -15,13 +18,17 @@ export function Pagination(props: {
scrollToTop, scrollToTop,
nextTitle, nextTitle,
prevTitle, prevTitle,
className,
} = props } = props
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
return ( return (
<nav <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" aria-label="Pagination"
> >
<div className="hidden sm:block"> <div className="hidden sm:block">

View File

@ -2,65 +2,48 @@ import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline' import { ShareIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy' 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 { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' 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: { export function ShareIconButton(props: {
contract?: Contract
group?: Group
buttonClassName?: string buttonClassName?: string
onCopyButtonClassName?: string
toastClassName?: string toastClassName?: string
username?: string
children?: React.ReactNode children?: React.ReactNode
iconClassName?: string
copyPayload: string
}) { }) {
const { const {
contract,
buttonClassName, buttonClassName,
onCopyButtonClassName,
toastClassName, toastClassName,
username,
group,
children, children,
iconClassName,
copyPayload,
} = props } = props
const [showToast, setShowToast] = useState(false) const [showToast, setShowToast] = useState(false)
return ( return (
<div className="relative z-10 flex-shrink-0"> <div className="relative z-10 flex-shrink-0">
<button <button
className={clsx(contractDetailsButtonClassName, buttonClassName)} className={clsx(
contractDetailsButtonClassName,
buttonClassName,
showToast ? onCopyButtonClassName : ''
)}
onClick={() => { onClick={() => {
if (contract) copyContractWithReferral(contract, username) copyToClipboard(copyPayload)
if (group) copyGroupWithReferral(group, username)
track('copy share link') track('copy share link')
setShowToast(true) setShowToast(true)
setTimeout(() => setShowToast(false), 2000) 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} {children}
</button> </button>

View File

@ -1,4 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button' import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col' import { Col } from './layout/col'
@ -10,19 +11,15 @@ import { track } from 'web/lib/service/analytics'
export function ShareMarket(props: { contract: Contract; className?: string }) { export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return ( return (
<Col className={clsx(className, 'gap-3')}> <Col className={clsx(className, 'gap-3')}>
<div>Share your market</div> <div>Share your market</div>
<Row className="mb-6 items-center"> <Row className="mb-6 items-center">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={contractUrl(contract)}
/>
<CopyLinkButton <CopyLinkButton
link={`https://${ENV_CONFIG.domain}${contractPath(contract)}`} url={url}
onCopy={() => track('copy share link')} displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none" buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'} toastClassName={'-left-28 mt-1'}
/> />

View File

@ -25,7 +25,7 @@ export const useAlgoFeed = (
getDefaultFeed().then((feed) => setAllFeed(feed)) getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed) } else setAllFeed(feed)
trackLatency('feed', getTime()) trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime()) console.log('"all" feed load time', getTime())
}) })

View 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])
}

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { trackView } from 'web/lib/firebase/tracking' import { trackView } from 'web/lib/firebase/tracking'
import { useIsVisible } from './use-is-visible' import { useIsVisible } from './use-is-visible'
import { useUser } from './use-user'
export const useSeenContracts = () => { export const useSeenContracts = () => {
const [seenContracts, setSeenContracts] = useState<{ const [seenContracts, setSeenContracts] = useState<{
@ -21,18 +22,19 @@ export const useSaveSeenContract = (
contract: Contract contract: Contract
) => { ) => {
const isVisible = useIsVisible(elem) const isVisible = useIsVisible(elem)
const user = useUser()
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible && user) {
const newSeenContracts = { const newSeenContracts = {
...getSeenContracts(), ...getSeenContracts(),
[contract.id]: Date.now(), [contract.id]: Date.now(),
} }
localStorage.setItem(key, JSON.stringify(newSeenContracts)) localStorage.setItem(key, JSON.stringify(newSeenContracts))
trackView(contract.id) trackView(user.id, contract.id)
} }
}, [isVisible, contract]) }, [isVisible, user, contract])
} }
const key = 'feed-seen-contracts' const key = 'feed-seen-contracts'

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query' import { QueryClient } from 'react-query'
@ -6,32 +6,14 @@ import { doc, DocumentData, where } from 'firebase/firestore'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
import { import {
getUser, getUser,
listenForLogin,
listenForPrivateUser, listenForPrivateUser,
listenForUser,
User, User,
users, users,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality' import { AuthContext } from 'web/components/auth-context'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
export const useUser = () => { export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>( return useContext(AuthContext)
undefined
)
useEffect(() => listenForLogin(setUser), [setUser])
useEffect(() => {
if (user) {
identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user
} }
export const usePrivateUser = (userId?: string) => { export const usePrivateUser = (userId?: string) => {

View File

@ -1,32 +1,28 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { PrivateUser, User } from 'common/user' import { PrivateUser, User } from 'common/user'
import {
listenForAllUsers,
listenForPrivateUsers,
} from 'web/lib/firebase/users'
import { groupBy, sortBy, difference } from 'lodash' import { groupBy, sortBy, difference } from 'lodash'
import { getContractsOfUserBets } from 'web/lib/firebase/bets' import { getContractsOfUserBets } from 'web/lib/firebase/bets'
import { useFollows } from './use-follows' import { useFollows } from './use-follows'
import { useUser } from './use-user' import { useUser } from './use-user'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DocumentData } from 'firebase/firestore'
import { users, privateUsers } from 'web/lib/firebase/users'
export const useUsers = () => { export const useUsers = () => {
const [users, setUsers] = useState<User[]>([]) const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
subscribe: true,
useEffect(() => { includeMetadataChanges: true,
listenForAllUsers(setUsers) })
}, []) return result.data ?? []
return users
} }
export const usePrivateUsers = () => { export const usePrivateUsers = () => {
const [users, setUsers] = useState<PrivateUser[]>([]) const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
['private users'],
useEffect(() => { privateUsers,
listenForPrivateUsers(setUsers) { subscribe: true, includeMetadataChanges: true }
}, []) )
return result.data || []
return users
} }
export const useDiscoverUsers = (userId: string | null | undefined) => { export const useDiscoverUsers = (userId: string | null | undefined) => {

View File

@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
export function groupPath( export function groupPath(
groupSlug: string, groupSlug: string,
subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings' subpath?:
| 'edit'
| 'questions'
| 'about'
| typeof GROUP_CHAT_SLUG
| 'leaderboards'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }

View File

@ -52,12 +52,19 @@ export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
if (idToken != null) { if (idToken != null) {
try { try {
return (await auth.verifyIdToken(idToken))?.uid return (await auth.verifyIdToken(idToken))?.uid
} catch (e) { } catch {
// plausibly expired; try the refresh token, if it's present
}
}
if (refreshToken != null) { if (refreshToken != null) {
try {
const resp = await requestFirebaseIdToken(refreshToken) const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
return (await auth.verifyIdToken(resp.id_token))?.uid return (await auth.verifyIdToken(resp.id_token))?.uid
} } catch (e) {
// this is a big unexpected problem -- either their cookies are corrupt
// or the refresh token API is down. functionally, they are not logged in
console.error(e)
} }
} }
return undefined return undefined

View File

@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore'
import { db } from './init' import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking' import { ClickEvent, LatencyEvent, View } from 'common/tracking'
import { listenForLogin, User } from './users'
let user: User | null = null export async function trackView(userId: string, contractId: string) {
if (typeof window !== 'undefined') { const ref = doc(collection(db, 'private-users', userId, 'views'))
listenForLogin((u) => (user = u))
}
export async function trackView(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'views'))
const view: View = { const view: View = {
contractId, contractId,
@ -21,9 +14,8 @@ export async function trackView(contractId: string) {
return await setDoc(ref, view) return await setDoc(ref, view)
} }
export async function trackClick(contractId: string) { export async function trackClick(userId: string, contractId: string) {
if (!user) return const ref = doc(collection(db, 'private-users', userId, 'events'))
const ref = doc(collection(db, 'private-users', user.id, 'events'))
const clickEvent: ClickEvent = { const clickEvent: ClickEvent = {
type: 'click', type: 'click',
@ -35,11 +27,11 @@ export async function trackClick(contractId: string) {
} }
export async function trackLatency( export async function trackLatency(
userId: string,
type: 'feed' | 'portfolio', type: 'feed' | 'portfolio',
latency: number latency: number
) { ) {
if (!user) return const ref = doc(collection(db, 'private-users', userId, 'latency'))
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
const latencyEvent: LatencyEvent = { const latencyEvent: LatencyEvent = {
type, type,

View File

@ -15,15 +15,10 @@ import {
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
onIdTokenChanged,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { zip } from 'lodash' import { zip } from 'lodash'
import { app, db } from './init' import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { createUser } from './api'
import { import {
coll, coll,
getValue, getValue,
@ -37,13 +32,11 @@ import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { addUserToGroupViaId } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { randomString } from 'common/util/random'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { deleteAuthCookies, setAuthCookies } from './auth'
export const users = coll<User>('users') export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users') export const privateUsers = coll<PrivateUser>('private-users')
@ -97,7 +90,6 @@ export function listenForPrivateUser(
return listenForValue<PrivateUser>(userRef, setPrivateUser) return listenForValue<PrivateUser>(userRef, setPrivateUser)
} }
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_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) 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 (!user || user.referredByUserId) return
// if the user wasn't created in the last minute, don't bother // if the user wasn't created in the last minute, don't bother
const now = dayjs().utc() const now = dayjs().utc()
@ -181,46 +173,6 @@ async function setCachedReferralInfoForUser(user: User | null) {
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) 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() { export async function firebaseLogin() {
const provider = new GoogleAuthProvider() const provider = new GoogleAuthProvider()
return signInWithPopup(auth, provider) return signInWithPopup(auth, provider)
@ -258,16 +210,6 @@ export async function listAllUsers() {
return docs.map((doc) => doc.data()) return docs.map((doc) => doc.data())
} }
export function listenForAllUsers(setUsers: (users: User[]) => void) {
listenForValues(users, setUsers)
}
export function listenForPrivateUsers(
setUsers: (users: PrivateUser[]) => void
) {
listenForValues(privateUsers, setUsers)
}
export function getTopTraders(period: Period) { export function getTopTraders(period: Period) {
const topTraders = query( const topTraders = query(
users, users,

View File

@ -40,7 +40,7 @@
"gridjs-react": "5.0.2", "gridjs-react": "5.0.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"next": "12.1.2", "next": "12.2.2",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"react": "17.0.2", "react": "17.0.2",
"react-confetti": "6.0.1", "react-confetti": "6.0.1",

View File

@ -10,7 +10,7 @@ import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel' import { ResolutionPanel } from 'web/components/resolution-panel'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer' 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 { import {
Contract, Contract,
getContractFromSlug, getContractFromSlug,
@ -43,9 +43,9 @@ import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useRouter } from 'next/router'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { richTextToString } from 'common/util/parse' import { richTextToString } from 'common/util/parse'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -157,15 +157,10 @@ export function ContractPageContent(
const ogCardProps = getOpenGraphProps(contract) const ogCardProps = getOpenGraphProps(contract)
const router = useRouter() useSaveReferral(user, {
defaultReferrer: contract.creatorUsername,
useEffect(() => { contractId: contract.id,
const { referrer } = router.query as { })
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(contract.creatorUsername, contract.id, referrer)
}, [user, contract, router])
const rightSidebar = hasSidePanel ? ( const rightSidebar = hasSidePanel ? (
<Col className="gap-4"> <Col className="gap-4">

View File

@ -5,6 +5,7 @@ import Head from 'next/head'
import Script from 'next/script' import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
function firstLine(msg: string) { function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '') return msg.replace(/\r?\n.*/s, '')
@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) {
/> />
</Head> </Head>
<AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Component {...pageProps} /> <Component {...pageProps} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider>
</> </>
) )
} }

View File

@ -14,12 +14,7 @@ import {
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
firebaseLogin,
getUser,
User,
writeReferralInfo,
} from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' 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 { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { CreateQuestionButton } from 'web/components/create-question-button' 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 { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
@ -42,7 +37,6 @@ import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments' import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user' import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx' import clsx from 'clsx'
@ -52,6 +46,9 @@ import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size' 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 const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -113,7 +110,7 @@ const groupSubpages = [
undefined, undefined,
GROUP_CHAT_SLUG, GROUP_CHAT_SLUG,
'questions', 'questions',
'rankings', 'leaderboards',
'about', 'about',
] as const ] as const
@ -154,13 +151,11 @@ export default function GroupPage(props: {
const messages = useCommentsOnGroup(group?.id) const messages = useCommentsOnGroup(group?.id)
const user = useUser() const user = useUser()
useEffect(() => {
const { referrer } = router.query as { useSaveReferral(user, {
referrer?: string defaultReferrer: creator.username,
} groupId: group?.id,
if (!user && router.isReady) })
writeReferralInfo(creator.username, undefined, referrer, group?.id)
}, [user, creator, group, router])
const { width } = useWindowSize() const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled const chatDisabled = !group || group.chatDisabled
@ -236,9 +231,9 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'questions'), href: groupPath(group.slug, 'questions'),
}, },
{ {
title: 'Rankings', title: 'Leaderboards',
content: leaderboard, content: leaderboard,
href: groupPath(group.slug, 'rankings'), href: groupPath(group.slug, 'leaderboards'),
}, },
{ {
title: 'About', title: 'About',
@ -252,7 +247,7 @@ export default function GroupPage(props: {
<Page <Page
rightSidebar={showChatSidebar ? chatTab : undefined} rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''} rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-none !pb-0' : ''} className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
> >
<SEO <SEO
title={group.name} title={group.name}
@ -328,6 +323,11 @@ function GroupOverview(props: {
}) })
} }
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
return ( return (
<> <>
<Col className="gap-2 rounded-b bg-white p-2"> <Col className="gap-2 rounded-b bg-white p-2">
@ -372,21 +372,26 @@ function GroupOverview(props: {
</span> </span>
)} )}
</Row> </Row>
{anyoneCanJoin && user && ( {anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}> <Col className="my-4 px-2">
<span className={'text-gray-500'}>Share</span> <div className="text-lg">Invite</div>
<ShareIconButton <div className={'mb-2 text-gray-500'}>
group={group} Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
username={user.username} sign up!
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'} </div>
>
<span className={'mx-2'}> <CopyLinkButton
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! url={shareUrl}
</span> tracking="copy group share link"
</ShareIconButton> buttonClassName="btn-md rounded-l-none"
</Row> toastClassName={'-left-28 mt-1'}
/>
</Col>
)} )}
<Col className={'mt-2'}> <Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} /> <GroupMemberSearch members={members} group={group} />
</Col> </Col>
</Col> </Col>
@ -487,14 +492,14 @@ function GroupLeaderboards(props: {
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0} scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings" title="🏅 Top bettors"
header="Profit" header="Profit"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />
<SortedLeaderboard <SortedLeaderboard
users={members} users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0} scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings" title="🏅 Top creators"
header="Market volume" header="Market volume"
maxToShow={maxToShow} maxToShow={maxToShow}
/> />

View File

@ -12,6 +12,7 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -21,6 +22,8 @@ const Home = () => {
const router = useRouter() const router = useRouter()
useTracking('view home') useTracking('view home')
useSaveReferral()
return ( return (
<> <>
<Page suspend={!!contract}> <Page suspend={!!contract}>

View File

@ -1,11 +1,13 @@
import React from 'react' import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel' import { LandingPagePanel } from 'web/components/landing-page-panel'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo' import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth' import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => { export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
// These hardcoded markets will be shown in the frontpage for signed-out users: // These hardcoded markets will be shown in the frontpage for signed-out users:
@ -26,6 +28,20 @@ export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
export default function Home(props: { hotContracts: Contract[] }) { export default function Home(props: { hotContracts: Contract[] }) {
const { hotContracts } = props const { hotContracts } = props
// for now this redirect in the component is how we handle the case where they are
// 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')
}
}, [router, user])
return ( return (
<Page> <Page>
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">

View File

@ -7,6 +7,8 @@ import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card' import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
export default function ClaimPage() { export default function ClaimPage() {
const user = useUser() const user = useUser()
@ -28,13 +30,12 @@ export default function ClaimPage() {
description="Send mana to anyone via link!" description="Send mana to anyone via link!"
url="/send" url="/send"
/> />
<div className="mx-auto max-w-xl"> <div className="mx-auto max-w-xl px-2">
<Row className="items-center justify-between">
<Title text={`Claim M$${manalink.amount} mana`} /> <Title text={`Claim M$${manalink.amount} mana`} />
<ManalinkCard <div className="my-auto">
user={user} <Button
info={info} onClick={async () => {
isClaiming={claiming}
onClaim={async () => {
setClaiming(true) setClaiming(true)
try { try {
if (user == null) { if (user == null) {
@ -50,12 +51,21 @@ export default function ClaimPage() {
} catch (e) { } catch (e) {
console.log(e) console.log(e)
const message = const message =
e && e instanceof Object ? e.toString() : 'An error occurred.' e && e instanceof Object
? e.toString()
: 'An error occurred.'
setError(message) setError(message)
} }
setClaiming(false) setClaiming(false)
}} }}
/> disabled={claiming}
size="lg"
>
{user ? 'Claim' : 'Login'}
</Button>
</div>
</Row>
<ManalinkCard info={info} />
{error && ( {error && (
<section className="my-5 text-red-500"> <section className="my-5 text-red-500">
<p>Failed to claim manalink.</p> <p>Failed to claim manalink.</p>

View File

@ -1,7 +1,4 @@
import clsx from 'clsx'
import { useState } from 'react' import { useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Claim, Manalink } from 'common/manalink'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -11,7 +8,6 @@ import { Title } from 'web/components/title'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserManalinks } from 'web/lib/firebase/manalinks' import { useUserManalinks } from 'web/lib/firebase/manalinks'
import { fromNow } from 'web/lib/util/time'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import { ManalinkTxn } from 'common/txn' import { ManalinkTxn } from 'common/txn'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -22,8 +18,11 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat' import customParseFormat from 'dayjs/plugin/customParseFormat'
import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
export function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {
@ -40,6 +39,10 @@ export default function LinkPage() {
(l.maxUses == null || l.claimedUserIds.length < l.maxUses) && (l.maxUses == null || l.claimedUserIds.length < l.maxUses) &&
(l.expiresTime == null || l.expiresTime > Date.now()) (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) { if (user == null) {
return null return null
@ -68,12 +71,30 @@ export default function LinkPage() {
don&apos;t yet have a Manifold account. don&apos;t yet have a Manifold account.
</p> </p>
<Subtitle text="Your Manalinks" /> <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> </Col>
</Page> </Page>
) )
} }
// TODO: either utilize this or get rid of it
export function ClaimsList(props: { txns: ManalinkTxn[] }) { export function ClaimsList(props: { txns: ManalinkTxn[] }) {
const { txns } = props const { txns } = props
return ( return (
@ -121,127 +142,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) {
</div> </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&apos;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>
)
}

View File

@ -16,7 +16,6 @@ import {
User, User,
} from 'common/user' } from 'common/user'
import { getUser } from 'web/lib/firebase/users' import { getUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx' import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'

57
web/pages/referrals.tsx Normal file
View 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>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

354
web/public/mtg/app.js Normal file
View File

@ -0,0 +1,354 @@
mode = 'PLAY'
allData = {}
total = 0
unseenTotal = 0
probList = []
nameList = []
k = 12
extra = 3
artDict = {}
totalCorrect = 0
totalSeen = 0
wordsLeft = k + extra
imagesLeft = k
maxRounds = 20
whichGuesser = 'counterspell'
un = false
online = false
firstPrint = false
flag = true
page = 1
document.location.search.split('&').forEach((pair) => {
let v = pair.split('=')
if (v[0] === '?whichguesser') {
whichGuesser = v[1]
} else if (v[0] === 'un') {
un = v[1]
} else if (v[0] === 'digital') {
online = v[1]
} else if (v[0] === 'original') {
firstPrint = v[1]
}
})
let firstFetch = fetch('jsons/' + whichGuesser + page + '.json')
fetchToResponse(firstFetch)
function putIntoMapAndFetch(data) {
putIntoMap(data.data)
if (data.has_more) {
page += 1
window.setTimeout(() =>
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json'))
)
} else {
for (const [key, value] of Object.entries(allData)) {
nameList.push(key)
probList.push(
value.length +
(probList.length === 0 ? 0 : probList[probList.length - 1])
)
unseenTotal = total
}
window.console.log(allData)
window.console.log(total)
window.console.log(probList)
window.console.log(nameList)
if (whichGuesser === 'counterspell') {
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
} else if (whichGuesser === 'burn') {
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
}
setUpNewGame()
}
}
function getKSamples() {
let usedCounters = new Set()
let currentTotal = unseenTotal
let samples = {}
let i = 0
while (i < k) {
let rand = Math.floor(Math.random() * currentTotal)
let count = 0
for (const [key, value] of Object.entries(allData)) {
if (usedCounters.has(key)) {
continue
} else if (count >= rand) {
usedCounters.add(key)
currentTotal -= value.length
unseenTotal--
let randIndex = Math.floor(Math.random() * value.length)
let arts = allData[key].splice(randIndex, 1)
samples[arts[0].artImg] = [key, arts[0].normalImg]
i++
break
} else {
count += value.length
}
}
}
for (const key of usedCounters) {
if (allData[key].length === 0) {
delete allData[key]
}
}
let count = 0
while (count < extra) {
let rand = Math.floor(Math.random() * total)
for (let j = 0; j < nameList.length; j++) {
if (j >= rand) {
if (usedCounters.has(nameList[j])) {
break
}
usedCounters.add(nameList[j])
count += 1
break
}
}
}
return [samples, usedCounters]
}
function fetchToResponse(fetch) {
return fetch
.then((response) => response.json())
.then((json) => {
putIntoMapAndFetch(json)
})
}
function determineIfSkip(card) {
if (!un) {
if (card.set_type === 'funny') {
return true
}
}
if (!online) {
if (card.digital) {
return true
}
}
if (firstPrint) {
if (
card.reprint === true ||
(card.frame_effects && card.frame_effects.includes('showcase'))
) {
return true
}
}
// reskinned card names show in art crop
if (card.flavor_name) {
return true
}
// don't include racist cards
return card.content_warning
}
function putIntoMap(data) {
for (let i = 0; i < data.length; i++) {
let card = data[i]
if (determineIfSkip(card)) {
continue
}
let name = card.name
// remove slashes from adventure cards
if (card.card_faces) {
name = card.card_faces[0].name
}
let normalImg = ''
if (card.image_uris.normal) {
normalImg = card.image_uris.normal
} else if (card.image_uris.large) {
normalImg = card.image_uris.large
} else if (card.image_uris.small) {
normalImg = card.image_uris.small
} else {
continue
}
let artImg = ''
if (card.image_uris.art_crop) {
artImg = card.image_uris.art_crop
} else {
continue
}
total += 1
if (!allData[name]) {
allData[name] = [{ artImg: artImg, normalImg: normalImg }]
} else {
allData[name].push({ artImg: artImg, normalImg: normalImg })
}
}
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1))
let temp = array[i]
array[i] = array[j]
array[j] = temp
}
}
function setUpNewGame() {
wordsLeft = k + extra
imagesLeft = k
let currentRound = totalSeen / k
if (currentRound + 1 === maxRounds) {
document.getElementById('round-number').innerText = 'Final Round'
} else {
document.getElementById('round-number').innerText =
'Round ' + (1 + currentRound)
}
setWordsLeft()
// select new cards
let sampledData = getKSamples()
artDict = sampledData[0]
let randomImages = Object.keys(artDict)
shuffleArray(randomImages)
let namesList = Array.from(sampledData[1]).sort()
// fill in the new cards and names
for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
let currCard = document.getElementById('card-' + cardIndex)
currCard.classList.remove('incorrect')
currCard.dataset.name = ''
currCard.dataset.url = randomImages[cardIndex - 1]
currCard.style.backgroundImage = "url('" + currCard.dataset.url + "')"
}
const nameBank = document.querySelector('.names-bank')
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
currName = document.getElementById('name-' + nameIndex)
// window.console.log(currName)
currName.innerText = namesList[nameIndex - 1]
nameBank.appendChild(currName)
}
}
function checkAnswers() {
let score = k
// show the correct full cards
for (cardIndex = 1; cardIndex <= k; cardIndex++) {
currCard = document.getElementById('card-' + cardIndex)
let incorrect = true
if (currCard.dataset.name) {
let guess = document.getElementById(currCard.dataset.name).innerText
// window.console.log(artDict[currCard.dataset.url][0], guess);
incorrect = artDict[currCard.dataset.url][0] !== guess
// decide if their guess was correct
}
if (incorrect) currCard.classList.add('incorrect')
// tally some kind of score
if (incorrect) score--
// show the correct card
currCard.style.backgroundImage =
"url('" + artDict[currCard.dataset.url][1] + "')"
}
totalSeen += k
totalCorrect += score
document.getElementById('score-amount').innerText = score + '/' + k
document.getElementById('score-percent').innerText = Math.round(
(totalCorrect * 100) / totalSeen
)
document.getElementById('score-amount-total').innerText =
totalCorrect + '/' + totalSeen
}
function toggleMode() {
event.preventDefault()
if (mode === 'PLAY') {
mode = 'ANSWER'
document.querySelector('.play-page').classList.add('answer-page')
window.console.log(totalSeen)
if (totalSeen / k === maxRounds - 1) {
document.getElementById('submit').style.display = 'none'
} else {
document.getElementById('submit').value = 'Next Round'
}
checkAnswers()
} else {
mode = 'PLAY'
document.querySelector('.play-page').classList.remove('answer-page')
document.getElementById('submit').value = 'Submit'
setUpNewGame()
}
}
function allowDrop(ev, id) {
ev.preventDefault()
}
function drag(ev) {
ev.dataTransfer.setData('text', ev.target.id)
let nameEl = document.querySelector('.selected')
if (nameEl) nameEl.classList.remove('selected')
}
function drop(ev, id) {
ev.preventDefault()
var data = ev.dataTransfer.getData('text')
dropOnCard(id, data)
}
function returnDrop(ev) {
ev.preventDefault()
var data = ev.dataTransfer.getData('text')
returnToNameBank(data)
}
function returnToNameBank(name) {
document
.querySelector('.names-bank')
.appendChild(document.getElementById(name))
let prevContainer = document.querySelector('[data-name=' + name + ']')
if (prevContainer) {
prevContainer.dataset.name = ''
wordsLeft += 1
imagesLeft += 1
setWordsLeft()
}
}
function selectName(ev) {
if (ev.target.parentNode.classList.contains('names-bank')) {
let nameEl = document.querySelector('.selected')
if (nameEl) nameEl.classList.remove('selected')
ev.target.classList.add('selected')
} else {
returnToNameBank(ev.target.id)
}
}
function dropSelected(ev, id) {
ev.preventDefault()
let nameEl = document.querySelector('.selected')
window.console.log('drop selected', nameEl)
if (!nameEl) return
nameEl.classList.remove('selected')
dropOnCard(id, nameEl.id)
}
function dropOnCard(id, data) {
let target = document.getElementById('card-' + id)
target.appendChild(document.getElementById(data))
// if this already has a name, remove that name
if (target.dataset.name) {
returnToNameBank(target.dataset.name)
}
// remove name data from a previous card if there is one
let prevContainer = document.querySelector('[data-name=' + data + ']')
if (prevContainer) {
prevContainer.dataset.name = ''
} else {
wordsLeft -= 1
imagesLeft -= 1
setWordsLeft()
}
target.dataset.name = data
}
function setWordsLeft() {
document.getElementById('words-left').innerText =
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft
}

559
web/public/mtg/guess.html Normal file
View File

@ -0,0 +1,559 @@
<!DOCTYPE html>
<html>
<head>
<!-- Google Tag Manager -->
<script>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js',
})
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l !== 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<script type="text/javascript" src="app.js"></script>
<style type="text/css">
body {
position: relative;
}
.play-page {
display: flex;
flex-direction: row-reverse;
font-family: Georgia, 'Times New Roman', Times, serif;
}
h1 {
font-family: Verdana, Geneva, Tahoma, sans-serif;
text-align: center;
}
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 240px;
}
.cards-container {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
}
.card {
width: 230px;
height: 208px;
border: 5px solid lightgrey;
margin: 5px;
align-items: flex-end;
box-sizing: border-box;
border-radius: 11px;
position: relative;
display: flex;
justify-content: center;
/*background-size: contain;*/
background-size: 220px;
background-repeat: no-repeat;
transition: height 1s, background-image 1s, border 0.4s 0.6s;
background-position-y: calc(50% - 18px);
}
.card:not([data-name^='name'])::after {
content: '';
height: 34px;
background: white;
width: 100%;
}
.answer-page .card {
height: 350px;
/*padding-top: 310px;*/
/*background-size: cover;*/
overflow: hidden;
border-color: rgb(0, 146, 156);
}
.answer-page .card.incorrect {
border-color: rgb(216, 27, 96);
}
.names-bank {
position: fixed;
padding: 10px 10px 40px;
}
.names-bank .name {
margin: 6px 0;
}
.answer-page .names-bank .name {
display: none;
}
.answer-page .names-bank .word-count {
display: none;
}
.word-count {
text-align: center;
font-style: italic;
color: #444;
}
.score {
width: 100%;
text-align: center;
background-color: rgb(255, 193, 7);
width: 200px;
font-family: Verdana, Geneva, Tahoma, sans-serif;
opacity: 0;
}
.names-bank .score {
overflow: hidden;
height: 0;
}
.answer-page .names-bank .score {
height: auto;
display: block;
opacity: 1;
transition: opacity 1.2s 0.2s;
padding: 20px;
}
.name {
width: 230px;
min-height: 36px;
border-radius: 2px;
background-color: lightgrey;
padding: 8px 12px 2px;
box-sizing: border-box;
}
.card .name {
border-radius: 0 0 5px 5px;
}
#submit {
margin-top: 10px;
padding: 8px 20px;
background-color: cadetblue;
border: none;
border-radius: 3px;
font-size: 1.1em;
color: white;
cursor: pointer;
}
#submit:hover {
background-color: rgb(0, 146, 156);
}
#newGame {
padding: 8px 20px;
background-color: lightpink;
border: none;
position: absolute;
top: 5px;
left: 20px;
border-radius: 3px;
font-size: 0.7em;
cursor: pointer;
}
#newGame:hover {
background-color: coral;
}
.selected {
background-color: orange;
}
@media screen and (orientation: landscape) and (max-height: 680px) {
/* CSS applied when the device is in landscape mode*/
.names-bank {
padding: 0;
top: 0;
max-height: 100vh;
overflow: scroll;
}
body {
font-size: 20px;
}
.word-count {
font-size: 14px;
}
h1 {
margin-right: 240px;
}
}
@media screen and (orientation: portrait) and (max-width: 1100px) {
body {
font-size: 1.8em;
}
.play-page {
flex-direction: column;
}
.names-bank {
flex-direction: row;
display: flex;
flex-wrap: wrap;
/* position: fixed; */
padding: 10px 10px 40px;
position: sticky;
top: 0;
z-index: 100;
background: white;
}
.answer-page .names-bank {
min-width: 100%;
justify-content: center;
}
form {
margin: 0;
}
.names-bank .name {
margin: 6px;
}
.names-bank .score {
width: 0;
}
.answer-page .names-bank .score {
width: auto;
}
.word-count {
position: absolute;
margin-top: -20px;
}
.name {
width: 300px;
}
.card {
width: 300px;
background-size: 300px;
height: 266px;
}
.answer-page .card {
height: 454px;
}
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG"
height="0"
width="0"
style="display: none; visibility: hidden"
></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<h1><span id="guess-type"></span>: <span id="round-number"></span></h1>
<div class="play-page">
<div
class="names-bank"
ondrop="returnDrop(event)"
ondragover="event.preventDefault()"
>
<div class="score">
YOUR SCORE
<div>Correct Answers This Round: <span id="score-amount"></span></div>
<div>
Correct Answers In Total: <span id="score-amount-total"></span>
</div>
<div>Overall Percent: <span id="score-percent"></span>%</div>
</div>
<div class="word-count"><span id="words-left"></span></div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-1"
>
Name 1
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-2"
>
Name 2
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-3"
>
Name 3
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-4"
>
Name 4
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-5"
>
Name 5
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-6"
>
Name 6
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-7"
>
Name 7
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-8"
>
Name 8
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-9"
>
Name 9
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-10"
>
Name 10
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-11"
>
Name 11
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-12"
>
Name 12
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-13"
>
Name 13
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-14"
>
Name 14
</div>
<div
class="name"
draggable="true"
ondragstart="drag(event)"
onClick="selectName(event)"
id="name-15"
>
Name 15
</div>
</div>
<form onsubmit="toggleMode(event)">
<div class="cards-container">
<div
class="card"
ondrop="drop(event,1)"
ondragover="allowDrop(event,1)"
onclick="dropSelected(event, 1)"
id="card-1"
></div>
<div
class="card"
ondrop="drop(event,2)"
ondragover="allowDrop(event,2)"
onclick="dropSelected(event, 2)"
id="card-2"
></div>
<div
class="card"
ondrop="drop(event,3)"
ondragover="allowDrop(event,3)"
onclick="dropSelected(event, 3)"
id="card-3"
></div>
<div
class="card"
ondrop="drop(event,4)"
ondragover="allowDrop(event,4)"
onclick="dropSelected(event, 4)"
id="card-4"
></div>
<div
class="card"
ondrop="drop(event,5)"
ondragover="allowDrop(event,5)"
onclick="dropSelected(event, 5)"
id="card-5"
></div>
<div
class="card"
ondrop="drop(event, 6)"
ondragover="allowDrop(event,6)"
onclick="dropSelected(event,6)"
id="card-6"
></div>
<div
class="card"
ondrop="drop(event,7)"
ondragover="allowDrop(event,7)"
onclick="dropSelected(event, 7)"
id="card-7"
></div>
<div
class="card"
ondrop="drop(event,8)"
ondragover="allowDrop(event,8)"
onclick="dropSelected(event, 8)"
id="card-8"
></div>
<div
class="card"
ondrop="drop(event,9)"
ondragover="allowDrop(event,9)"
onclick="dropSelected(event, 9)"
id="card-9"
></div>
<div
class="card"
ondrop="drop(event,10)"
ondragover="allowDrop(event,10)"
onclick="dropSelected(event, 10)"
id="card-10"
></div>
<div
class="card"
ondrop="drop(event,11)"
ondragover="allowDrop(event,11)"
onclick="dropSelected(event, 11)"
id="card-11"
></div>
<div
class="card"
ondrop="drop(event,12)"
ondragover="allowDrop(event,12)"
onclick="dropSelected(event, 12)"
id="card-12"
></div>
</div>
<input type="submit" id="submit" value="Submit" />
</form>
</div>
<div style="position: absolute; top: 0; left: 0; right: 0; color: grey">
<form method="get" action="index.html">
<input type="submit" id="newGame" value="New Game" />
</form>
</div>
<div style="margin: -40px 0 0; height: 60px">
<a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a>
</div>
<div
style="
font-size: 0.9em;
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: grey;
font-style: italic;
"
>
made by
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="https://idamayer.com"
>Ida Mayer</a
>
&
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="mailto:alexlien.alien@gmail.com"
>Alex Lien</a
>, 2022
</div>
</body>
</html>

View File

@ -0,0 +1,92 @@
import time
import requests
import json
# add category name here
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn']
def generate_initial_query(category):
string_query = 'https://api.scryfall.com/cards/search?q='
if category == 'counterspell':
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
elif category == 'terror':
string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
'%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
elif category == 'wrath':
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
elif category == 'burn':
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
'.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
# add category string query here
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
'+-frame%3Aextendedart+language%3Aenglish&unique=art&page='
print(string_query)
return string_query
def fetch_and_write_all(category, query):
count = 1
will_repeat = True
while will_repeat:
will_repeat = fetch_and_write(category, query, count)
count += 1
def fetch_and_write(category, query, count):
query += str(count)
response = requests.get(f"{query}").json()
time.sleep(0.1)
with open('jsons/' + category + str(count) + '.json', 'w') as f:
json.dump(to_compact_write_form(response), f)
return response['has_more']
def to_compact_write_form(response):
fieldsToUse = ['has_more']
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
'set_type']
smallJson = dict()
data = []
# write all fields needed in response
for field in fieldsToUse:
smallJson[field] = response[field]
# write all fields needed in card
for card in response['data']:
write_card = dict()
for field in fieldsInCard:
if field == 'name' and 'card_faces' in card:
write_card['name'] = card['card_faces'][0]['name']
elif field == 'image_uris':
write_card['image_uris'] = write_image_uris(card['image_uris'])
elif field in card:
write_card[field] = card[field]
data.append(write_card)
smallJson['data'] = data
return smallJson
# only write images needed
def write_image_uris(card_image_uris):
image_uris = dict()
if 'normal' in card_image_uris:
image_uris['normal'] = card_image_uris['normal']
elif 'large' in card_image_uris:
image_uris['normal'] = card_image_uris['large']
elif 'small' in card_image_uris:
image_uris['normal'] = card_image_uris['small']
if card_image_uris:
image_uris['art_crop'] = card_image_uris['art_crop']
return image_uris
if __name__ == "__main__":
for category in allCategories:
print(category)
fetch_and_write_all(category, generate_initial_query(category))

202
web/public/mtg/index.html Normal file
View File

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html>
<head>
<!-- Google Tag Manager -->
<script>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js',
})
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l !== 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<style type="text/css">
body {
position: relative;
}
.play-page {
display: flex;
flex-direction: row-reverse;
font-family: Georgia, 'Times New Roman', Times, serif;
min-height: 200px;
}
h1,
h3 {
font-family: Verdana, Geneva, Tahoma, sans-serif;
text-align: center;
}
#submit {
margin-top: 10px;
padding: 8px 20px;
background-color: cadetblue;
border: none;
border-radius: 3px;
font-size: 1.1em;
color: white;
cursor: pointer;
}
#submit:hover {
background-color: rgb(0, 146, 156);
}
[type='radio'] {
display: none;
}
[type='radio'] + label.radio-label {
background: lightgrey;
display: block;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
label.radio-label:hover {
background: darkgrey;
}
[type='radio']:checked + label.radio-label {
background: lightcoral;
}
.radio-label h3 {
margin: 0;
display: inline-block;
vertical-align: middle;
width: 220px;
}
.thumbnail {
display: inline-block;
vertical-align: middle;
width: 67px;
height: 48px;
margin-right: 4px;
}
body {
padding: 70px 0 30px;
}
#addl-options {
position: absolute;
top: 30px;
right: 30px;
background-color: white;
padding: 10px;
cursor: pointer;
width: 200px;
}
#addl-options > summary {
list-style: none;
text-align: right;
}
</style>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG"
height="0"
width="0"
style="display: none; visibility: hidden"
></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<h1>Magic the Guessering</h1>
<div class="play-page" style="justify-content: center">
<form
method="get"
action="guess.html"
style="display: flex; flex-direction: column; align-items: center"
>
<input
type="radio"
id="counterspell"
name="whichguesser"
value="counterspell"
checked
/>
<label class="radio-label" for="counterspell">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
/>
<h3>Counterspell Guesser</h3></label
><br />
<input type="radio" id="burn" name="whichguesser" value="burn" />
<label class="radio-label" for="burn">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
/>
<h3>Match With Hot Singles</h3></label
><br />
<details id="addl-options">
<summary>
<img
src="https://mythicspoiler.com/images/buttons/ustset.png"
style="width: 32px; vertical-align: top"
/>
Options
</summary>
<input type="checkbox" name="digital" id="digital" checked />
<label for="digital">include digital cards</label>
<br />
<input type="checkbox" name="un" id="un" checked />
<label for="un">include un-cards</label>
<br />
<input type="checkbox" name="original" id="original" />
<label for="original">restrict to only original printing</label>
</details>
<input type="submit" id="submit" value="Play" />
</form>
</div>
<div style="margin: -40px 0 0; height: 60px">
<a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a>
</div>
<div
style="
font-size: 0.9em;
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: grey;
font-style: italic;
"
>
made by
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="https://idamayer.com"
>Ida Mayer</a
>
&
<a
style="color: rgb(0, 146, 156); font-style: italic"
href="mailto:alexlien.alien@gmail.com"
>Alex Lien</a
>, 2022
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,5 @@
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin')
module.exports = { module.exports = {
content: [ content: [
@ -32,6 +33,22 @@ module.exports = {
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/line-clamp'), require('@tailwindcss/line-clamp'),
require('daisyui'), require('daisyui'),
plugin(function ({ addUtilities }) {
addUtilities({
'.scrollbar-hide': {
/* IE and Edge */
'-ms-overflow-style': 'none',
/* Firefox */
'scrollbar-width': 'none',
/* Safari and Chrome */
'&::-webkit-scrollbar': {
display: 'none',
},
},
})
}),
], ],
daisyui: { daisyui: {
themes: [ themes: [

178
yarn.lock
View File

@ -2385,10 +2385,10 @@
resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b"
integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==
"@next/env@12.1.2": "@next/env@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.1.2.tgz#4b0f5fd448ac60b821d2486d2987948e3a099f03" resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc"
integrity sha512-A/P4ysmFScBFyu1ZV0Mr1Y89snyQhqGwsCrkEpK+itMF+y+pMqBoPVIyakUf4LXqGWJGiGFuIerihvSG70Ad8Q== integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw==
"@next/eslint-plugin-next@12.1.6": "@next/eslint-plugin-next@12.1.6":
version "12.1.6" version "12.1.6"
@ -2397,65 +2397,70 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/swc-android-arm-eabi@12.1.2": "@next/swc-android-arm-eabi@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.2.tgz#675e952d9032ac7bec02f3f413c17d33bbd90857" resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd"
integrity sha512-iwalfLBhYmCIlj09czFbovj1SmTycf0AGR8CB357wgmEN8xIuznIwSsCH87AhwQ9apfNtdeDhxvuKmhS9T3FqQ== integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ==
"@next/swc-android-arm64@12.1.2": "@next/swc-android-arm64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.2.tgz#d9710c50853235f258726b19a649df9c29a49682" resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e"
integrity sha512-ZoR0Vx7czJhTgRAcFbzTKQc2n2ChC036/uc6PbgYiI/LreEnfmsV/CiREP0pUVs5ndntOX8kBA3BSbh4zCO5tQ== integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA==
"@next/swc-darwin-arm64@12.1.2": "@next/swc-darwin-arm64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.2.tgz#aadd21b711c82b3efa9b4ecf7665841259e1fa7e" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50"
integrity sha512-VXv7lpqFjHwkK65CZHkjvBxlSBTG+l3O0Zl2zHniHj0xHzxJZvR8VFjV2zIMZCYSfVqeQ5yt2rjwuQ9zbpGtXQ== integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA==
"@next/swc-darwin-x64@12.1.2": "@next/swc-darwin-x64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.2.tgz#3b1a389828f5c88ecb828a6394692fdeaf175081" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133"
integrity sha512-evXxJQnXEnU+heWyun7d0UV6bhBcmoiyFGR3O3v9qdhGbeXh+SXYVxRO69juuh6V7RWRdlb1KQ0rGUNa1k0XSw== integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw==
"@next/swc-linux-arm-gnueabihf@12.1.2": "@next/swc-freebsd-x64@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.2.tgz#db4371ca716bf94c94d4f6b001ac3c9d08d97d79" resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95"
integrity sha512-LJV/wo6R0Ot7Y/20bZs00aBG4J333RT6H/5Q2AROE4Hnx7cenSktSnfU6WCnJgzYLSIHdbLs549LcZMULuVquw== integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA==
"@next/swc-linux-arm64-gnu@12.1.2": "@next/swc-linux-arm-gnueabihf@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.2.tgz#0e71db03b8b12ed315c8be7d15392ecefe562b7c" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6"
integrity sha512-fjlYU1Y8kVjjRKyuyQBYLHPxjGOS2ox7U8TqAvtgKvd2PxqdsgW4sP+VDovRVPrZlGXNllKoJiqMO1OoR9fB6w== integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q==
"@next/swc-linux-arm64-musl@12.1.2": "@next/swc-linux-arm64-gnu@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.2.tgz#f1b055793da1c12167ed3b6e32aef8289721a1fb" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061"
integrity sha512-Y1JRDMHqSjLObjyrD1hf6ePrJcOF/mkw+LbAzoNgrHL1dSuIAqcz3jYunJt8T7Yw48xSJy6LPSL9BclAHwEwOA== integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw==
"@next/swc-linux-x64-gnu@12.1.2": "@next/swc-linux-arm64-musl@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.2.tgz#69764ffaacb3b9b373897fff15d7dd871455efe2" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56"
integrity sha512-5N4QSRT60ikQqCU8iHfYZzlhg6MFTLsKhMTARmhn8wLtZfN9VVyTFwZrJQWjV64dZc4JFeXDANGao8fm55y6bw== integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A==
"@next/swc-linux-x64-musl@12.1.2": "@next/swc-linux-x64-gnu@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.2.tgz#0ddaedb5ec578c01771f83be2046dafb2f70df91" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78"
integrity sha512-b32F/xAgdYG4Pt0foFzhF+2uhvNxnEj7aJNp1R4EhZotdej2PzvFWcP/dGkc7MJl205pBz5oC3gHyILIIlW6XA== integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A==
"@next/swc-win32-arm64-msvc@12.1.2": "@next/swc-linux-x64-musl@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.2.tgz#9e17ed56d5621f8c6961193da3a0b155cea511c9" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a"
integrity sha512-hVOcGmWDeVwO00Aclopsj6MoYhfJl5zA4vjAai9KjgclQTFZa/DC0vQjgKAHHKGT5oMHgjiq/G7L6P1/UfwYnw== integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw==
"@next/swc-win32-ia32-msvc@12.1.2": "@next/swc-win32-arm64-msvc@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.2.tgz#ddd260cbe8bc4002fb54415b80baccf37f8db783" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157"
integrity sha512-wnVDGIVz2pR3vIkyN6IE+1NvMSBrBj1jba11iR16m8TAPzZH/PrNsxr0a9N5VavEXXLcQpoUVvT+N7nflbRAHg== integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg==
"@next/swc-win32-x64-msvc@12.1.2": "@next/swc-win32-ia32-msvc@12.2.2":
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.2.tgz#37412a314bcf4c6006a74e1ef9764048344f3848" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f"
integrity sha512-MLNcurEpQp0+7OU9261f7PkN52xTGkfrt4IYTIXau7DO/aHj927oK6piIJdl9EOHdX/KN5W6qlyErj170PSHtw== integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA==
"@next/swc-win32-x64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89"
integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ==
"@nivo/annotations@0.74.0": "@nivo/annotations@0.74.0":
version "0.74.0" version "0.74.0"
@ -2837,6 +2842,13 @@
"@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-jsx" "^6.2.1"
"@svgr/plugin-svgo" "^6.2.0" "@svgr/plugin-svgo" "^6.2.0"
"@swc/helpers@0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.2.tgz#ed1f6997ffbc22396665d9ba74e2a5c0a2d782f8"
integrity sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw==
dependencies:
tslib "^2.4.0"
"@szmarczak/http-timer@^1.1.2": "@szmarczak/http-timer@^1.1.2":
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
@ -4290,7 +4302,7 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001335:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz#8a1e7fdc4db9c2ec79a05e9fd68eb93a761888bb" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz#8a1e7fdc4db9c2ec79a05e9fd68eb93a761888bb"
integrity sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g== integrity sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==
caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001283, caniuse-lite@^1.0.30001332: caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332:
version "1.0.30001341" version "1.0.30001341"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498"
integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==
@ -8320,29 +8332,31 @@ next-sitemap@^2.5.14:
"@corex/deepmerge" "^2.6.148" "@corex/deepmerge" "^2.6.148"
minimist "^1.2.6" minimist "^1.2.6"
next@12.1.2: next@12.2.2:
version "12.1.2" version "12.2.2"
resolved "https://registry.yarnpkg.com/next/-/next-12.1.2.tgz#c5376a8ae17d3e404a2b691c01f94c8943306f29" resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072"
integrity sha512-JHPCsnFTBO0Z4SQxSYc611UA1WA+r/3y3Neg66AH5/gSO/oksfRnFw/zGX/FZ9+oOUHS9y3wJFawNpVYR2gJSQ== integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg==
dependencies: dependencies:
"@next/env" "12.1.2" "@next/env" "12.2.2"
caniuse-lite "^1.0.30001283" "@swc/helpers" "0.4.2"
caniuse-lite "^1.0.30001332"
postcss "8.4.5" postcss "8.4.5"
styled-jsx "5.0.1" styled-jsx "5.0.2"
use-subscription "1.5.1" use-sync-external-store "1.1.0"
optionalDependencies: optionalDependencies:
"@next/swc-android-arm-eabi" "12.1.2" "@next/swc-android-arm-eabi" "12.2.2"
"@next/swc-android-arm64" "12.1.2" "@next/swc-android-arm64" "12.2.2"
"@next/swc-darwin-arm64" "12.1.2" "@next/swc-darwin-arm64" "12.2.2"
"@next/swc-darwin-x64" "12.1.2" "@next/swc-darwin-x64" "12.2.2"
"@next/swc-linux-arm-gnueabihf" "12.1.2" "@next/swc-freebsd-x64" "12.2.2"
"@next/swc-linux-arm64-gnu" "12.1.2" "@next/swc-linux-arm-gnueabihf" "12.2.2"
"@next/swc-linux-arm64-musl" "12.1.2" "@next/swc-linux-arm64-gnu" "12.2.2"
"@next/swc-linux-x64-gnu" "12.1.2" "@next/swc-linux-arm64-musl" "12.2.2"
"@next/swc-linux-x64-musl" "12.1.2" "@next/swc-linux-x64-gnu" "12.2.2"
"@next/swc-win32-arm64-msvc" "12.1.2" "@next/swc-linux-x64-musl" "12.2.2"
"@next/swc-win32-ia32-msvc" "12.1.2" "@next/swc-win32-arm64-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.1.2" "@next/swc-win32-ia32-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.2.2"
no-case@^3.0.4: no-case@^3.0.4:
version "3.0.4" version "3.0.4"
@ -10892,10 +10906,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0:
dependencies: dependencies:
inline-style-parser "0.1.1" inline-style-parser "0.1.1"
styled-jsx@5.0.1: styled-jsx@5.0.2:
version "5.0.1" version "5.0.2"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.1.tgz#78fecbbad2bf95ce6cd981a08918ce4696f5fc80" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.2.tgz#ff230fd593b737e9e68b630a694d460425478729"
integrity sha512-+PIZ/6Uk40mphiQJJI1202b+/dYeTVd9ZnMPR80pgiWbjIwvN2zIp4r9et0BgqBuShh48I0gttPlAXA7WVvBxw== integrity sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==
stylehacks@^5.1.0: stylehacks@^5.1.0:
version "5.1.0" version "5.1.0"
@ -11437,12 +11451,10 @@ use-latest@^1.2.1:
dependencies: dependencies:
use-isomorphic-layout-effect "^1.1.1" use-isomorphic-layout-effect "^1.1.1"
use-subscription@1.5.1: use-sync-external-store@1.1.0:
version "1.5.1" version "1.1.0"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82"
integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA== integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==
dependencies:
object-assign "^4.1.1"
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"