From 5cc7898e1ef0c13c9e5696daaa7a448fe6fa295c Mon Sep 17 00:00:00 2001 From: Songkeys Date: Thu, 27 Jun 2024 17:22:40 +0800 Subject: [PATCH] feat: wallet (#92) * feat: wallet * fix: circular import --- icons/mgc/power.svg | 3 + icons/mgc/power_mono.svg | 3 + package.json | 1 + pnpm-lock.yaml | 15 +++ .../src/components/ui/wallet/balance.tsx | 39 ++++++++ src/renderer/src/components/user-button.tsx | 8 ++ src/renderer/src/hono.ts | 68 +++++++++++++ .../src/hooks/biz/useEntryActions.tsx | 20 ++++ src/renderer/src/lib/shortcuts.ts | 5 +- .../src/modules/settings/action-card.tsx | 1 - .../modules/settings/tabs/wallet/index.tsx | 13 +++ .../my-wallet-section/claim-daily-reward.tsx | 64 ++++++++++++ .../my-wallet-section/create-wallet.tsx | 27 +++++ .../tabs/wallet/my-wallet-section/index.tsx | 34 +++++++ .../tabs/wallet/transaction-section/index.tsx | 94 ++++++++++++++++++ src/renderer/src/modules/wallet/hooks.ts | 22 +++++ src/renderer/src/modules/wallet/tip-modal.tsx | 44 +++++++++ src/renderer/src/pages/settings/wallet.tsx | 16 +++ src/renderer/src/queries/index.ts | 2 + src/renderer/src/queries/wallet.ts | 98 +++++++++++++++++++ 20 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 icons/mgc/power.svg create mode 100644 icons/mgc/power_mono.svg create mode 100644 src/renderer/src/components/ui/wallet/balance.tsx create mode 100644 src/renderer/src/modules/settings/tabs/wallet/index.tsx create mode 100644 src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/claim-daily-reward.tsx create mode 100644 src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/create-wallet.tsx create mode 100644 src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/index.tsx create mode 100644 src/renderer/src/modules/settings/tabs/wallet/transaction-section/index.tsx create mode 100644 src/renderer/src/modules/wallet/hooks.ts create mode 100644 src/renderer/src/modules/wallet/tip-modal.tsx create mode 100644 src/renderer/src/pages/settings/wallet.tsx create mode 100644 src/renderer/src/queries/wallet.ts diff --git a/icons/mgc/power.svg b/icons/mgc/power.svg new file mode 100644 index 000000000..298476672 --- /dev/null +++ b/icons/mgc/power.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/mgc/power_mono.svg b/icons/mgc/power_mono.svg new file mode 100644 index 000000000..700036315 --- /dev/null +++ b/icons/mgc/power_mono.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 68368d240..9115a05ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69b4aba89..8958b72c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/renderer/src/components/ui/wallet/balance.tsx b/src/renderer/src/components/ui/wallet/balance.tsx new file mode 100644 index 000000000..7713d0199 --- /dev/null +++ b/src/renderer/src/components/ui/wallet/balance.tsx @@ -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 ( + + + + {formatted} + {" "} + {withSuffix && $POWER} + + + +
+ {formattedFull} + {" "} + $POWER +
+
+
+ ) +} diff --git a/src/renderer/src/components/user-button.tsx b/src/renderer/src/components/user-button.tsx index f1a461263..b8f83f8fc 100644 --- a/src/renderer/src/components/user-button.tsx +++ b/src/renderer/src/components/user-button.tsx @@ -136,6 +136,14 @@ export const ProfileButton: FC = 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 + + + { nextFrame(settingModalPresent) }} > diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index 39583688f..af97e56b2 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -23,6 +23,74 @@ declare const routes: hono_hono_base.HonoBase { 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, diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 0e946d979..31bf4d66e 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -63,7 +63,10 @@ export const shortcuts = { copyLink: { name: "Copy Link", key: "Meta+Shift+C", - + }, + tip: { + name: "Tip", + key: "Meta+Shift+T", }, }, } as const diff --git a/src/renderer/src/modules/settings/action-card.tsx b/src/renderer/src/modules/settings/action-card.tsx index 70800f7ef..57e0ed059 100644 --- a/src/renderer/src/modules/settings/action-card.tsx +++ b/src/renderer/src/modules/settings/action-card.tsx @@ -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 { diff --git a/src/renderer/src/modules/settings/tabs/wallet/index.tsx b/src/renderer/src/modules/settings/tabs/wallet/index.tsx new file mode 100644 index 000000000..2aed349c8 --- /dev/null +++ b/src/renderer/src/modules/settings/tabs/wallet/index.tsx @@ -0,0 +1,13 @@ +import { SettingsTitle } from "../../title" +import { MyWalletSection } from "./my-wallet-section" +import { TransactionsSection } from "./transaction-section" + +export const SettingWallet = () => ( +
+ + + + + +
+) diff --git a/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/claim-daily-reward.tsx b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/claim-daily-reward.tsx new file mode 100644 index 000000000..7ccf43d85 --- /dev/null +++ b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/claim-daily-reward.tsx @@ -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 ( + + + mutation.mutate()} + disabled={!canClaim} + > + {canClaim ? + "Claim Daily Reward" : + `Reward in ${hour}:${minute}:${second}`} + + + + {canClaim ? + "Claim your daily reward (2.00 PowerTokens) now!" : + `You can claim your daily reward in ${hour}:${minute}:${second}.`} + + + ) +} diff --git a/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/create-wallet.tsx b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/create-wallet.tsx new file mode 100644 index 000000000..ef6ad893f --- /dev/null +++ b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/create-wallet.tsx @@ -0,0 +1,27 @@ +import { StyledButton } from "@renderer/components/ui/button" +import { useCreateWalletMutation } from "@renderer/queries/wallet" + +export const CreateWallet = () => { + const mutation = useCreateWalletMutation() + + return ( +
+ mutation.mutate()} + > + Create Wallet + + +

+ Create a free wallet to receive + {" "} + PowerTokens + , which can + be used to reward creators and also get rewarded for your content + contributions. +

+
+ ) +} diff --git a/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/index.tsx b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/index.tsx new file mode 100644 index 000000000..119457977 --- /dev/null +++ b/src/renderer/src/modules/settings/tabs/wallet/my-wallet-section/index.tsx @@ -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 ( +
+ + + {wallet.isPending ? ( + + ) : !myWallet ? + ( + + ) : + ( +
+
Balance
+ {myWallet.powerToken} + +
+ )} +
+ ) +} diff --git a/src/renderer/src/modules/settings/tabs/wallet/transaction-section/index.tsx b/src/renderer/src/modules/settings/tabs/wallet/transaction-section/index.tsx new file mode 100644 index 000000000..73465309d --- /dev/null +++ b/src/renderer/src/modules/settings/tabs/wallet/transaction-section/index.tsx @@ -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
Loading...
+ + if (transactions.data?.length === 0) return
No transactions
+ + return ( +
+ + +
+ + + + Type + Amount + From + To + Entry + Date + + + + + {transactions.data?.map( + (row) => ( + + {row.type} + {row.powerToken} + + + + {dayjs(row.createdAt).fromNow()} + + ), + )} + +
+
+
+ ) +} + +const TypeRenderer = ({ children }: { children: NonNullable["data"]>[number]["type"] }) => ( +
+ {children} +
+) + +const UserRenderer = ({ user }: { user?: NonNullable["data"]>[number]["fromUser" | "toUser"] }) => { + const me = useUser() + const isMe = user?.id === me?.id + + return ( +
+ + + {user?.name?.slice(0, 2)} + + +
+ {isMe ? You : user?.name || "NULL"} +
+
+ ) +} + +const EntryRenderer = ({ entry }: { entry: NonNullable["data"]>[number]["toEntry"] }) => ( +
+ {entry?.title ?? "-"} +
+) diff --git a/src/renderer/src/modules/wallet/hooks.ts b/src/renderer/src/modules/wallet/hooks.ts new file mode 100644 index 000000000..80ea3d59b --- /dev/null +++ b/src/renderer/src/modules/wallet/hooks.ts @@ -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]) +} diff --git a/src/renderer/src/modules/wallet/tip-modal.tsx b/src/renderer/src/modules/wallet/tip-modal.tsx new file mode 100644 index 000000000..b90afee5d --- /dev/null +++ b/src/renderer/src/modules/wallet/tip-modal.tsx @@ -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 ( +
+ +
+ ) + } + + if (transationsQuery.error) { + return
Failed to load transactions history.
+ } + + return ( +
+ {/* // TODO: */} +
+ tipMutation.mutate({ userId, entryId, amount: "0" })} + variant={tipMutation.isSuccess ? "plain" : "primary"} + > + {tipMutation.isSuccess && ( + + )} + Claim + +
+
+ ) +} diff --git a/src/renderer/src/pages/settings/wallet.tsx b/src/renderer/src/pages/settings/wallet.tsx new file mode 100644 index 000000000..7f666691e --- /dev/null +++ b/src/renderer/src/pages/settings/wallet.tsx @@ -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 +} diff --git a/src/renderer/src/queries/index.ts b/src/renderer/src/queries/index.ts index 43f40df9a..78de20b7a 100644 --- a/src/renderer/src/queries/index.ts +++ b/src/renderer/src/queries/index.ts @@ -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, } diff --git a/src/renderer/src/queries/wallet.ts b/src/renderer/src/queries/wallet.ts new file mode 100644 index 000000000..45a0b4029 --- /dev/null +++ b/src/renderer/src/queries/wallet.ts @@ -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[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[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.") + }, +})