feat: wallet (#92)

* feat: wallet

* fix: circular import
This commit is contained in:
Songkeys 2024-06-27 17:22:40 +08:00 committed by GitHub
parent 2ff6fc0082
commit 5cc7898e1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 575 additions and 2 deletions

3
icons/mgc/power.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6336 24.5001C19.2755 24.5001 24.6598 19.1158 24.6598 12.4739C24.6598 5.83206 19.2755 0.447754 12.6336 0.447754C5.99173 0.447754 0.607422 5.83206 0.607422 12.4739C0.607422 19.1158 5.99173 24.5001 12.6336 24.5001ZM10.3646 13.5043H7.87669C7.0796 13.5043 6.65288 12.5623 7.17622 11.9584L13.2711 5.03422C13.8347 4.39011 14.8975 4.79268 14.8975 5.64613V11.4431H17.3854C18.1906 11.4431 18.6173 12.3852 18.0859 12.989L11.991 19.9132C11.4274 20.5573 10.3646 20.1548 10.3646 19.3013V13.5043Z" fill="#FF5C00"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

3
icons/mgc/power_mono.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6336 24.5001C19.2755 24.5001 24.6598 19.1158 24.6598 12.4739C24.6598 5.83206 19.2755 0.447754 12.6336 0.447754C5.99173 0.447754 0.607422 5.83206 0.607422 12.4739C0.607422 19.1158 5.99173 24.5001 12.6336 24.5001ZM10.3646 13.5043H7.87669C7.0796 13.5043 6.65288 12.5623 7.17622 11.9584L13.2711 5.03422C13.8347 4.39011 14.8975 4.79268 14.8975 5.64613V11.4431H17.3854C18.1906 11.4431 18.6173 12.3852 18.0859 12.989L11.991 19.9132C11.4274 20.5573 10.3646 20.1548 10.3646 19.3013V13.5043Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

View File

@ -59,6 +59,7 @@
"clsx": "2.1.1",
"cmdk": "1.0.0",
"dayjs": "1.11.11",
"dnum": "^2.13.1",
"dotenv": "16.4.5",
"electron-updater": "^6.2.1",
"font-list": "1.5.1",

View File

@ -118,6 +118,9 @@ importers:
dayjs:
specifier: 1.11.11
version: 1.11.11
dnum:
specifier: ^2.13.1
version: 2.13.1
dotenv:
specifier: 16.4.5
version: 16.4.5
@ -3090,6 +3093,9 @@ packages:
os: [darwin]
hasBin: true
dnum@2.13.1:
resolution: {integrity: sha512-4oZ+BtlvNtKFJji1Fc5073LyJFvgioBQ0PNu/C+r1A8P09Yvka/aXYYD5bsUHMTUPEu01iv4bk+5nPQmt5AA8A==}
doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
@ -3569,6 +3575,9 @@ packages:
franc-min@6.2.0:
resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==}
from-exponential@1.1.1:
resolution: {integrity: sha512-VBE7f5OVnYwdgB3LHa+Qo29h8qVpxhVO9Trlc+AWm+/XNAgks1tAwMFHb33mjeiof77GglsJzeYF7OqXrROP/A==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@ -8958,6 +8967,10 @@ snapshots:
verror: 1.10.1
optional: true
dnum@2.13.1:
dependencies:
from-exponential: 1.1.1
doctrine@3.0.0:
dependencies:
esutils: 2.0.3
@ -9662,6 +9675,8 @@ snapshots:
dependencies:
trigram-utils: 2.0.1
from-exponential@1.1.1: {}
fs-constants@1.0.0: {}
fs-extra@10.1.0:

View File

@ -0,0 +1,39 @@
import { cn } from "@renderer/lib/utils"
import { format } from "dnum"
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"
export const Balance = ({
children,
className,
precision = 2,
withSuffix = false,
}: {
/** The token balance in wei. */
children: bigint
className?: string
precision?: number
withSuffix?: boolean
}) => {
const from = [BigInt(children), 18] as const
const formatted = format(from, { digits: precision, trailingZeros: true })
const formattedFull = format(from, { digits: 18, trailingZeros: true })
return (
<Tooltip>
<TooltipTrigger>
<span className={cn("font-mono tabular-nums", className)}>
{formatted}
{" "}
{withSuffix && <span>$POWER</span>}
</span>
</TooltipTrigger>
<TooltipContent>
<div className="font-mono text-sm">
<span className="font-bold tabular-nums">{formattedFull}</span>
{" "}
<span>$POWER</span>
</div>
</TooltipContent>
</Tooltip>
)
}

View File

@ -136,6 +136,14 @@ export const ProfileButton: FC<LoginProps> = memo((props) => {
// Here we need to delay one frame, so it's two raf,
// in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
// and the page freezes after modal is turned off.
nextFrame(() => settingModalPresent("wallet"))
}}
>
Wallet
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
nextFrame(settingModalPresent)
}}
>

View File

@ -23,6 +23,74 @@ declare const routes: hono_hono_base.HonoBase<hono_types.BlankEnv, {
status: 200;
};
};
"/wallets/transactions": {
$get: {
input: {
query: {
type?: "tip" | "mint" | "burn" | "withdraw" | undefined;
hash?: string | undefined;
fromUserId?: string | undefined;
toUserId?: string | undefined;
toEntryId?: string | undefined;
fromOrToUserId?: string | undefined;
createdAfter?: string | undefined;
};
};
output: {
code: 0;
data: {
type: "tip" | "mint" | "burn" | "withdraw";
createdAt: string;
powerToken: bigint;
hash: string;
fromUserId: string | null;
toUserId: string | null;
toEntryId: string | null;
comment: string | null;
fromUser?: {
name: string | null;
id: string;
email: string;
emailVerified: Date | null;
image: string | null;
handle: string | null;
} | undefined;
toUser?: {
name: string | null;
id: string;
email: string;
emailVerified: Date | null;
image: string | null;
handle: string | null;
} | undefined;
toEntry?: {
description: string | null;
title: string | null;
content: string | null;
id: string;
author: string | null;
url: string | null;
feedId: string;
guid: string;
authorUrl: string | null;
authorAvatar: string | null;
changedAt: string;
publishedAt: string;
images: string[] | null;
categories: string[] | null;
enclosures?: {
url: string;
length?: number | undefined;
type?: string | undefined;
title?: string | undefined;
}[] | null | undefined;
} | undefined;
}[];
};
outputFormat: "json";
status: 200;
};
};
"/wallets/transactions/claim_daily": {
$post: {
input: {};

View File

@ -1,7 +1,9 @@
import { apiClient } from "@renderer/lib/api-fetch"
import { tipcClient } from "@renderer/lib/client"
import { nextFrame } from "@renderer/lib/dom"
import { shortcuts } from "@renderer/lib/shortcuts"
import type { EntryModel } from "@renderer/models"
import { useTipModal } from "@renderer/modules/wallet/hooks"
import { entryActions } from "@renderer/store"
import { useMutation, useQuery } from "@tanstack/react-query"
import type { FetchError } from "ofetch"
@ -112,14 +114,32 @@ export const useEntryActions = ({
refetchOnWindowFocus: false,
})
// const canTip = Boolean(entry?.feeds.ownerUserId)
// comment out L119-122 to work ⬇️
const openTipModal = useTipModal({
userId: entry?.feeds.ownerUserId ?? undefined,
entryId: entry?.entries.id ?? undefined,
})
const collect = useCollect(entry)
const uncollect = useUnCollect(entry)
const read = useRead()
const unread = useUnread()
const items = useMemo(() => {
if (!entry || view === undefined) return []
const items = [
[
{
key: "tip",
shortcut: shortcuts.entry.tip.key,
name: `Tip`,
className: "i-mgc-power-mono",
// disabled: !canTip,
onClick: () => {
nextFrame(openTipModal)
},
},
{
key: "star",
shortcut: shortcuts.entry.toggleStarred.key,

View File

@ -63,7 +63,10 @@ export const shortcuts = {
copyLink: {
name: "Copy Link",
key: "Meta+Shift+C",
},
tip: {
name: "Tip",
key: "Meta+Shift+T",
},
},
} as const

View File

@ -1,4 +1,3 @@
/* eslint-disable tailwindcss/no-custom-classname */
import { Button } from "@renderer/components/ui/button"
import { Card, CardHeader } from "@renderer/components/ui/card"
import {

View File

@ -0,0 +1,13 @@
import { SettingsTitle } from "../../title"
import { MyWalletSection } from "./my-wallet-section"
import { TransactionsSection } from "./transaction-section"
export const SettingWallet = () => (
<div>
<SettingsTitle />
<MyWalletSection />
<TransactionsSection />
</div>
)

View File

@ -0,0 +1,64 @@
import { TooltipTrigger } from "@radix-ui/react-tooltip"
import { StyledButton } from "@renderer/components/ui/button"
import { Tooltip, TooltipContent } from "@renderer/components/ui/tooltip"
import {
useClaimWalletDailyRewardMutation,
useClaimWalletDailyRewardTtl,
} from "@renderer/queries/wallet"
import { useEffect, useState } from "react"
export const ClaimDailyReward = () => {
const mutation = useClaimWalletDailyRewardMutation()
const ttl = useClaimWalletDailyRewardTtl()
const [ttlState, setTtlState] = useState(ttl.data?.data.ttl ?? 0)
const hour = Math.floor(ttlState / 3600)
.toString()
.padStart(2, "0")
const minute = Math.floor((ttlState % 3600) / 60)
.toString()
.padStart(2, "0")
const second = (ttlState % 60).toString().padStart(2, "0")
const canClaim = ttlState <= 0
// refresh ttl every second
useEffect(() => {
const timer = setInterval(() => {
setTtlState((prev) => (prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(timer)
}, [])
// update ttl when ttl changes
useEffect(() => {
if (ttl.data?.data.ttl) {
setTtlState(ttl.data.data.ttl)
}
}, [ttl.data?.data.ttl])
return (
<Tooltip>
<TooltipTrigger>
<StyledButton
variant="primary"
isLoading={mutation.isPending}
onClick={() => mutation.mutate()}
disabled={!canClaim}
>
{canClaim ?
"Claim Daily Reward" :
`Reward in ${hour}:${minute}:${second}`}
</StyledButton>
</TooltipTrigger>
<TooltipContent>
{canClaim ?
"Claim your daily reward (2.00 PowerTokens) now!" :
`You can claim your daily reward in ${hour}:${minute}:${second}.`}
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,27 @@
import { StyledButton } from "@renderer/components/ui/button"
import { useCreateWalletMutation } from "@renderer/queries/wallet"
export const CreateWallet = () => {
const mutation = useCreateWalletMutation()
return (
<div>
<StyledButton
variant="primary"
isLoading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Create Wallet
</StyledButton>
<p className="my-2 text-theme-inactive">
Create a free wallet to receive
{" "}
<strong>PowerTokens</strong>
, which can
be used to reward creators and also get rewarded for your content
contributions.
</p>
</div>
)
}

View File

@ -0,0 +1,34 @@
import { useUser } from "@renderer/atoms/user"
import { LoadingCircle } from "@renderer/components/ui/loading"
import { Balance } from "@renderer/components/ui/wallet/balance"
import { useWallet } from "@renderer/queries/wallet"
import { SettingSectionTitle } from "../../../section"
import { ClaimDailyReward } from "./claim-daily-reward"
import { CreateWallet } from "./create-wallet"
export const MyWalletSection = () => {
const user = useUser()
const wallet = useWallet({ userId: user?.id })
const myWallet = wallet.data?.[0]
return (
<div>
<SettingSectionTitle title="My Wallet" />
{wallet.isPending ? (
<LoadingCircle size="large" />
) : !myWallet ?
(
<CreateWallet />
) :
(
<div className="flex flex-row items-center gap-x-5">
<div className="font-bold">Balance</div>
<Balance withSuffix>{myWallet.powerToken}</Balance>
<ClaimDailyReward />
</div>
)}
</div>
)
}

View File

@ -0,0 +1,94 @@
import { useUser } from "@renderer/atoms/user"
import { Avatar, AvatarFallback, AvatarImage } from "@renderer/components/ui/avatar"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@renderer/components/ui/table"
import { Balance } from "@renderer/components/ui/wallet/balance"
import dayjs from "@renderer/lib/dayjs"
import { cn } from "@renderer/lib/utils"
import { SettingSectionTitle } from "@renderer/modules/settings/section"
import { useWallet, useWalletTransactions } from "@renderer/queries/wallet"
export const TransactionsSection = () => {
const user = useUser()
const wallet = useWallet({ userId: user?.id })
const myWallet = wallet.data?.[0]
const transactions = useWalletTransactions({ fromOrToUserId: user?.id })
if (!myWallet) return
if (transactions.isLoading) return <div className="text-theme-disabled">Loading...</div>
if (transactions.data?.length === 0) return <div className="text-theme-disabled">No transactions</div>
return (
<div>
<SettingSectionTitle title="Transactions" />
<div className="overflow-x-scroll">
<Table>
<TableHeader>
<TableRow>
<TableHead size="sm" className="uppercase">Type</TableHead>
<TableHead size="sm" className="uppercase">Amount</TableHead>
<TableHead size="sm" className="uppercase">From</TableHead>
<TableHead size="sm" className="uppercase">To</TableHead>
<TableHead size="sm" className="uppercase">Entry</TableHead>
<TableHead size="sm" className="uppercase">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.data?.map(
(row) => (
<TableRow key={row.hash}>
<TableCell align="center" size="sm"><TypeRenderer>{row.type}</TypeRenderer></TableCell>
<TableCell align="center" size="sm"><Balance>{row.powerToken}</Balance></TableCell>
<TableCell align="center" size="sm"><UserRenderer user={row.fromUser} /></TableCell>
<TableCell align="center" size="sm"><UserRenderer user={row.toUser} /></TableCell>
<TableCell align="center" size="sm"><EntryRenderer entry={row.toEntry} /></TableCell>
<TableCell align="center" size="sm">{dayjs(row.createdAt).fromNow()}</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
</div>
</div>
)
}
const TypeRenderer = ({ children }: { children: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number]["type"] }) => (
<div className={cn("center rounded-full p-px text-xs uppercase", {
"bg-theme-primary text-white": children === "tip",
"bg-green-700 text-white": children === "mint",
"bg-red-700 text-white": children === "burn",
"bg-yellow-700 text-white": children === "withdraw",
})}
>
{children}
</div>
)
const UserRenderer = ({ user }: { user?: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number]["fromUser" | "toUser"] }) => {
const me = useUser()
const isMe = user?.id === me?.id
return (
<div className="center">
<Avatar className="aspect-square size-4">
<AvatarImage src={user?.image || undefined} />
<AvatarFallback>{user?.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
<div className="ml-1">
{isMe ? <span className="font-bold">You</span> : user?.name || "NULL"}
</div>
</div>
)
}
const EntryRenderer = ({ entry }: { entry: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number]["toEntry"] }) => (
<div className="center line-clamp-1 truncate">
{entry?.title ?? "-"}
</div>
)

View File

@ -0,0 +1,22 @@
import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks"
import { createElement, useCallback } from "react"
import { toast } from "sonner"
import { TipModalContent } from "./tip-modal"
export const useTipModal = ({ userId, entryId }: { userId?: string, entryId?: string }) => {
const { present } = useModalStack()
return useCallback(() => {
if (!userId) {
// this should not happen unless there is a bug in the code
toast.error("You must have a user to tip.")
return
}
present({
title: "Tip",
content: () => createElement(TipModalContent, { userId, entryId }),
})
}, [present, userId, entryId])
}

View File

@ -0,0 +1,44 @@
import { StyledButton } from "@renderer/components/ui/button"
import { LoadingCircle } from "@renderer/components/ui/loading"
import { useWalletTipMutation, useWalletTransactions } from "@renderer/queries/wallet"
import type { FC } from "react"
export const TipModalContent: FC<{
userId: string
entryId?: string
}> = ({ userId, entryId }) => {
const transationsQuery = useWalletTransactions({ toUserId: userId, toEntryId: entryId })
const tipMutation = useWalletTipMutation()
if (transationsQuery.isPending) {
return (
<div className="center h-32 w-[650px]">
<LoadingCircle size="large" />
</div>
)
}
if (transationsQuery.error) {
return <div>Failed to load transactions history.</div>
}
return (
<div className="w-[650px] max-w-full">
{/* // TODO: */}
<div className="mt-3 flex justify-end">
<StyledButton
disabled={tipMutation.isSuccess || tipMutation.isPending}
isLoading={tipMutation.isPending}
onClick={() => tipMutation.mutate({ userId, entryId, amount: "0" })}
variant={tipMutation.isSuccess ? "plain" : "primary"}
>
{tipMutation.isSuccess && (
<i className="i-mgc-check-circle-filled mr-2 bg-green-500" />
)}
Claim
</StyledButton>
</div>
</div>
)
}

View File

@ -0,0 +1,16 @@
import { SettingWallet } from "@renderer/modules/settings/tabs/wallet"
import { defineSettingPage } from "@renderer/modules/settings/utils"
const iconName = "i-mgc-power"
const name = "Wallet"
const priority = 1050
export const loader = defineSettingPage({
iconName,
name,
priority,
})
export function Component() {
return <SettingWallet />
}

View File

@ -5,6 +5,7 @@ import { discover } from "./discover"
import { entries } from "./entries"
import { feed } from "./feed"
import { subscription } from "./subscriptions"
import { wallet } from "./wallet"
export const Queries = {
subscription,
@ -14,4 +15,5 @@ export const Queries = {
auth,
ai,
discover,
wallet,
}

View File

@ -0,0 +1,98 @@
import { useBizQuery } from "@renderer/hooks"
import { apiClient, getFetchErrorMessage } from "@renderer/lib/api-fetch"
import { defineQuery } from "@renderer/lib/defineQuery"
import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner"
export const wallet = {
get: ({ userId }: { userId?: string } = {}) =>
defineQuery(
["wallet", userId].filter(Boolean),
async () => {
const res = await apiClient.wallets.$get({
query: { userId },
})
return res.data
},
{
rootKey: ["wallet"],
},
),
claimDailyRewardTtl: () =>
defineQuery(["wallet", "claimDailyRewardTtl"], async () =>
apiClient.wallets.transactions.claim_daily_ttl.$get()),
transactions: {
get: (query: Parameters<typeof apiClient.wallets.transactions.$get>[0]["query"] = {}) =>
defineQuery(
["wallet", "transactions", query].filter(Boolean),
async () => {
const res = await apiClient.wallets.transactions.$get({ query })
return res.data
},
{
rootKey: ["wallet", "transactions"],
},
),
},
}
export const useWallet = ({ userId }: { userId?: string } = {}) =>
useBizQuery(
wallet.get({ userId }),
{ enabled: !!userId },
)
export const useWalletTransactions = (query: Parameters<typeof wallet.transactions.get>[0] = {}) =>
useBizQuery(
wallet.transactions.get(query),
)
export const useCreateWalletMutation = () =>
useMutation({
mutationKey: ["createWallet"],
mutationFn: () => apiClient.wallets.$post(),
async onError(err) {
toast.error(await getFetchErrorMessage(err))
},
onSuccess() {
wallet.get().invalidate()
toast("🎉 Wallet created.")
},
})
export const useClaimWalletDailyRewardTtl = () =>
useBizQuery(
wallet.claimDailyRewardTtl(),
{ refetchInterval: 5000 },
)
export const useClaimWalletDailyRewardMutation = () => useMutation({
mutationKey: ["claimWalletDailyReward"],
mutationFn: () => apiClient.wallets.transactions.claim_daily.$post(),
async onError(err) {
toast.error(await getFetchErrorMessage(err))
},
onSuccess() {
wallet.get().invalidate()
wallet.claimDailyRewardTtl().invalidate()
toast("🎉 Daily reward claimed.")
},
})
export const useWalletTipMutation = () => useMutation({
mutationKey: ["walletTip"],
mutationFn: (data: { userId: string, entryId?: string, amount: string }) =>
apiClient.wallets.transactions.tip.$post({ json: data }),
async onError(err) {
toast.error(await getFetchErrorMessage(err))
},
onSuccess() {
wallet.get().invalidate()
wallet.transactions.get().invalidate()
toast("🎉 Tipped.")
},
})