initial commit of viewing manalinks

This commit is contained in:
ingawei 2022-07-19 04:07:35 -07:00
parent 47a27bf3fe
commit 1f72354313
7 changed files with 223 additions and 177 deletions

View File

@ -20,7 +20,7 @@ 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 =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props const { contract, bets } = props

View File

@ -5,6 +5,12 @@ 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 { User } from 'web/lib/firebase/users'
import { Button } from './button' import { Button } from './button'
import { Claim, Manalink } from 'common/manalink'
import { useState } from 'react'
import { ShareIconButton } from './share-icon-button'
import { DotsHorizontalIcon, XCircleIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user'
export type ManalinkInfo = { export type ManalinkInfo = {
expiresTime: number | null expiresTime: number | null
@ -15,19 +21,21 @@ export type ManalinkInfo = {
} }
export function ManalinkCard(props: { export function ManalinkCard(props: {
user: User | null | undefined user?: User | null | undefined
className?: string className?: string
info: ManalinkInfo info: ManalinkInfo
isClaiming: boolean isClaiming?: boolean
onClaim?: () => void onClaim?: () => void
preview?: boolean
}) { }) {
const { user, className, isClaiming, info, onClaim } = props const { user, className, isClaiming, info, onClaim, preview = false } = props
const { expiresTime, maxUses, uses, amount, message } = info const { expiresTime, maxUses, uses, amount, message } = info
return ( return (
<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,12 +52,18 @@ export function ManalinkCard(props: {
</Col> </Col>
<img <img
className="mb-6 block self-center transition-all group-hover:rotate-12" className={clsx(
'ransition-all block h-1/3 w-1/3 self-center 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={clsx(
'rounded-b-xl bg-white p-4',
preview ? '' : 'justify-end'
)}
>
<Col> <Col>
<div className="mb-1 text-xl text-indigo-500"> <div className="mb-1 text-xl text-indigo-500">
{formatMoney(amount)} {formatMoney(amount)}
@ -57,52 +71,151 @@ export function ManalinkCard(props: {
<div>{message}</div> <div>{message}</div>
</Col> </Col>
<div className="ml-auto"> {!preview && (
<Button onClick={onClaim} disabled={isClaiming}> <div className="ml-auto">
{user ? 'Claim' : 'Login'} <Button onClick={onClaim} disabled={isClaiming}>
</Button> {user ? 'Claim' : 'Login'}
</div> </Button>
</div>
)}
</Row> </Row>
</div> </div>
) )
} }
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 <>
className={clsx( <Col
className, className={clsx(
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all' 'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
)} className,
> link.slug === highlightedSlug
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100"> ? 'shadow-lg shadow-indigo-500 transition-none'
<div> : ''
{maxUses != null )}
? `${maxUses - uses}/${maxUses} uses left` >
: `Unlimited use`} <div
className={clsx(
'relative flex flex-col rounded-t-lg bg-gradient-to-br transition-all',
getManalinkGradient(link.amount)
)}
>
{details && (
<ClaimsList
className="absolute h-full w-full bg-white opacity-90"
link={link}
/>
)}
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div>
{maxUses != null
? `${maxUses - claims.length}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className={clsx(
'my-auto block w-1/3 self-center py-3 transition-all',
details ? '' : 'group-hover:rotate-12'
)}
src="/logo-white.svg"
/>
</div> </div>
<div> <Col className="w-full rounded-b-lg bg-white px-4 py-2 text-lg">
{expiresTime != null <Row className="relative gap-1">
? `Expires ${fromNow(expiresTime)}` <div className="my-auto mb-1 w-full text-indigo-500">
: 'Never expires'} {formatMoney(amount)}
</div>
<ShareIconButton
manalink={link}
toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'}
onCopyButtonClassName={
'bg-indigo-100 text-indigo-500 transition-none hover:bg-indigo-100 hover:text-indigo-500'
}
/>
<button
onClick={() => setDetails(!details)}
className={clsx(
contractDetailsButtonClassName,
details
? 'bg-indigo-100 text-indigo-500 hover:bg-indigo-100 hover:text-indigo-500'
: ''
)}
>
<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>
<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> </div>
</Col> </Col>
</>
<img
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
/>
<Row className="rounded-b-lg bg-white p-2">
<Col className="text-md">
<div className="mb-1 text-indigo-500">{formatMoney(amount)}</div>
<div className="text-xs">{message}</div>
</Col>
</Row>
</div>
) )
} }
function Claim(props: { claim: Claim }) {
const { claim } = props
const who = useUserById(claim.toId)
return (
<Row className="my-1 gap-2 text-xs">
<div>{who?.name || 'Loading...'}</div>
<div className="text-gray-500">{fromNow(claim.claimedTime)}</div>
</Row>
)
}
function getManalinkGradient(amount: number) {
if (amount < 20) {
return 'from-slate-300 via-slate-500 to-slate-800'
} else if (amount >= 20 && amount < 50) {
return 'from-indigo-300 via-indigo-500 to-indigo-800'
} else if (amount >= 50 && amount < 100) {
return 'from-violet-300 via-violet-500 to-violet-800'
} else if (amount >= 100) {
return 'from-indigo-300 via-violet-500 to-rose-400'
}
}

View File

@ -4,7 +4,11 @@ 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,
ManalinkCardPreview,
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'
@ -191,7 +195,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

@ -1,17 +1,24 @@
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
}) { }) {
const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props const { page, itemsPerPage, totalItems, setPage, scrollToTop, className } =
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

@ -11,6 +11,8 @@ 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 { Group } from 'common/group'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { Manalink } from 'common/manalink'
import getManalinkUrl from 'web/get-manalink-url'
function copyContractWithReferral(contract: Contract, username?: string) { function copyContractWithReferral(contract: Contract, username?: string) {
const postFix = const postFix =
@ -30,37 +32,55 @@ function copyGroupWithReferral(group: Group, username?: string) {
) )
} }
function copyManalink(manalink: Manalink) {
copyToClipboard(getManalinkUrl(manalink.slug))
}
export function ShareIconButton(props: { export function ShareIconButton(props: {
contract?: Contract contract?: Contract
group?: Group group?: Group
manalink?: Manalink
buttonClassName?: string buttonClassName?: string
onCopyButtonClassName?: string
toastClassName?: string toastClassName?: string
username?: string username?: string
children?: React.ReactNode children?: React.ReactNode
iconClassName?: string
}) { }) {
const { const {
contract, contract,
manalink,
buttonClassName, buttonClassName,
onCopyButtonClassName,
toastClassName, toastClassName,
username, username,
group, group,
children, children,
iconClassName,
} = 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) if (contract) copyContractWithReferral(contract, username)
if (group) copyGroupWithReferral(group, username) if (group) copyGroupWithReferral(group, username)
if (manalink) copyManalink(manalink)
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

@ -28,7 +28,7 @@ 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">
<Title text={`Claim M$${manalink.amount} mana`} /> <Title text={`Claim M$${manalink.amount} mana`} />
<ManalinkCard <ManalinkCard
user={user} user={user}

View File

@ -21,8 +21,12 @@ import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
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 function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {
return `${location.protocol}//${location.host}/link/${slug}` return `${location.protocol}//${location.host}/link/${slug}`
} }
@ -37,6 +41,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
@ -65,12 +73,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 (
@ -118,127 +144,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>
)
}