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
|
// 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
|
||||||
|
|
|
@ -323,6 +323,20 @@
|
||||||
"order": "DESCENDING"
|
"order": "DESCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "manalinks",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "fromId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": [
|
"fieldOverrides": [
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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
|
// 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'
|
||||||
|
|
|
@ -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)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
if (!fromSnap.exists) {
|
||||||
return { status: 'error', message: 'User not found' }
|
return { status: 'error', message: 'User not found' }
|
||||||
}
|
}
|
||||||
const fromUser = fromSnap.data() as User
|
const fromUser = fromSnap.data() as User
|
||||||
|
|
||||||
if (amount > 0 && fromUser.balance < amount) {
|
if (fromUser.balance < amount) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Track payments received by charities, bank, contracts too.
|
||||||
if (toType === 'USER') {
|
if (toType === 'USER') {
|
||||||
const toDoc = firestore.doc(`users/${toId}`)
|
const toDoc = firestore.doc(`users/${toId}`)
|
||||||
const toSnap = await transaction.get(toDoc)
|
const toSnap = await fbTransaction.get(toDoc)
|
||||||
if (!toSnap.exists) {
|
if (!toSnap.exists) {
|
||||||
return { status: 'error', message: 'User not found' }
|
return { status: 'error', message: 'User not found' }
|
||||||
}
|
}
|
||||||
const toUser = toSnap.data() as User
|
const toUser = toSnap.data() as User
|
||||||
if (amount < 0 && toUser.balance < -amount) {
|
fbTransaction.update(toDoc, {
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
message: `Insufficient balance: ${
|
|
||||||
toUser.username
|
|
||||||
} needed ${-amount} but only had ${toUser.balance} `,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.update(toDoc, {
|
|
||||||
balance: toUser.balance + amount,
|
balance: toUser.balance + amount,
|
||||||
totalDeposits: toUser.totalDeposits + amount,
|
totalDeposits: toUser.totalDeposits + amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||||
|
const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
|
||||||
const txn = removeUndefinedProps({
|
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
|
||||||
id: newTxnDoc.id,
|
fbTransaction.update(fromDoc, {
|
||||||
createdTime: Date.now(),
|
|
||||||
|
|
||||||
fromId,
|
|
||||||
fromType,
|
|
||||||
toId,
|
|
||||||
toType,
|
|
||||||
|
|
||||||
amount,
|
|
||||||
category,
|
|
||||||
data: innerData,
|
|
||||||
token,
|
|
||||||
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
|
|
||||||
transaction.create(newTxnDoc, txn)
|
|
||||||
transaction.update(fromDoc, {
|
|
||||||
balance: fromUser.balance - amount,
|
balance: fromUser.balance - amount,
|
||||||
totalDeposits: fromUser.totalDeposits - amount,
|
totalDeposits: fromUser.totalDeposits - amount,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { status: 'success', txn }
|
return { status: 'success', txn }
|
||||||
})
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
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 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"
|
||||||
|
|
|
@ -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')
|
||||||
|
|
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 { 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'])
|
||||||
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
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"
|
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"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user