diff --git a/web/components/button.tsx b/web/components/button.tsx new file mode 100644 index 00000000..3b59581b --- /dev/null +++ b/web/components/button.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +export default function Button(props: { + className?: string + onClick?: () => void + color: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' + children?: ReactNode + type?: 'button' | 'reset' | 'submit' +}) { + const { + className, + onClick, + children, + color = 'indigo', + type = 'button', + } = props + + return ( + + ) +} diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index 97f5951c..fec05919 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -67,3 +67,44 @@ export function ManalinkCard(props: { ) } + +export function ManalinkCardPreview(props: { + className?: string + info: ManalinkInfo + defaultMessage: string +}) { + const { className, defaultMessage, info } = 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 || defaultMessage}
+ +
+
+ ) +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx new file mode 100644 index 00000000..d74980cf --- /dev/null +++ b/web/components/manalinks/create-links-button.tsx @@ -0,0 +1,197 @@ +import clsx from 'clsx' +import { useState } from 'react' +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 { createManalink } from 'web/lib/firebase/manalinks' +import { Modal } from 'web/components/layout/modal' +import Textarea from 'react-expanding-textarea' +import dayjs from 'dayjs' +import Button from '../button' +import { getManalinkUrl } from 'web/pages/links' +import { DuplicateIcon } from '@heroicons/react/outline' + +export function CreateLinksButton(props: { + user: User + highlightedSlug: string + setHighlightedSlug: (slug: string) => void +}) { + const { user, highlightedSlug, setHighlightedSlug } = props + const [open, setOpen] = useState(false) + + return ( + <> + setOpen(newOpen)}> + + { + const slug = await createManalink({ + fromId: user.id, + amount: newManalink.amount, + expiresTime: newManalink.expiresTime, + maxUses: newManalink.maxUses, + message: newManalink.message, + }) + setHighlightedSlug(slug || '') + }} + /> + + + + + + ) +} + +function CreateManalinkForm(props: { + highlightedSlug: string + user: User + onCreate: (m: ManalinkInfo) => Promise +}) { + const { user, onCreate, highlightedSlug } = props + const [isCreating, setIsCreating] = useState(false) + const [finishedCreating, setFinishedCreating] = useState(false) + const [copyPressed, setCopyPressed] = useState(false) + setTimeout(() => setCopyPressed(false), 300) + + const [newManalink, setNewManalink] = useState({ + expiresTime: null, + amount: 100, + maxUses: 1, + uses: 0, + message: '', + }) + + return ( + <> + {!finishedCreating && ( +
{ + e.preventDefault() + setIsCreating(true) + onCreate(newManalink).finally(() => setIsCreating(false)) + setFinishedCreating(true) + }} + > + + <div className="flex flex-col flex-wrap gap-x-5 gap-y-2"> + <div className="form-control flex-auto"> + <label className="label">Amount</label> + <input + className="input input-bordered" + type="number" + value={newManalink.amount} + onChange={(e) => + setNewManalink((m) => { + return { ...m, amount: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="flex flex-col gap-2 md:flex-row"> + <div className="form-control"> + <label className="label">Uses</label> + <input + className="input input-bordered w-full" + type="number" + value={newManalink.maxUses ?? ''} + onChange={(e) => + setNewManalink((m) => { + return { ...m, maxUses: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="form-control"> + <label className="label">Expires in</label> + <input + value={ + newManalink.expiresTime != null + ? dayjs(newManalink.expiresTime).format( + 'YYYY-MM-DDTHH:mm' + ) + : '' + } + className="input input-bordered" + type="datetime-local" + onChange={(e) => { + setNewManalink((m) => { + return { + ...m, + expiresTime: e.target.value + ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() + : null, + } + }) + }} + ></input> + </div> + </div> + <div className="form-control w-full"> + <label className="label">Message</label> + <Textarea + placeholder={`From ${user.name}`} + className="input input-bordered resize-none" + autoFocus + value={newManalink.message} + rows="3" + onChange={(e) => + setNewManalink((m) => { + return { ...m, message: e.target.value } + }) + } + /> + </div> + </div> + <Button + type="submit" + color={'indigo'} + className={clsx( + 'mt-8 whitespace-nowrap drop-shadow-md', + isCreating ? 'disabled' : '' + )} + > + Create + </Button> + </form> + )} + {finishedCreating && ( + <> + <Title className="!my-0" text="Manalink Created!" /> + <ManalinkCardPreview + className="my-4" + defaultMessage={`From ${user.name}`} + info={newManalink} + /> + <Row + className={clsx( + 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', + copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' + )} + > + <div className="w-full select-text truncate"> + {getManalinkUrl(highlightedSlug)} + </div> + <DuplicateIcon + onClick={() => { + navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) + setCopyPressed(true) + }} + className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" + /> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/subtitle.tsx b/web/components/subtitle.tsx new file mode 100644 index 00000000..85d19778 --- /dev/null +++ b/web/components/subtitle.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx' + +export function Subtitle(props: { text: string; className?: string }) { + const { text, className } = props + return ( + <h1 + className={clsx( + 'mt-6 mb-2 inline-block text-lg text-indigo-500 sm:mt-6 sm:mb-2 sm:text-xl', + className + )} + > + {text} + </h1> + ) +} diff --git a/web/get-manalink-url.ts b/web/get-manalink-url.ts new file mode 100644 index 00000000..89a0c8a6 --- /dev/null +++ b/web/get-manalink-url.ts @@ -0,0 +1,3 @@ +export default function getManalinkUrl(slug: string) { + return `${location.protocol}//${location.host}/link/${slug}` +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 12cde274..ede997df 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -4,41 +4,29 @@ 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' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' +import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' -import { createManalink, 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-users' import { ManalinkTxn } from 'common/txn' -import { User } from 'common/user' -import { Tabs } from 'web/components/layout/tabs' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' -import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' - -import Textarea from 'react-expanding-textarea' +import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' dayjs.extend(customParseFormat) -function getLinkUrl(slug: string) { +export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } -// TODO: incredibly gross, but the tab component is wrongly designed and -// keeps the tab state inside of itself, so this seems like the only -// way we can tell it to switch tabs from outside after initial render. -function setTabIndex(tabIndex: number) { - const tabHref = document.getElementById(`tab-${tabIndex}`) - if (tabHref) { - tabHref.click() - } -} - export default function LinkPage() { const user = useUser() const links = useUserManalinks(user?.id ?? '') @@ -58,166 +46,31 @@ export default function LinkPage() { <Page> <SEO title="Manalinks" - description="Send mana to anyone via link!" + description="Send M$ to others with a link, even if they don't have a Manifold account yet!" url="/send" /> <Col className="w-full px-8"> - <Title text="Manalinks" /> - <Tabs - labelClassName={'pb-2 pt-1 '} - defaultIndex={0} - tabs={[ - { - title: 'Create a link', - content: ( - <CreateManalinkForm - user={user} - onCreate={async (newManalink) => { - const slug = await createManalink({ - fromId: user.id, - amount: newManalink.amount, - expiresTime: newManalink.expiresTime, - maxUses: newManalink.maxUses, - message: newManalink.message, - }) - setTabIndex(1) - setHighlightedSlug(slug || '') - }} - /> - ), - }, - { - title: 'Unclaimed links', - content: ( - <LinksTable - links={unclaimedLinks} - highlightedSlug={highlightedSlug} - /> - ), - }, - // TODO: we have no use case for this atm and it's also really inefficient - // { - // title: 'Claimed', - // content: <ClaimsList txns={manalinkTxns} />, - // }, - ]} - /> + <Row className="items-center justify-between"> + <Title text="Manalinks" /> + {user && ( + <CreateLinksButton + user={user} + highlightedSlug={highlightedSlug} + setHighlightedSlug={setHighlightedSlug} + /> + )} + </Row> + <p> + You can use manalinks to send mana to other people, even if they + don't yet have a Manifold account. + </p> + <Subtitle text="Your Manalinks" /> + <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> </Col> </Page> ) } -function CreateManalinkForm(props: { - user: User - onCreate: (m: ManalinkInfo) => Promise<void> -}) { - const { user, onCreate } = props - const [isCreating, setIsCreating] = useState(false) - const [newManalink, setNewManalink] = useState<ManalinkInfo>({ - expiresTime: null, - amount: 100, - maxUses: 1, - uses: 0, - message: '', - }) - return ( - <> - <p> - You can use manalinks to send mana to other people, even if they - don't yet have a Manifold account. - </p> - <form - className="my-5" - onSubmit={(e) => { - e.preventDefault() - setIsCreating(true) - onCreate(newManalink).finally(() => setIsCreating(false)) - }} - > - <div className="flex flex-row flex-wrap gap-x-5 gap-y-2"> - <div className="form-control flex-auto"> - <label className="label">Amount</label> - <input - className="input" - type="number" - value={newManalink.amount} - onChange={(e) => - setNewManalink((m) => { - return { ...m, amount: parseInt(e.target.value) } - }) - } - ></input> - </div> - <div className="form-control flex-auto"> - <label className="label">Uses</label> - <input - className="input" - type="number" - value={newManalink.maxUses ?? ''} - onChange={(e) => - setNewManalink((m) => { - return { ...m, maxUses: parseInt(e.target.value) } - }) - } - ></input> - </div> - <div className="form-control flex-auto"> - <label className="label">Expires at</label> - <input - value={ - newManalink.expiresTime != null - ? dayjs(newManalink.expiresTime).format('YYYY-MM-DDTHH:mm') - : '' - } - className="input" - type="datetime-local" - onChange={(e) => { - setNewManalink((m) => { - return { - ...m, - expiresTime: e.target.value - ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() - : null, - } - }) - }} - ></input> - </div> - </div> - <div className="form-control w-full"> - <label className="label">Message</label> - <Textarea - placeholder={`From ${user.name}`} - className="input input-bordered resize-none" - autoFocus - value={newManalink.message} - onChange={(e) => - setNewManalink((m) => { - return { ...m, message: e.target.value } - }) - } - /> - </div> - <button - type="submit" - className={clsx('btn mt-5', isCreating ? 'loading disabled' : '')} - > - {isCreating ? '' : 'Create'} - </button> - </form> - - <Title text="Preview" /> - <p>This is what the person you send the link to will see:</p> - <ManalinkCard - className="my-5" - defaultMessage={`From ${user.name}`} - info={newManalink} - isClaiming={false} - /> - </> - ) -} - export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -334,8 +187,8 @@ function LinkSummaryRow(props: { }) { const { link, highlight, expanded, onToggle } = props const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer', - highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white' + '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}> @@ -350,7 +203,7 @@ function LinkSummaryRow(props: { <td className="px-5 py-4 font-medium text-gray-900"> {formatMoney(link.amount)} </td> - <td className="px-5 py-4">{getLinkUrl(link.slug)}</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"> @@ -365,22 +218,27 @@ function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { return links.length == 0 ? ( <p>You don't currently have any outstanding manalinks.</p> ) : ( - <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 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> ) }