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:
parent
4eedf65b21
commit
6cc2d8af58
35
common/manalink.ts
Normal file
35
common/manalink.ts
Normal 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
|
||||
}
|
|
@ -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<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -16,6 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
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
|
||||
|
|
|
@ -323,6 +323,20 @@
|
|||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "manalinks",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "fromId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
102
functions/src/claim-manalink.ts
Normal file
102
functions/src/claim-manalink.ts
Normal 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()
|
|
@ -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'
|
||||
|
|
|
@ -5,23 +5,15 @@ import { User } from '../../common/user'
|
|||
import { Txn } from '../../common/txn'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
|
||||
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
|
||||
|
||||
export const transact = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, 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()
|
||||
|
|
|
@ -28,6 +28,7 @@ export function Tabs(props: {
|
|||
{tabs.map((tab, i) => (
|
||||
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
|
||||
<a
|
||||
id={`tab-${i}`}
|
||||
key={tab.title}
|
||||
onClick={(e) => {
|
||||
if (!tab.href) {
|
||||
|
|
69
web/components/manalink-card.tsx
Normal file
69
web/components/manalink-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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<Comment[]>([] as Comment[])
|
||||
|
@ -67,7 +70,13 @@ export function UserPage(props: {
|
|||
const [commentsByContract, setCommentsByContract] = useState<
|
||||
Map<Contract, Comment[]> | '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 && (
|
||||
<Confetti
|
||||
width={width ? width : 500}
|
||||
height={height ? height : 500}
|
||||
recycle={false}
|
||||
numberOfPieces={300}
|
||||
/>
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
|
|
|
@ -68,3 +68,8 @@ export const addLiquidity = (data: { amount: number; contractId: string }) => {
|
|||
.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')
|
||||
|
|
94
web/lib/firebase/manalinks.ts
Normal file
94
web/lib/firebase/manalinks.ts
Normal 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
|
||||
}
|
|
@ -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<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'])
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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",
|
||||
|
|
68
web/pages/link/[slug].tsx
Normal file
68
web/pages/link/[slug].tsx
Normal 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
386
web/pages/links.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user