From 7a041fd753daa00cb9a17a3cacc9ca399ece6db4 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 20 Jul 2022 22:45:53 -0700 Subject: [PATCH] Changing manalinks table UI (#665) From table to card view --- web/components/contract/contract-details.tsx | 9 +- .../contract/contract-info-dialog.tsx | 2 +- web/components/manalink-card.tsx | 262 +++++++++++++----- .../manalinks/create-links-button.tsx | 5 +- web/components/pagination.tsx | 9 +- web/components/share-icon-button.tsx | 49 ++-- web/pages/link/[slug].tsx | 64 +++-- web/pages/links.tsx | 155 ++--------- 8 files changed, 285 insertions(+), 270 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index b4d67520..036311fe 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -11,6 +11,7 @@ import { UserLink } from '../user-page' import { Contract, contractMetrics, + contractPath, contractPool, updateContract, } from 'web/lib/firebase/contracts' @@ -33,6 +34,7 @@ import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' +import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -222,9 +224,12 @@ export function ContractDetails(props: {
{volumeLabel}
{!disabled && } diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index a0c7fcc9..d976253f 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,7 +19,7 @@ import { InfoTooltip } from '../info-tooltip' import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index b5a79091..b49e1621 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -3,9 +3,13 @@ import { formatMoney } from 'common/util/format' import { fromNow } from 'web/lib/util/time' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' -import { User } from 'web/lib/firebase/users' -import { Button } from './button' - +import { Claim, Manalink } from 'common/manalink' +import { useState } from 'react' +import { ShareIconButton } from './share-icon-button' +import { DotsHorizontalIcon } from '@heroicons/react/solid' +import { contractDetailsButtonClassName } from './contract/contract-info-dialog' +import { useUserById } from 'web/hooks/use-user' +import getManalinkUrl from 'web/get-manalink-url' export type ManalinkInfo = { expiresTime: number | null maxUses: number | null @@ -15,94 +19,202 @@ export type ManalinkInfo = { } export function ManalinkCard(props: { - user: User | null | undefined - className?: string info: ManalinkInfo - isClaiming: boolean - onClaim?: () => void + className?: string + preview?: boolean }) { - const { user, className, isClaiming, info, onClaim } = props + const { className, info, preview = false } = props const { expiresTime, maxUses, uses, amount, message } = info return ( -
+
+ +
+ {maxUses != null + ? `${maxUses - uses}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ + + + + +
+ {formatMoney(amount)} +
+
{message}
+ +
+
+ + ) +} + +export function ManalinkCardFromView(props: { + className?: string + link: Manalink + highlightedSlug: string +}) { + const { className, link, highlightedSlug } = props + const { message, amount, expiresTime, maxUses, claims } = link + const [details, setDetails] = useState(false) + + return ( + - -
- {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} -
-
- {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} -
- - - - - -
+
setDetails(!details)} + > + {details && ( + + )} + +
+ {maxUses != null + ? `${maxUses - claims.length}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ + +
+ + +
{formatMoney(amount)}
-
{message}
- - -
- -
-
-
+ + +
+
{message || '\n\n'}
+ + ) } -export function ManalinkCardPreview(props: { - className?: string - info: ManalinkInfo -}) { - const { className, info } = props - const { expiresTime, maxUses, uses, amount, message } = info +function ClaimsList(props: { link: Manalink; className: string }) { + const { link, className } = props return ( -
- -
- {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} + <> + +
+ Claimed by...
-
- {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} +
+ {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-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' + } +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 0d1d603e..25b51bb2 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -4,7 +4,7 @@ import { Col } from '../layout/col' import { Row } from '../layout/row' import { Title } from '../title' import { User } from 'common/user' -import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card' +import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' import Textarea from 'react-expanding-textarea' @@ -37,6 +37,7 @@ export function CreateLinksButton(props: { message: newManalink.message, }) setHighlightedSlug(slug || '') + setTimeout(() => setHighlightedSlug(''), 3700) }} /> @@ -191,7 +192,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 069ebda7..3f4108bc 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,9 +1,12 @@ +import clsx from 'clsx' + export function Pagination(props: { page: number itemsPerPage: number totalItems: number setPage: (page: number) => void scrollToTop?: boolean + className?: string nextTitle?: string prevTitle?: string }) { @@ -15,13 +18,17 @@ export function Pagination(props: { scrollToTop, nextTitle, prevTitle, + className, } = props const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 return ( <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + className={clsx( + 'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6', + className + )} aria-label="Pagination" > <div className="hidden sm:block"> diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx index 507d90c2..4db192a9 100644 --- a/web/components/share-icon-button.tsx +++ b/web/components/share-icon-button.tsx @@ -2,65 +2,48 @@ import React, { useState } from 'react' import { ShareIcon } from '@heroicons/react/outline' import clsx from 'clsx' -import { Contract } from 'common/contract' import { copyToClipboard } from 'web/lib/util/copy' -import { contractPath } from 'web/lib/firebase/contracts' -import { ENV_CONFIG } from 'common/envs/constants' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' -import { Group } from 'common/group' -import { groupPath } from 'web/lib/firebase/groups' - -function copyContractWithReferral(contract: Contract, username?: string) { - const postFix = - username && contract.creatorUsername !== username - ? '?referrer=' + username - : '' - copyToClipboard( - `https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}` - ) -} - -// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically -function copyGroupWithReferral(group: Group, username?: string) { - const postFix = username ? '?referrer=' + username : '' - copyToClipboard( - `https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}` - ) -} export function ShareIconButton(props: { - contract?: Contract - group?: Group buttonClassName?: string + onCopyButtonClassName?: string toastClassName?: string - username?: string children?: React.ReactNode + iconClassName?: string + copyPayload: string }) { const { - contract, buttonClassName, + onCopyButtonClassName, toastClassName, - username, - group, children, + iconClassName, + copyPayload, } = props const [showToast, setShowToast] = useState(false) return ( <div className="relative z-10 flex-shrink-0"> <button - className={clsx(contractDetailsButtonClassName, buttonClassName)} + className={clsx( + contractDetailsButtonClassName, + buttonClassName, + showToast ? onCopyButtonClassName : '' + )} onClick={() => { - if (contract) copyContractWithReferral(contract, username) - if (group) copyGroupWithReferral(group, username) + copyToClipboard(copyPayload) track('copy share link') setShowToast(true) setTimeout(() => setShowToast(false), 2000) }} > - <ShareIcon className="h-[24px] w-5" aria-hidden="true" /> + <ShareIcon + className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} + aria-hidden="true" + /> {children} </button> diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 8ad9850f..119fec77 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -7,6 +7,8 @@ import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' export default function ClaimPage() { const user = useUser() @@ -28,34 +30,42 @@ export default function ClaimPage() { description="Send mana to anyone via link!" url="/send" /> - <div className="mx-auto max-w-xl"> - <Title text={`Claim M$${manalink.amount} mana`} /> - <ManalinkCard - user={user} - info={info} - isClaiming={claiming} - onClaim={async () => { - setClaiming(true) - try { - if (user == null) { - await firebaseLogin() + <div className="mx-auto max-w-xl px-2"> + <Row className="items-center justify-between"> + <Title text={`Claim M$${manalink.amount} mana`} /> + <div className="my-auto"> + <Button + onClick={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + setClaiming(false) + return + } + if (user?.id == manalink.fromId) { + throw new Error("You can't claim your own manalink.") + } + await claimManalink({ slug: manalink.slug }) + user && router.push(`/${user.username}?claimed-mana=yes`) + } catch (e) { + console.log(e) + const message = + e && e instanceof Object + ? e.toString() + : 'An error occurred.' + setError(message) + } setClaiming(false) - return - } - if (user?.id == manalink.fromId) { - throw new Error("You can't claim your own manalink.") - } - await claimManalink({ slug: manalink.slug }) - user && router.push(`/${user.username}?claimed-mana=yes`) - } catch (e) { - console.log(e) - const message = - e && e instanceof Object ? e.toString() : 'An error occurred.' - setError(message) - } - setClaiming(false) - }} - /> + }} + disabled={claiming} + size="lg" + > + {user ? 'Claim' : 'Login'} + </Button> + </div> + </Row> + <ManalinkCard info={info} /> {error && ( <section className="my-5 text-red-500"> <p>Failed to claim manalink.</p> diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 490f1878..8a2e6767 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -1,7 +1,4 @@ -import clsx from 'clsx' import { useState } from 'react' -import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' -import { Claim, Manalink } from 'common/manalink' import { formatMoney } from 'common/util/format' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -11,7 +8,6 @@ import { Title } from 'web/components/title' import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' import { useUserManalinks } from 'web/lib/firebase/manalinks' -import { fromNow } from 'web/lib/util/time' import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' import { Avatar } from 'web/components/avatar' @@ -22,8 +18,11 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' +import { ManalinkCardFromView } from 'web/components/manalink-card' +import { Pagination } from 'web/components/pagination' dayjs.extend(customParseFormat) +const LINKS_PER_PAGE = 24 export const getServerSideProps = redirectIfLoggedOut('/') export function getManalinkUrl(slug: string) { @@ -40,6 +39,10 @@ export default function LinkPage() { (l.maxUses == null || l.claimedUserIds.length < l.maxUses) && (l.expiresTime == null || l.expiresTime > Date.now()) ) + const [page, setPage] = useState(0) + const start = page * LINKS_PER_PAGE + const end = start + LINKS_PER_PAGE + const displayedLinks = unclaimedLinks.slice(start, end) if (user == null) { return null @@ -68,12 +71,30 @@ export default function LinkPage() { don't yet have a Manifold account. </p> <Subtitle text="Your Manalinks" /> - <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> + <Col className="grid w-full gap-4 md:grid-cols-2"> + {displayedLinks.map((link) => { + return ( + <ManalinkCardFromView + link={link} + highlightedSlug={highlightedSlug} + /> + ) + })} + </Col> + <Pagination + page={page} + itemsPerPage={LINKS_PER_PAGE} + totalItems={unclaimedLinks.length} + setPage={setPage} + className="mt-4 bg-transparent" + scrollToTop + /> </Col> </Page> ) } +// TODO: either utilize this or get rid of it export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -121,127 +142,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) { </div> ) } - -function ClaimTableRow(props: { claim: Claim }) { - const { claim } = props - const who = useUserById(claim.toId) - return ( - <tr> - <td className="px-5 py-2">{who?.name || 'Loading...'}</td> - <td className="px-5 py-2">{`${new Date( - claim.claimedTime - ).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td> - </tr> - ) -} - -function LinkDetailsTable(props: { link: Manalink }) { - const { link } = props - return ( - <table className="w-full divide-y divide-gray-300 border border-gray-400"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th className="px-5 py-2">Claimed by</th> - <th className="px-5 py-2">Time</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> - {link.claims.length ? ( - link.claims.map((claim) => <ClaimTableRow claim={claim} />) - ) : ( - <tr> - <td className="px-5 py-2" colSpan={2}> - No claims yet. - </td> - </tr> - )} - </tbody> - </table> - ) -} - -function LinkTableRow(props: { link: Manalink; highlight: boolean }) { - const { link, highlight } = props - const [expanded, setExpanded] = useState(false) - return ( - <> - <LinkSummaryRow - link={link} - highlight={highlight} - expanded={expanded} - onToggle={() => setExpanded((exp) => !exp)} - /> - {expanded && ( - <tr> - <td className="bg-gray-100 p-3" colSpan={5}> - <LinkDetailsTable link={link} /> - </td> - </tr> - )} - </> - ) -} - -function LinkSummaryRow(props: { - link: Manalink - highlight: boolean - expanded: boolean - onToggle: () => void -}) { - const { link, highlight, expanded, onToggle } = props - const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', - highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' - ) - return ( - <tr id={link.slug} key={link.slug} className={className}> - <td className="py-4 pl-5" onClick={onToggle}> - {expanded ? ( - <ChevronUpIcon className="h-5 w-5" /> - ) : ( - <ChevronDownIcon className="h-5 w-5" /> - )} - </td> - - <td className="px-5 py-4 font-medium text-gray-900"> - {formatMoney(link.amount)} - </td> - <td className="px-5 py-4">{getManalinkUrl(link.slug)}</td> - <td className="px-5 py-4">{link.claimedUserIds.length}</td> - <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> - <td className="px-5 py-4"> - {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} - </td> - </tr> - ) -} - -function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { - const { links, highlightedSlug } = props - return links.length == 0 ? ( - <p>You don't currently have any outstanding manalinks.</p> - ) : ( - <div className="overflow-scroll"> - <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th></th> - <th className="px-5 py-3.5">Amount</th> - <th className="px-5 py-3.5">Link</th> - <th className="px-5 py-3.5">Uses</th> - <th className="px-5 py-3.5">Max Uses</th> - <th className="px-5 py-3.5">Expires</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white"> - {links.map((link) => ( - <LinkTableRow - link={link} - highlight={link.slug === highlightedSlug} - /> - ))} - </tbody> - </table> - </div> - ) -}