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 c03518e906.

* 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 <marshall@pol.rs>
This commit is contained in:
Austin Chen 2022-06-23 03:07:52 -05:00 committed by GitHub
parent 4eedf65b21
commit 6cc2d8af58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 901 additions and 82 deletions

35
common/manalink.ts Normal file
View File

@ -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
}

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold // A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) // 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' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,6 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number amount: number
token: 'M$' // | 'USD' | MarketOutcome token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
// Any extra data // Any extra data
data?: { [key: string]: any } 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 DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink

View File

@ -323,6 +323,20 @@
"order": "DESCENDING" "order": "DESCENDING"
} }
] ]
},
{
"collectionGroup": "manalinks",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "fromId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
} }
], ],
"fieldOverrides": [ "fieldOverrides": [

View File

@ -104,6 +104,16 @@ service cloud.firestore {
allow read; 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} { match /users/{userId}/notifications/{notificationId} {
allow read; allow read;
allow update: if resource.data.userId == request.auth.uid allow update: if resource.data.userId == request.auth.uid

View File

@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Deploying ## Deploying
0. `$ firebase use prod` to switch to prod 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) (Future TODO: auto-deploy functions on Git push)
## Secrets management ## Secrets management

View File

@ -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()

View File

@ -4,6 +4,7 @@ admin.initializeApp()
// v1 // v1
// export * from './keep-awake' // export * from './keep-awake'
export * from './claim-manalink'
export * from './transact' export * from './transact'
export * from './resolve-market' export * from './resolve-market'
export * from './stripe' export * from './stripe'

View File

@ -5,23 +5,15 @@ import { User } from '../../common/user'
import { Txn } from '../../common/txn' import { Txn } from '../../common/txn'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
export const transact = functions export const transact = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => { .https.onCall(async (data: TxnData, context) => {
const userId = context?.auth?.uid const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' } if (!userId) return { status: 'error', message: 'Not authorized' }
const { const { amount, fromType, fromId } = data
amount,
fromType,
fromId,
toId,
toType,
category,
token,
data: innerData,
description,
} = data
if (fromType !== 'USER') if (fromType !== 'USER')
return { return {
@ -40,69 +32,53 @@ export const transact = functions
// Run as transaction to prevent race conditions. // Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
const fromDoc = firestore.doc(`users/${userId}`) await runTxn(transaction, data)
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 }
}) })
}) })
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() const firestore = admin.firestore()

View File

@ -28,6 +28,7 @@ export function Tabs(props: {
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
<a <a
id={`tab-${i}`}
key={tab.title} key={tab.title}
onClick={(e) => { onClick={(e) => {
if (!tab.href) { if (!tab.href) {

View File

@ -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 (
<div
className={clsx(
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'
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className="mb-6 block self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
width={200}
height={200}
/>
<Row className="justify-end rounded-b-xl bg-white p-4">
<Col>
<div className="mb-1 text-xl text-indigo-500">
{formatMoney(amount)}
</div>
<div>{message || defaultMessage}</div>
</Col>
<div className="ml-auto">
<button
className={clsx('btn', isClaiming ? 'loading disabled' : '')}
onClick={onClaim}
>
{isClaiming ? '' : 'Claim'}
</button>
</div>
</Row>
</div>
)
}

View File

@ -1,7 +1,10 @@
import clsx from 'clsx' import clsx from 'clsx'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid' import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline' import { PencilIcon } from '@heroicons/react/outline'
import Confetti from 'react-confetti'
import { follow, unfollow, User } from 'web/lib/firebase/users' import { follow, unfollow, User } from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list' import { CreatorContractsList } from './contract/contracts-list'
@ -16,7 +19,7 @@ import { Row } from './layout/row'
import { genHash } from 'common/util/random' import { genHash } from 'common/util/random'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
import { UserCommentsList } from './comments-list' 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 { Comment, getUsersComments } from 'web/lib/firebase/comments'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts' 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 { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button' import { FollowButton } from './follow-button'
import { useRouter } from 'next/router'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -57,6 +59,7 @@ export function UserPage(props: {
defaultTabTitle?: string | undefined defaultTabTitle?: string | undefined
}) { }) {
const { user, currentUser, defaultTabTitle } = props const { user, currentUser, defaultTabTitle } = props
const router = useRouter()
const isCurrentUser = user.id === currentUser?.id const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[]) const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[])
@ -67,7 +70,13 @@ export function UserPage(props: {
const [commentsByContract, setCommentsByContract] = useState< const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading' Map<Contract, Comment[]> | 'loading'
>('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(() => { useEffect(() => {
if (!user) return if (!user) return
@ -117,7 +126,14 @@ export function UserPage(props: {
description={user.bio ?? ''} description={user.bio ?? ''}
url={`/${user.username}`} url={`/${user.username}`}
/> />
{showConfetti && (
<Confetti
width={width ? width : 500}
height={height ? height : 500}
recycle={false}
numberOfPieces={300}
/>
)}
{/* Banner image up top, with an circle avatar overlaid */} {/* Banner image up top, with an circle avatar overlaid */}
<div <div
className="h-32 w-full bg-cover bg-center sm:h-40" className="h-32 w-full bg-cover bg-center sm:h-40"

View File

@ -68,3 +68,8 @@ export const addLiquidity = (data: { amount: number; contractId: string }) => {
.then((r) => r.data as { status: string }) .then((r) => r.data as { status: string })
.catch((e) => ({ status: 'error', message: e.message })) .catch((e) => ({ status: 'error', message: e.message }))
} }
export const claimManalink = cloudFunction<
string,
{ status: 'error' | 'success'; message?: string }
>('claimManalink')

View File

@ -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<Manalink | null>(null)
useEffect(() => {
if (slug) {
getManalink(slug).then(setManalink)
}
}, [slug])
return manalink
}
export function listenForUserManalinks(
fromId: string | undefined,
setLinks: (links: Manalink[]) => void
) {
return listenForValues<Manalink>(listUserManalinks(fromId), setLinks)
}
export const useUserManalinks = (fromId: string) => {
const [links, setLinks] = useState<Manalink[]>([])
useEffect(() => {
return listenForUserManalinks(fromId, setLinks)
}, [fromId])
return links
}

View File

@ -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 { collection, orderBy, query, where } from 'firebase/firestore'
import { db } from './init' import { db } from './init'
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { useState, useEffect } from 'react'
import { orderBy as _orderBy } from 'lodash'
const txnCollection = collection(db, 'txns') const txnCollection = collection(db, 'txns')
@ -39,3 +41,29 @@ export function listenForTipTxns(
) { ) {
return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns) return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns)
} }
// Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
const [toTxns, setToTxns] = useState<ManalinkTxn[]>([])
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'])
}

View File

@ -128,7 +128,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
export async function firebaseLogin() { export async function firebaseLogin() {
const provider = new GoogleAuthProvider() const provider = new GoogleAuthProvider()
signInWithPopup(auth, provider) return signInWithPopup(auth, provider)
} }
export async function firebaseLogout() { export async function firebaseLogout() {

View File

@ -32,6 +32,7 @@
"gridjs": "5.0.2", "gridjs": "5.0.2",
"gridjs-react": "5.0.2", "gridjs-react": "5.0.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"nanoid": "^3.3.4",
"next": "12.1.2", "next": "12.1.2",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"react": "17.0.2", "react": "17.0.2",

68
web/pages/link/[slug].tsx Normal file
View File

@ -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<string | undefined>(undefined)
const fromUser = useUserById(manalink?.fromId)
if (!manalink) {
return <></>
}
const info = { ...manalink, uses: manalink.claims.length }
return (
<>
<SEO
title="Send Mana"
description="Send mana to anyone via link!"
url="/send"
/>
<div className="mx-auto max-w-xl">
<Title text={`Claim ${manalink.amount} mana`} />
<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>
</>
)
}

386
web/pages/links.tsx Normal file
View File

@ -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&apos;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&apos;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>
)
}

View File

@ -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" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== 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: natural-compare@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"