From 1f72354313cb507e4b843a92fc6248bfec82e87d Mon Sep 17 00:00:00 2001 From: ingawei Date: Tue, 19 Jul 2022 04:07:35 -0700 Subject: [PATCH] initial commit of viewing manalinks --- .../contract/contract-info-dialog.tsx | 2 +- web/components/manalink-card.tsx | 201 ++++++++++++++---- .../manalinks/create-links-button.tsx | 8 +- web/components/pagination.tsx | 11 +- web/components/share-icon-button.tsx | 24 ++- web/pages/link/[slug].tsx | 2 +- web/pages/links.tsx | 152 +++---------- 7 files changed, 223 insertions(+), 177 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index b5ecea15..0393d10f 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -20,7 +20,7 @@ import { TagsInput } from 'web/components/tags-input' import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index b5a79091..bd72e75e 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -5,6 +5,12 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { User } from 'web/lib/firebase/users' import { Button } from './button' +import { Claim, Manalink } from 'common/manalink' +import { useState } from 'react' +import { ShareIconButton } from './share-icon-button' +import { DotsHorizontalIcon, XCircleIcon } from '@heroicons/react/solid' +import { contractDetailsButtonClassName } from './contract/contract-info-dialog' +import { useUserById } from 'web/hooks/use-user' export type ManalinkInfo = { expiresTime: number | null @@ -15,19 +21,21 @@ export type ManalinkInfo = { } export function ManalinkCard(props: { - user: User | null | undefined + user?: User | null | undefined className?: string info: ManalinkInfo - isClaiming: boolean + isClaiming?: boolean 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 return (
@@ -44,12 +52,18 @@ export function ManalinkCard(props: { - +
{formatMoney(amount)} @@ -57,52 +71,151 @@ export function ManalinkCard(props: {
{message}
-
- -
+ {!preview && ( +
+ +
+ )}
) } -export function ManalinkCardPreview(props: { +export function ManalinkCardFromView(props: { className?: string - info: ManalinkInfo + link: Manalink + highlightedSlug: string }) { - const { className, info } = props - const { expiresTime, maxUses, uses, amount, message } = info + const { className, link, highlightedSlug } = props + const { message, amount, expiresTime, maxUses, claims } = link + + const [details, setDetails] = useState(false) + return ( -
- -
- {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} + <> + +
+ {details && ( + + )} + +
+ {maxUses != null + ? `${maxUses - claims.length}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ +
-
- {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} + + +
+ {formatMoney(amount)} +
+ + +
+
{message || '\n\n'}
+ + + + ) +} + +function ClaimsList(props: { link: Manalink; className: string }) { + const { link, className } = props + return ( + <> + +
+ Claimed by... +
+
+ {link.claims.length > 0 ? ( + <> + {link.claims.map((claim) => ( + + + + ))} + + ) : ( +
+ No one has claimed this manalink yet! Share your manalink to start + spreading the wealth. +
+ )}
- - - - -
{formatMoney(amount)}
-
{message}
- -
-
+ ) } + +function Claim(props: { claim: Claim }) { + const { claim } = props + const who = useUserById(claim.toId) + return ( + +
{who?.name || 'Loading...'}
+
{fromNow(claim.claimedTime)}
+
+ ) +} + +function getManalinkGradient(amount: number) { + if (amount < 20) { + return 'from-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' + } +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 0d1d603e..21e137c3 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -4,7 +4,11 @@ import { Col } from '../layout/col' import { Row } from '../layout/row' import { Title } from '../title' import { User } from 'common/user' -import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card' +import { + ManalinkCard, + ManalinkCardPreview, + ManalinkInfo, +} from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' import Textarea from 'react-expanding-textarea' @@ -191,7 +195,7 @@ function CreateManalinkForm(props: { {finishedCreating && ( <> - <ManalinkCardPreview className="my-4" info={newManalink} /> + <ManalinkCard className="my-4" info={newManalink} preview /> <Row className={clsx( 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index a585985d..17f38af5 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,17 +1,24 @@ +import clsx from 'clsx' + export function Pagination(props: { page: number itemsPerPage: number totalItems: number setPage: (page: number) => void 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 return ( <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + className={clsx( + 'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6', + className + )} aria-label="Pagination" > <div className="hidden sm:block"> diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx index 507d90c2..80853010 100644 --- a/web/components/share-icon-button.tsx +++ b/web/components/share-icon-button.tsx @@ -11,6 +11,8 @@ import { track } from 'web/lib/service/analytics' import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' import { Group } from 'common/group' import { groupPath } from 'web/lib/firebase/groups' +import { Manalink } from 'common/manalink' +import getManalinkUrl from 'web/get-manalink-url' function copyContractWithReferral(contract: Contract, username?: string) { const postFix = @@ -30,37 +32,55 @@ function copyGroupWithReferral(group: Group, username?: string) { ) } +function copyManalink(manalink: Manalink) { + copyToClipboard(getManalinkUrl(manalink.slug)) +} + export function ShareIconButton(props: { contract?: Contract group?: Group + manalink?: Manalink buttonClassName?: string + onCopyButtonClassName?: string toastClassName?: string username?: string children?: React.ReactNode + iconClassName?: string }) { const { contract, + manalink, buttonClassName, + onCopyButtonClassName, toastClassName, username, group, children, + iconClassName, } = props const [showToast, setShowToast] = useState(false) return ( <div className="relative z-10 flex-shrink-0"> <button - className={clsx(contractDetailsButtonClassName, buttonClassName)} + className={clsx( + contractDetailsButtonClassName, + buttonClassName, + showToast ? onCopyButtonClassName : '' + )} onClick={() => { if (contract) copyContractWithReferral(contract, username) if (group) copyGroupWithReferral(group, username) + if (manalink) copyManalink(manalink) track('copy share link') setShowToast(true) setTimeout(() => setShowToast(false), 2000) }} > - <ShareIcon className="h-[24px] w-5" aria-hidden="true" /> + <ShareIcon + className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} + aria-hidden="true" + /> {children} </button> diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 8ad9850f..d3691a67 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -28,7 +28,7 @@ export default function ClaimPage() { description="Send mana to anyone via link!" 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`} /> <ManalinkCard user={user} diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 76c62978..eb029b6a 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -21,8 +21,12 @@ import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' +import { ManalinkCardFromView } from 'web/components/manalink-card' +import { Pagination } from 'web/components/pagination' dayjs.extend(customParseFormat) +const LINKS_PER_PAGE = 24 + export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } @@ -37,6 +41,10 @@ export default function LinkPage() { (l.maxUses == null || l.claimedUserIds.length < l.maxUses) && (l.expiresTime == null || l.expiresTime > Date.now()) ) + const [page, setPage] = useState(0) + const start = page * LINKS_PER_PAGE + const end = start + LINKS_PER_PAGE + const displayedLinks = unclaimedLinks.slice(start, end) if (user == null) { return null @@ -65,12 +73,30 @@ export default function LinkPage() { don't yet have a Manifold account. </p> <Subtitle text="Your Manalinks" /> - <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> + <Col className="grid w-full gap-4 md:grid-cols-2"> + {displayedLinks.map((link) => { + return ( + <ManalinkCardFromView + link={link} + highlightedSlug={highlightedSlug} + /> + ) + })} + </Col> + <Pagination + page={page} + itemsPerPage={LINKS_PER_PAGE} + totalItems={unclaimedLinks.length} + setPage={setPage} + className="mt-4 bg-transparent" + scrollToTop + /> </Col> </Page> ) } +// TODO: either utilize this or get rid of it export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -118,127 +144,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) { </div> ) } - -function ClaimTableRow(props: { claim: Claim }) { - const { claim } = props - const who = useUserById(claim.toId) - return ( - <tr> - <td className="px-5 py-2">{who?.name || 'Loading...'}</td> - <td className="px-5 py-2">{`${new Date( - claim.claimedTime - ).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td> - </tr> - ) -} - -function LinkDetailsTable(props: { link: Manalink }) { - const { link } = props - return ( - <table className="w-full divide-y divide-gray-300 border border-gray-400"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th className="px-5 py-2">Claimed by</th> - <th className="px-5 py-2">Time</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> - {link.claims.length ? ( - link.claims.map((claim) => <ClaimTableRow claim={claim} />) - ) : ( - <tr> - <td className="px-5 py-2" colSpan={2}> - No claims yet. - </td> - </tr> - )} - </tbody> - </table> - ) -} - -function LinkTableRow(props: { link: Manalink; highlight: boolean }) { - const { link, highlight } = props - const [expanded, setExpanded] = useState(false) - return ( - <> - <LinkSummaryRow - link={link} - highlight={highlight} - expanded={expanded} - onToggle={() => setExpanded((exp) => !exp)} - /> - {expanded && ( - <tr> - <td className="bg-gray-100 p-3" colSpan={5}> - <LinkDetailsTable link={link} /> - </td> - </tr> - )} - </> - ) -} - -function LinkSummaryRow(props: { - link: Manalink - highlight: boolean - expanded: boolean - onToggle: () => void -}) { - const { link, highlight, expanded, onToggle } = props - const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', - highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' - ) - return ( - <tr id={link.slug} key={link.slug} className={className}> - <td className="py-4 pl-5" onClick={onToggle}> - {expanded ? ( - <ChevronUpIcon className="h-5 w-5" /> - ) : ( - <ChevronDownIcon className="h-5 w-5" /> - )} - </td> - - <td className="px-5 py-4 font-medium text-gray-900"> - {formatMoney(link.amount)} - </td> - <td className="px-5 py-4">{getManalinkUrl(link.slug)}</td> - <td className="px-5 py-4">{link.claimedUserIds.length}</td> - <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> - <td className="px-5 py-4"> - {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} - </td> - </tr> - ) -} - -function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { - const { links, highlightedSlug } = props - return links.length == 0 ? ( - <p>You don't currently have any outstanding manalinks.</p> - ) : ( - <div className="overflow-scroll"> - <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th></th> - <th className="px-5 py-3.5">Amount</th> - <th className="px-5 py-3.5">Link</th> - <th className="px-5 py-3.5">Uses</th> - <th className="px-5 py-3.5">Max Uses</th> - <th className="px-5 py-3.5">Expires</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white"> - {links.map((link) => ( - <LinkTableRow - link={link} - highlight={link.slug === highlightedSlug} - /> - ))} - </tbody> - </table> - </div> - ) -}