456 lines
13 KiB
TypeScript
456 lines
13 KiB
TypeScript
import { track } from '@amplitude/analytics-browser'
|
|
import {
|
|
ArrowSmRightIcon,
|
|
PlusCircleIcon,
|
|
XCircleIcon,
|
|
} from '@heroicons/react/outline'
|
|
|
|
import PencilIcon from '@heroicons/react/solid/PencilIcon'
|
|
|
|
import { Contract } from 'common/contract'
|
|
import { Group } from 'common/group'
|
|
import { Post } from 'common/post'
|
|
import { useEffect, useState } from 'react'
|
|
import { ReactNode } from 'react'
|
|
import { getPost } from 'web/lib/firebase/posts'
|
|
import { ContractSearch } from '../contract-search'
|
|
import { ContractCard } from '../contract/contract-card'
|
|
|
|
import Masonry from 'react-masonry-css'
|
|
|
|
import { Col } from '../layout/col'
|
|
import { Row } from '../layout/row'
|
|
import { SiteLink } from '../site-link'
|
|
import { GroupOverviewPost } from './group-overview-post'
|
|
import { getContractFromId } from 'web/lib/firebase/contracts'
|
|
import { groupPath, updateGroup } from 'web/lib/firebase/groups'
|
|
import { PinnedSelectModal } from '../pinned-select-modal'
|
|
import { Button } from '../button'
|
|
import { User } from 'common/user'
|
|
import { UserLink } from '../user-link'
|
|
import { EditGroupButton } from './edit-group-button'
|
|
import { JoinOrLeaveGroupButton } from './groups-button'
|
|
import { Linkify } from '../linkify'
|
|
import { ChoicesToggleGroup } from '../choices-toggle-group'
|
|
import { CopyLinkButton } from '../copy-link-button'
|
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
|
import toast from 'react-hot-toast'
|
|
import { ENV_CONFIG } from 'common/envs/constants'
|
|
import { PostCard, PostCardList } from '../post-card'
|
|
import { LoadingIndicator } from '../loading-indicator'
|
|
import { useUser } from 'web/hooks/use-user'
|
|
import { CreatePost } from '../create-post'
|
|
import { Modal } from '../layout/modal'
|
|
|
|
const MAX_TRENDING_POSTS = 6
|
|
|
|
export function GroupOverview(props: {
|
|
group: Group
|
|
isEditable: boolean
|
|
posts: Post[]
|
|
aboutPost: Post | null
|
|
creator: User
|
|
user: User | null | undefined
|
|
memberIds: string[]
|
|
}) {
|
|
const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
|
|
props
|
|
return (
|
|
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
|
|
<GroupOverviewPinned
|
|
group={group}
|
|
posts={posts}
|
|
isEditable={isEditable}
|
|
/>
|
|
{(group.aboutPostId != null || isEditable) && (
|
|
<>
|
|
<SectionHeader label={'About'} href={'/post/' + group.slug} />
|
|
<GroupOverviewPost
|
|
group={group}
|
|
isEditable={isEditable}
|
|
post={aboutPost}
|
|
/>
|
|
</>
|
|
)}
|
|
<SectionHeader label={'Trending'} />
|
|
<ContractSearch
|
|
user={user}
|
|
defaultSort={'score'}
|
|
noControls
|
|
maxResults={MAX_TRENDING_POSTS}
|
|
defaultFilter={'all'}
|
|
additionalFilter={{ groupSlug: group.slug }}
|
|
persistPrefix={`group-trending-${group.slug}`}
|
|
/>
|
|
<GroupAbout
|
|
group={group}
|
|
creator={creator}
|
|
isEditable={isEditable}
|
|
user={user}
|
|
memberIds={memberIds}
|
|
/>
|
|
|
|
<GroupPosts group={group} posts={posts} />
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
export function GroupPosts(props: { posts: Post[]; group: Group }) {
|
|
const { posts, group } = props
|
|
const [showCreatePost, setShowCreatePost] = useState(false)
|
|
const user = useUser()
|
|
|
|
const createPost = (
|
|
<Modal size="xl" open={showCreatePost} setOpen={setShowCreatePost}>
|
|
<div className="w-full bg-white py-10">
|
|
<CreatePost group={group} />
|
|
</div>
|
|
</Modal>
|
|
)
|
|
|
|
const postList = (
|
|
<div className=" align-start w-full items-start">
|
|
<Row className="flex justify-between">
|
|
<Col>
|
|
<SectionHeader label={'Latest Posts'} />
|
|
</Col>
|
|
<Col>
|
|
{user && (
|
|
<Button
|
|
className="btn-md"
|
|
onClick={() => setShowCreatePost(!showCreatePost)}
|
|
>
|
|
Add a Post
|
|
</Button>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
|
|
<div className="mt-2">
|
|
<PostCardList posts={posts} />
|
|
{posts.length === 0 && (
|
|
<div className="text-center text-gray-500">No posts yet</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return showCreatePost ? createPost : postList
|
|
}
|
|
|
|
function GroupOverviewPinned(props: {
|
|
group: Group
|
|
posts: Post[]
|
|
isEditable: boolean
|
|
}) {
|
|
const { group, posts, isEditable } = props
|
|
const [pinned, setPinned] = useState<JSX.Element[]>([])
|
|
|
|
useEffect(() => {
|
|
async function getPinned() {
|
|
if (group.pinnedItems == null) {
|
|
updateGroup(group, { pinnedItems: [] })
|
|
} else {
|
|
const itemComponents = await Promise.all(
|
|
group.pinnedItems.map(async (element) => {
|
|
if (element.type === 'post') {
|
|
const post = await getPost(element.itemId)
|
|
if (post) {
|
|
return <PostCard post={post as Post} />
|
|
}
|
|
} else if (element.type === 'contract') {
|
|
const contract = await getContractFromId(element.itemId)
|
|
if (contract) {
|
|
return <ContractCard contract={contract as Contract} />
|
|
}
|
|
}
|
|
})
|
|
)
|
|
setPinned(
|
|
itemComponents.filter(
|
|
(element) => element != undefined
|
|
) as JSX.Element[]
|
|
)
|
|
}
|
|
}
|
|
getPinned()
|
|
}, [group, group.pinnedItems])
|
|
|
|
async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
|
|
await updateGroup(group, {
|
|
pinnedItems: [
|
|
...group.pinnedItems,
|
|
...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
|
|
],
|
|
})
|
|
}
|
|
|
|
function onDeleteClicked(index: number) {
|
|
const newPinned = group.pinnedItems.filter((item) => {
|
|
return item.itemId !== group.pinnedItems[index].itemId
|
|
})
|
|
updateGroup(group, { pinnedItems: newPinned })
|
|
}
|
|
|
|
return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? (
|
|
<PinnedItems
|
|
posts={posts}
|
|
group={group}
|
|
isEditable={isEditable}
|
|
pinned={pinned}
|
|
onDeleteClicked={onDeleteClicked}
|
|
onSubmit={onSubmit}
|
|
modalMessage={'Pin posts or markets to the overview of this group.'}
|
|
/>
|
|
) : (
|
|
<LoadingIndicator />
|
|
)
|
|
}
|
|
|
|
export function PinnedItems(props: {
|
|
posts: Post[]
|
|
isEditable: boolean
|
|
pinned: JSX.Element[]
|
|
onDeleteClicked: (index: number) => void
|
|
onSubmit: (selectedItems: { itemId: string; type: string }[]) => void
|
|
group?: Group
|
|
modalMessage: string
|
|
}) {
|
|
const {
|
|
isEditable,
|
|
pinned,
|
|
onDeleteClicked,
|
|
onSubmit,
|
|
posts,
|
|
group,
|
|
modalMessage,
|
|
} = props
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
|
|
return pinned.length > 0 || isEditable ? (
|
|
<div>
|
|
<Row className="mb-3 items-center justify-between">
|
|
<SectionHeader label={'Pinned'} />
|
|
{isEditable && (
|
|
<Button
|
|
color="gray"
|
|
size="xs"
|
|
onClick={() => {
|
|
setEditMode(!editMode)
|
|
}}
|
|
>
|
|
{editMode ? (
|
|
'Done'
|
|
) : (
|
|
<>
|
|
<PencilIcon className="inline h-4 w-4" />
|
|
Edit
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</Row>
|
|
<div>
|
|
<Masonry
|
|
breakpointCols={{ default: 2, 768: 1 }}
|
|
className="-ml-4 flex w-auto"
|
|
columnClassName="pl-4 bg-clip-padding"
|
|
>
|
|
{pinned.length == 0 && !editMode && (
|
|
<div className="flex flex-col items-center justify-center">
|
|
<p className="text-center text-gray-400">
|
|
No pinned items yet. Click the edit button to add some!
|
|
</p>
|
|
</div>
|
|
)}
|
|
{pinned.map((element, index) => (
|
|
<div className="relative my-2">
|
|
{element}
|
|
|
|
{editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
|
|
</div>
|
|
))}
|
|
{editMode && pinned.length < 6 && (
|
|
<div className=" py-2">
|
|
<Row
|
|
className={
|
|
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100'
|
|
}
|
|
>
|
|
<button
|
|
className="flex w-full justify-center"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<PlusCircleIcon
|
|
className="h-12 w-12 text-gray-600"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
</Row>
|
|
</div>
|
|
)}
|
|
</Masonry>
|
|
</div>
|
|
<PinnedSelectModal
|
|
open={open}
|
|
group={group}
|
|
posts={posts}
|
|
setOpen={setOpen}
|
|
title="Pin a post or market"
|
|
description={
|
|
<div className={'text-md my-4 text-gray-600'}>{modalMessage}</div>
|
|
}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<></>
|
|
)
|
|
}
|
|
|
|
function SectionHeader(props: {
|
|
label: string
|
|
href?: string
|
|
children?: ReactNode
|
|
}) {
|
|
const { label, href, children } = props
|
|
const content = (
|
|
<>
|
|
{label}{' '}
|
|
<ArrowSmRightIcon
|
|
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
|
aria-hidden="true"
|
|
/>
|
|
</>
|
|
)
|
|
|
|
return (
|
|
<Row className="mb-3 items-center justify-between">
|
|
{href ? (
|
|
<SiteLink
|
|
className="text-xl"
|
|
href={href}
|
|
onClick={() => track('group click section header', { section: href })}
|
|
>
|
|
{content}
|
|
</SiteLink>
|
|
) : (
|
|
<span className="text-xl">{content}</span>
|
|
)}
|
|
{children}
|
|
</Row>
|
|
)
|
|
}
|
|
|
|
export function GroupAbout(props: {
|
|
group: Group
|
|
creator: User
|
|
user: User | null | undefined
|
|
isEditable: boolean
|
|
memberIds: string[]
|
|
}) {
|
|
const { group, creator, isEditable, user, memberIds } = props
|
|
const anyoneCanJoinChoices: { [key: string]: string } = {
|
|
Closed: 'false',
|
|
Open: 'true',
|
|
}
|
|
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
|
|
function updateAnyoneCanJoin(newVal: boolean) {
|
|
if (group.anyoneCanJoin == newVal || !isEditable) return
|
|
setAnyoneCanJoin(newVal)
|
|
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
|
|
loading: 'Updating group...',
|
|
success: 'Updated group!',
|
|
error: "Couldn't update group",
|
|
})
|
|
}
|
|
const postFix = user ? '?referrer=' + user.username : ''
|
|
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
|
|
group.slug
|
|
)}${postFix}`
|
|
const isMember = user ? memberIds.includes(user.id) : false
|
|
|
|
return (
|
|
<>
|
|
<Col className="gap-2 rounded-b bg-white p-2">
|
|
<Row className={'flex-wrap justify-between'}>
|
|
<div className={'inline-flex items-center'}>
|
|
<div className="mr-1 text-gray-500">Created by</div>
|
|
<UserLink
|
|
className="text-neutral"
|
|
name={creator.name}
|
|
username={creator.username}
|
|
/>
|
|
</div>
|
|
{isEditable ? (
|
|
<EditGroupButton className={'ml-1'} group={group} />
|
|
) : (
|
|
user && (
|
|
<Row>
|
|
<JoinOrLeaveGroupButton
|
|
group={group}
|
|
user={user}
|
|
isMember={isMember}
|
|
/>
|
|
</Row>
|
|
)
|
|
)}
|
|
</Row>
|
|
<div className={'block sm:hidden'}>
|
|
<Linkify text={group.about} />
|
|
</div>
|
|
<Row className={'items-center gap-1'}>
|
|
<span className={'text-gray-500'}>Membership</span>
|
|
{user && user.id === creator.id ? (
|
|
<ChoicesToggleGroup
|
|
currentChoice={anyoneCanJoin.toString()}
|
|
choicesMap={anyoneCanJoinChoices}
|
|
setChoice={(choice) =>
|
|
updateAnyoneCanJoin(choice.toString() === 'true')
|
|
}
|
|
toggleClassName={'h-10'}
|
|
className={'ml-2'}
|
|
/>
|
|
) : (
|
|
<span className={'text-gray-700'}>
|
|
{anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
|
|
</span>
|
|
)}
|
|
</Row>
|
|
|
|
{anyoneCanJoin && user && (
|
|
<Col className="my-4 px-2">
|
|
<div className="text-lg">Invite</div>
|
|
<div className={'mb-2 text-gray-500'}>
|
|
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
|
|
sign up!
|
|
</div>
|
|
|
|
<CopyLinkButton
|
|
url={shareUrl}
|
|
tracking="copy group share link"
|
|
buttonClassName="btn-md rounded-l-none"
|
|
toastClassName={'-left-28 mt-1'}
|
|
/>
|
|
</Col>
|
|
)}
|
|
</Col>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function CrossIcon(props: { onClick: () => void }) {
|
|
const { onClick } = props
|
|
|
|
return (
|
|
<div>
|
|
<button className=" text-gray-500 hover:text-gray-700" onClick={onClick}>
|
|
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-200 bg-opacity-50">
|
|
<XCircleIcon className="h-12 w-12 text-gray-600" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|