From 6cc2d8af58bf9680ba2574e2fec4e93c25882518 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Thu, 23 Jun 2022 03:07:52 -0500 Subject: [PATCH] Manalink: Send mana to anyone via link (#114) * Set up Firestore structure for mana bounty links * Split up manalinks into successes and failures * Allow clients to create manalinks * Track txnId and successful users * Store custom amounts in the link * List all manalinks you've created * Support backend for claiming manalinks * Add some more error handling * Tweak readme * Fix typescript breakage * Revert "Convert common imports in functions to be absolute" This reverts commit c03518e9063848f4116e61d3a4f1188f7acedbb8. * Scaffolding so `claimManalink` works * Clean up imports * Barebones endpoint to claim mana * Fix rules to only allow link creators to query * Design out claim giftcard * List all claimed transactions * Style in a more awesome card * Fix import * Padding tweak * Fix useManalinkTxns hook * /send -> /link * Tidy up some details * Do a bunch of random manalinks work * Fix up LinksTable to build * Clean up LinksTable an absurd amount * Basic details functionality on manalinks table * Work on manalink claim stuff * Fix up some merge mess * Not-signed-in flow implemented * Better manalinks table * Only show outstanding links in table * Use new `ManalinkTxn` type * /link -> /links * Change manalinks page UI to use nice looking tabs * Many fixes to manalinks UI * Default to 1 use * Tidying up * Some copy changes based on feedback * Add required index Co-authored-by: Marshall Polaris --- common/manalink.ts | 35 +++ common/txn.ts | 10 +- firestore.indexes.json | 14 ++ firestore.rules | 10 + functions/README.md | 2 +- functions/src/claim-manalink.ts | 102 ++++++++ functions/src/index.ts | 1 + functions/src/transact.ts | 124 ++++------ web/components/layout/tabs.tsx | 1 + web/components/manalink-card.tsx | 69 ++++++ web/components/user-page.tsx | 24 +- web/lib/firebase/fn-call.ts | 5 + web/lib/firebase/manalinks.ts | 94 ++++++++ web/lib/firebase/txns.ts | 30 ++- web/lib/firebase/users.ts | 2 +- web/package.json | 1 + web/pages/link/[slug].tsx | 68 ++++++ web/pages/links.tsx | 386 +++++++++++++++++++++++++++++++ yarn.lock | 5 + 19 files changed, 901 insertions(+), 82 deletions(-) create mode 100644 common/manalink.ts create mode 100644 functions/src/claim-manalink.ts create mode 100644 web/components/manalink-card.tsx create mode 100644 web/lib/firebase/manalinks.ts create mode 100644 web/pages/link/[slug].tsx create mode 100644 web/pages/links.tsx diff --git a/common/manalink.ts b/common/manalink.ts new file mode 100644 index 00000000..7dc3b8dc --- /dev/null +++ b/common/manalink.ts @@ -0,0 +1,35 @@ +export type Manalink = { + // The link to send: https://manifold.markets/send/{slug} + // Also functions as the unique id for the link. + slug: string + + // Note: we assume both fromId and toId are of SourceType 'USER' + fromId: string + + // Displayed to people claiming the link + message: string + + // How much to send with the link + amount: number + token: 'M$' // TODO: could send eg YES shares too?? + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + // If null, the link can be used infinitely + maxUses: number | null + + // Used for simpler caching + claimedUserIds: string[] + // Successful redemptions of the link + claims: Claim[] +} + +export type Claim = { + toId: string + + // The ID of the successful txn that tracks the money moved + txnId: string + + claimedTime: number +} diff --git a/common/txn.ts b/common/txn.ts index c64eddbb..25d4a1c3 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip +type AnyTxnType = Donation | Tip | Manalink type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,6 +16,7 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome + category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' // Any extra data data?: { [key: string]: any } @@ -39,5 +40,12 @@ type Tip = { } } +type Manalink = { + fromType: 'USER' + toType: 'USER' + category: 'MANALINK' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip +export type ManalinkTxn = Txn & Manalink diff --git a/firestore.indexes.json b/firestore.indexes.json index 5dd029df..064f6f2f 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -323,6 +323,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "manalinks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fromId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index 2be7f21a..7f02a43d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -104,6 +104,16 @@ service cloud.firestore { allow read; } + // Note: `resource` = existing doc, `request.resource` = incoming doc + match /manalinks/{slug} { + // Anyone can view any manalink + allow get; + // Only you can create a manalink with your fromId + allow create: if request.auth.uid == request.resource.data.fromId; + // Only you can list and change your own manalinks + allow list, update: if request.auth.uid == resource.data.fromId; + } + match /users/{userId}/notifications/{notificationId} { allow read; allow update: if resource.data.userId == request.auth.uid diff --git a/functions/README.md b/functions/README.md index 7fd312c3..031cc4fa 100644 --- a/functions/README.md +++ b/functions/README.md @@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying 0. `$ firebase use prod` to switch to prod -1. `$ yarn deploy` to push your changes live! +1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) ## Secrets management diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts new file mode 100644 index 00000000..4bcd8b16 --- /dev/null +++ b/functions/src/claim-manalink.ts @@ -0,0 +1,102 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from 'common/user' +import { Manalink } from 'common/manalink' +import { runTxn, TxnData } from './transact' + +export const claimManalink = functions + .runWith({ minInstances: 1 }) + .https.onCall(async (slug: string, context) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + // Look up the manalink + const manalinkDoc = firestore.doc(`manalinks/${slug}`) + const manalinkSnap = await transaction.get(manalinkDoc) + if (!manalinkSnap.exists) { + return { status: 'error', message: 'Manalink not found' } + } + const manalink = manalinkSnap.data() as Manalink + + const { amount, fromId, claimedUserIds } = manalink + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: `User ${fromId} not found` } + } + const fromUser = fromSnap.data() as User + + // Only permit one redemption per user per link + if (claimedUserIds.includes(userId)) { + return { + status: 'error', + message: `${fromUser.name} already redeemed manalink ${slug}`, + } + } + + // Disallow expired or maxed out links + if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { + return { + status: 'error', + message: `Manalink ${slug} expired on ${new Date( + manalink.expiresTime + ).toLocaleString()}`, + } + } + if ( + manalink.maxUses != null && + manalink.maxUses <= manalink.claims.length + ) { + return { + status: 'error', + message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`, + } + } + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `, + } + } + + // Actually execute the txn + const data: TxnData = { + fromId, + fromType: 'USER', + toId: userId, + toType: 'USER', + amount, + token: 'M$', + category: 'MANALINK', + description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`, + } + const result = await runTxn(transaction, data) + const txnId = result.txn?.id + if (!txnId) { + return { status: 'error', message: result.message } + } + + // Update the manalink object with this info + const claim = { + toId: userId, + txnId, + claimedTime: Date.now(), + } + transaction.update(manalinkDoc, { + claimedUserIds: [...claimedUserIds, userId], + claims: [...manalink.claims, claim], + }) + + return { status: 'success', message: 'Manalink claimed' } + }) + }) + +const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 7bcd2199..0a538ff8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -4,6 +4,7 @@ admin.initializeApp() // v1 // export * from './keep-awake' +export * from './claim-manalink' export * from './transact' export * from './resolve-market' export * from './stripe' diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 397632ea..cd091b83 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -5,23 +5,15 @@ import { User } from '../../common/user' import { Txn } from '../../common/txn' import { removeUndefinedProps } from '../../common/util/object' +export type TxnData = Omit + export const transact = functions .runWith({ minInstances: 1 }) - .https.onCall(async (data: Omit, context) => { + .https.onCall(async (data: TxnData, context) => { const userId = context?.auth?.uid if (!userId) return { status: 'error', message: 'Not authorized' } - const { - amount, - fromType, - fromId, - toId, - toType, - category, - token, - data: innerData, - description, - } = data + const { amount, fromType, fromId } = data if (fromType !== 'USER') return { @@ -40,69 +32,53 @@ export const transact = functions // Run as transaction to prevent race conditions. return await firestore.runTransaction(async (transaction) => { - const fromDoc = firestore.doc(`users/${userId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const fromUser = fromSnap.data() as User - - if (amount > 0 && fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, - } - } - - if (toType === 'USER') { - const toDoc = firestore.doc(`users/${toId}`) - const toSnap = await transaction.get(toDoc) - if (!toSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const toUser = toSnap.data() as User - if (amount < 0 && toUser.balance < -amount) { - return { - status: 'error', - message: `Insufficient balance: ${ - toUser.username - } needed ${-amount} but only had ${toUser.balance} `, - } - } - - transaction.update(toDoc, { - balance: toUser.balance + amount, - totalDeposits: toUser.totalDeposits + amount, - }) - } - - const newTxnDoc = firestore.collection(`txns/`).doc() - - const txn = removeUndefinedProps({ - id: newTxnDoc.id, - createdTime: Date.now(), - - fromId, - fromType, - toId, - toType, - - amount, - category, - data: innerData, - token, - - description, - }) - - transaction.create(newTxnDoc, txn) - transaction.update(fromDoc, { - balance: fromUser.balance - amount, - totalDeposits: fromUser.totalDeposits - amount, - }) - - return { status: 'success', txn } + await runTxn(transaction, data) }) }) +export async function runTxn( + fbTransaction: admin.firestore.Transaction, + data: TxnData +) { + const { amount, fromId, toId, toType } = data + + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await fbTransaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + // TODO: Track payments received by charities, bank, contracts too. + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await fbTransaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + fbTransaction.update(toDoc, { + balance: toUser.balance + amount, + totalDeposits: toUser.totalDeposits + amount, + }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data } + fbTransaction.create(newTxnDoc, removeUndefinedProps(txn)) + fbTransaction.update(fromDoc, { + balance: fromUser.balance - amount, + totalDeposits: fromUser.totalDeposits - amount, + }) + + return { status: 'success', txn } +} + const firestore = admin.firestore() diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index b5a70a5e..69e8cfab 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -28,6 +28,7 @@ export function Tabs(props: { {tabs.map((tab, i) => ( { if (!tab.href) { diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx new file mode 100644 index 00000000..97f5951c --- /dev/null +++ b/web/components/manalink-card.tsx @@ -0,0 +1,69 @@ +import clsx from 'clsx' +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' + +export type ManalinkInfo = { + expiresTime: number | null + maxUses: number | null + uses: number + amount: number + message: string +} + +export function ManalinkCard(props: { + className?: string + info: ManalinkInfo + defaultMessage: string + isClaiming: boolean + onClaim?: () => void +}) { + const { className, defaultMessage, isClaiming, info, onClaim } = 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/user-page.tsx b/web/components/user-page.tsx index 04aaf67f..cd896c59 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,7 +1,10 @@ import clsx from 'clsx' import { uniq } from 'lodash' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' import { LinkIcon } from '@heroicons/react/solid' import { PencilIcon } from '@heroicons/react/outline' +import Confetti from 'react-confetti' import { follow, unfollow, User } from 'web/lib/firebase/users' import { CreatorContractsList } from './contract/contracts-list' @@ -16,7 +19,7 @@ import { Row } from './layout/row' import { genHash } from 'common/util/random' import { Tabs } from './layout/tabs' import { UserCommentsList } from './comments-list' -import { useEffect, useState } from 'react' +import { useWindowSize } from 'web/hooks/use-window-size' import { Comment, getUsersComments } from 'web/lib/firebase/comments' import { Contract } from 'common/contract' import { getContractFromId, listContracts } from 'web/lib/firebase/contracts' @@ -27,7 +30,6 @@ import { getUserBets } from 'web/lib/firebase/bets' import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' -import { useRouter } from 'next/router' export function UserLink(props: { name: string @@ -57,6 +59,7 @@ export function UserPage(props: { defaultTabTitle?: string | undefined }) { const { user, currentUser, defaultTabTitle } = props + const router = useRouter() const isCurrentUser = user.id === currentUser?.id const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [usersComments, setUsersComments] = useState([] as Comment[]) @@ -67,7 +70,13 @@ export function UserPage(props: { const [commentsByContract, setCommentsByContract] = useState< Map | 'loading' >('loading') - const router = useRouter() + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + + useEffect(() => { + const claimedMana = router.query['claimed-mana'] === 'yes' + setShowConfetti(claimedMana) + }, [router]) useEffect(() => { if (!user) return @@ -117,7 +126,14 @@ export function UserPage(props: { description={user.bio ?? ''} url={`/${user.username}`} /> - + {showConfetti && ( + + )} {/* Banner image up top, with an circle avatar overlaid */}
{ .then((r) => r.data as { status: string }) .catch((e) => ({ status: 'error', message: e.message })) } + +export const claimManalink = cloudFunction< + string, + { status: 'error' | 'success'; message?: string } +>('claimManalink') diff --git a/web/lib/firebase/manalinks.ts b/web/lib/firebase/manalinks.ts new file mode 100644 index 00000000..67c7a00a --- /dev/null +++ b/web/lib/firebase/manalinks.ts @@ -0,0 +1,94 @@ +import { + collection, + getDoc, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { doc } from 'firebase/firestore' +import { Manalink } from '../../../common/manalink' +import { db } from './init' +import { customAlphabet } from 'nanoid' +import { listenForValues } from './utils' +import { useEffect, useState } from 'react' + +export async function createManalink(data: { + fromId: string + amount: number + expiresTime: number | null + maxUses: number | null + message: string +}) { + const { fromId, amount, expiresTime, maxUses, message } = data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return null + + const manalink: Manalink = { + slug, + fromId, + amount, + token: 'M$', + createdTime: Date.now(), + expiresTime, + maxUses, + claimedUserIds: [], + claims: [], + message, + } + + const ref = doc(db, 'manalinks', slug) + await setDoc(ref, manalink) + return slug +} + +const manalinkCol = collection(db, 'manalinks') + +// TODO: This required an index, make sure to also set up in prod +function listUserManalinks(fromId?: string) { + return query( + manalinkCol, + where('fromId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +export async function getManalink(slug: string) { + const docSnap = await getDoc(doc(db, 'manalinks', slug)) + return docSnap.data() as Manalink +} + +export function useManalink(slug: string) { + const [manalink, setManalink] = useState(null) + useEffect(() => { + if (slug) { + getManalink(slug).then(setManalink) + } + }, [slug]) + return manalink +} + +export function listenForUserManalinks( + fromId: string | undefined, + setLinks: (links: Manalink[]) => void +) { + return listenForValues(listUserManalinks(fromId), setLinks) +} + +export const useUserManalinks = (fromId: string) => { + const [links, setLinks] = useState([]) + + useEffect(() => { + return listenForUserManalinks(fromId, setLinks) + }, [fromId]) + + return links +} diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 58ba7bf6..c4c8aa93 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -1,7 +1,9 @@ -import { DonationTxn, TipTxn } from 'common/txn' +import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn' import { collection, orderBy, query, where } from 'firebase/firestore' import { db } from './init' import { getValues, listenForValues } from './utils' +import { useState, useEffect } from 'react' +import { orderBy as _orderBy } from 'lodash' const txnCollection = collection(db, 'txns') @@ -39,3 +41,29 @@ export function listenForTipTxns( ) { return listenForValues(getTipsQuery(contractId), setTxns) } + +// Find all manalink Txns that are from or to this user +export function useManalinkTxns(userId: string) { + const [fromTxns, setFromTxns] = useState([]) + const [toTxns, setToTxns] = useState([]) + + useEffect(() => { + // TODO: Need to instantiate these indexes too + const fromQuery = query( + txnCollection, + where('fromId', '==', userId), + where('category', '==', 'MANALINK'), + orderBy('createdTime', 'desc') + ) + const toQuery = query( + txnCollection, + where('toId', '==', userId), + where('category', '==', 'MANALINK'), + orderBy('createdTime', 'desc') + ) + listenForValues(fromQuery, setFromTxns) + listenForValues(toQuery, setToTxns) + }, [userId]) + + return _orderBy([...fromTxns, ...toTxns], ['createdTime'], ['desc']) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index bc743cf2..97e30aa5 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -128,7 +128,7 @@ export function listenForLogin(onUser: (user: User | null) => void) { export async function firebaseLogin() { const provider = new GoogleAuthProvider() - signInWithPopup(auth, provider) + return signInWithPopup(auth, provider) } export async function firebaseLogout() { diff --git a/web/package.json b/web/package.json index 4fc83ad0..5b05a292 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "gridjs": "5.0.2", "gridjs-react": "5.0.2", "lodash": "4.17.21", + "nanoid": "^3.3.4", "next": "12.1.2", "node-fetch": "3.2.4", "react": "17.0.2", diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx new file mode 100644 index 00000000..0b0186ed --- /dev/null +++ b/web/pages/link/[slug].tsx @@ -0,0 +1,68 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { claimManalink } from 'web/lib/firebase/fn-call' +import { useManalink } from 'web/lib/firebase/manalinks' +import { ManalinkCard } from 'web/components/manalink-card' +import { useUser } from 'web/hooks/use-user' +import { useUserById } from 'web/hooks/use-users' +import { firebaseLogin } from 'web/lib/firebase/users' + +export default function ClaimPage() { + const user = useUser() + const router = useRouter() + const { slug } = router.query as { slug: string } + const manalink = useManalink(slug) + const [claiming, setClaiming] = useState(false) + const [error, setError] = useState(undefined) + + const fromUser = useUserById(manalink?.fromId) + if (!manalink) { + return <> + } + + const info = { ...manalink, uses: manalink.claims.length } + return ( + <> + +
+ + <ManalinkCard + defaultMessage={fromUser?.name || 'Enjoy this mana!'} + info={info} + isClaiming={claiming} + onClaim={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + } + const result = await claimManalink(manalink.slug) + if (result.data.status == 'error') { + throw new Error(result.data.message) + } + router.push('/account?claimed-mana=yes') + } catch (e) { + console.log(e) + const message = + e && e instanceof Object ? e.toString() : 'An error occurred.' + setError(message) + } + setClaiming(false) + }} + /> + {error && ( + <section className="my-5 text-red-500"> + <p>Failed to claim manalink.</p> + <p>{error}</p> + </section> + )} + </div> + </> + ) +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx new file mode 100644 index 00000000..08c99460 --- /dev/null +++ b/web/pages/links.tsx @@ -0,0 +1,386 @@ +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 { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { createManalink, 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 dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +dayjs.extend(customParseFormat) + +function getLinkUrl(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 ?? '') + // const manalinkTxns = useManalinkTxns(user?.id ?? '') + const [highlightedSlug, setHighlightedSlug] = useState('') + const unclaimedLinks = links.filter( + (l) => + (l.maxUses == null || l.claimedUserIds.length < l.maxUses) && + (l.expiresTime == null || l.expiresTime > Date.now()) + ) + + if (user == null) { + return null + } + + return ( + <Page> + <SEO + title="Manalinks" + description="Send mana to anyone via link!" + url="/send" + /> + <Col className="w-full px-8"> + <Title text="Manalinks" /> + <Tabs + className={'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} />, + // }, + ]} + /> + </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 ( + <> + <h1 className="mb-4 text-xl font-semibold text-gray-900"> + Claimed links + </h1> + {txns.map((txn) => ( + <ClaimDescription txn={txn} key={txn.id} /> + ))} + </> + ) +} + +export function ClaimDescription(props: { txn: ManalinkTxn }) { + const { txn } = props + const from = useUserById(txn.fromId) + const to = useUserById(txn.toId) + + if (!from || !to) { + return <>Loading...</> + } + + return ( + <div className="mb-2 flow-root pr-2 md:pr-0"> + <div className="relative flex items-center space-x-3"> + <Avatar username={to.name} avatarUrl={to.avatarUrl} size="sm" /> + <div className="min-w-0 flex-1"> + <p className="mt-0.5 text-sm text-gray-500"> + <UserLink + className="text-gray-500" + username={to.username} + name={to.name} + />{' '} + claimed {formatMoney(txn.amount)} from{' '} + <UserLink + className="text-gray-500" + username={from.username} + name={from.name} + /> + <RelativeTimestamp time={txn.createdTime} /> + </p> + </div> + </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', + highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white' + ) + 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">{getLinkUrl(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> + ) : ( + <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> + ) +} diff --git a/yarn.lock b/yarn.lock index 3b65ca2e..83742947 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8026,6 +8026,11 @@ nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"