chore: split achievement code

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-10-10 14:06:09 +08:00
parent e09dce467b
commit 2aa4fe118e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
3 changed files with 300 additions and 294 deletions

View File

@ -87,19 +87,21 @@ export const ProfileButton: FC<LoginProps> = memo((props) => {
</ActionButton>
</DropdownMenuTrigger>
<RootPortal>
<UserAvatar
style={{
left: x,
top: y,
}}
className={cn(
"pointer-events-none fixed -bottom-6 z-[999] p-0 duration-200 [&_*]:border-0",
dropdown ? "h-14 -translate-x-4" : "h-6",
)}
hideName
/>
</RootPortal>
{x !== 0 && y !== 0 && (
<RootPortal>
<UserAvatar
style={{
left: x,
top: y,
}}
className={cn(
"pointer-events-none fixed -bottom-6 z-[999] p-0 duration-200 [&_*]:border-0",
dropdown ? "h-14 -translate-x-4" : "h-6",
)}
hideName
/>
</RootPortal>
)}
<DropdownMenuContent
className="min-w-[180px] overflow-visible px-1 pt-4"
side="bottom"

View File

@ -0,0 +1,283 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useSingleton } from "foxact/use-singleton"
import { atom, useStore } from "jotai"
import { nanoid } from "nanoid"
import type { FC } from "react"
import { useEffect } from "react"
import { Trans } from "react-i18next"
import { Button } from "~/components/ui/button"
import { styledButtonVariant } from "~/components/ui/button/variants"
import { LoadingCircle, LoadingWithIcon } from "~/components/ui/loading"
import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { Chain } from "~/lib/chain"
import { cn } from "~/lib/utils"
import achievementAnimationUri from "~/lottie/achievement.lottie?url"
const absoluteachievementAnimationUri = new URL(achievementAnimationUri, import.meta.url).href
enum AchievementsActionIdMap {
FIRST_CLAIM_FEED = 0,
}
const achievementActionIdCopyMap: Record<
AchievementsActionIdMap,
{ title: I18nKeys; description: I18nKeys }
> = {
[AchievementsActionIdMap.FIRST_CLAIM_FEED]: {
title: "achievement.first_claim_feed",
description: "achievement.first_claim_feed_description",
},
}
export const AchievementModalContent: FC = () => {
const jotaiStore = useStore()
const queryClient = useQueryClient()
const defaultAchievements = useSingleton(buildDefaultAchievements)
const _achievementType = apiClient.achievement.$get({
query: {
type: "all",
},
})
type Achievement = Awaited<typeof _achievementType>["data"]
const {
data: achievements,
isLoading,
refetch,
} = useQuery({
queryKey: ["achievements"],
meta: {
persist: true,
},
queryFn: async () => {
const res = await apiClient.achievement.$get({
query: {
type: "all",
},
})
jotaiStore.set(achievementsDataAtom.current, res.data)
return res.data
},
initialData: defaultAchievements.current as Achievement,
})
useEffect(() => {
if (!achievements) return
let shouldPolling = false
const pollingChain = new Chain()
for (const achievement of achievements) {
if (achievement.type === "checking") {
shouldPolling = true
break
}
}
if (shouldPolling) {
pollingChain.wait(2000).next(() => {
refetch()
})
}
return () => {
pollingChain.abort()
}
}, [achievements, refetch])
const { mutateAsync: mintAchievement, isPending: isMinting } = useMutation({
mutationFn: async (actionId: number) => {
return apiClient.achievement.$put({
json: {
actionId,
},
})
},
})
const achievementsDataAtom = useSingleton(() => atom<typeof achievements>())
const { mutateAsync: checkAchievement, isPending: checkPending } = useMutation({
mutationFn: async (actionId: number) => {
return apiClient.achievement.check.$post({
json: {
actionId,
},
})
},
onSuccess: () => {
refetch()
},
})
const t = useI18n()
return (
<div className="relative flex w-full grow flex-col items-center">
<DotLottieReact
className="mt-12 size-[100px]"
autoplay
speed={2}
src={absoluteachievementAnimationUri}
loop={false}
/>
<div className="mt-4 text-xl font-bold">{t("words.achievement")}</div>
<small className="center mt-1 gap-1 text-theme-vibrancyFg">
<Trans
i18nKey={"achievement.mint_more_power"}
components={{
power: (
<span className="center gap-0.5 font-semibold">
<span>{t("words.power")}</span>
<i className="i-mgc-power text-accent" />
</span>
),
}}
/>
</small>
<ul className="mt-10 flex w-full grow flex-col gap-2">
{isLoading ? (
<div className="center pointer-events-none grow -translate-y-16">
<LoadingWithIcon icon={<i className="i-mgc-trophy-cute-re" />} size="large" />
</div>
) : (
achievements?.map((achievement) => {
const copy = achievementActionIdCopyMap[achievement.actionId]
if (!copy) return null
return (
<li key={achievement.id} className="flex items-center justify-between">
<div>
<div className="text-base font-bold">
{t(copy.title)}
{achievement.power && (
<span className="ml-2 inline-flex items-center gap-0.5 text-xs font-normal">
<span className="font-medium opacity-80">{achievement.power}</span>
<i className="i-mgc-power text-sm text-accent" />
</span>
)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
{t(copy.description)}
</div>
</div>
{achievement.type === "checking" && (
<div
className={styledButtonVariant({
variant: "outline",
className: "relative border-0 pointer-events-none",
})}
>
<LoadingCircle size="small" className="center absolute inset-0" />
<span className="select-none opacity-0">{t("words.mint")}</span>
</div>
)}
{achievement.type === "incomplete" && (
<button
type="button"
className={styledButtonVariant({
variant: "ghost",
className: "relative hover:bg-transparent group cursor-pointer",
})}
onClick={() => {
checkAchievement(achievement.actionId)
}}
>
<div className="center absolute z-[1] opacity-0 duration-200 group-hover:opacity-100">
<i
className={cn(
"i-mgc-refresh-2-cute-re size-5 text-accent",
checkPending && "animate-spin",
)}
/>
</div>
<div className="duration-200 group-hover:opacity-30">
<span className="center relative ml-2 inline-flex w-24 -translate-y-1 flex-col *:!m-0">
<small className="shrink-0 text-xs leading-tight text-muted-foreground">
{achievement.progress} / {achievement.progressMax}
</small>
<span className="relative h-1 w-full overflow-hidden rounded-full bg-accent/10">
<span
className="absolute -left-3 top-0 inline-block h-1 rounded-full bg-accent"
style={{
width: `calc(${Math.min(
(achievement.progress / achievement.progressMax) * 100,
100,
)}% + 0.75rem)`,
}}
/>
</span>
</span>
</div>
</button>
)}
{achievement.type === "received" && (
<div
className={styledButtonVariant({
variant: "outline",
className:
"relative !bg-green-100/50 gap-1 border-green-200 text-green-800 dark:text-foreground dark:!bg-green-100/5 dark:border-green-200/20",
})}
>
<i className="i-mgc-check-filled" />
{t("achievement.all_done")}
</div>
)}
{achievement.type === "completed" && (
<Button
isLoading={isMinting}
variant="primary"
onClick={async () => {
const res = await mintAchievement(achievement.actionId)
const currentData = jotaiStore.get(achievementsDataAtom.current)
if (!currentData) return
let shouldInvalidate = false
const newData = currentData.map((item) => {
if (item.id === achievement.id && res.data.result) {
shouldInvalidate = true
return {
...item,
done: true,
doneAt: new Date().toISOString(),
type: "received",
} as const
}
return item
})
jotaiStore.set(achievementsDataAtom.current, newData)
if (shouldInvalidate) {
queryClient.invalidateQueries({ queryKey: ["achievements"] })
}
}}
>
{t("words.mint")}
</Button>
)}
</li>
)
})
)}
</ul>
</div>
)
}
const buildDefaultAchievements = () => {
return [
{
id: nanoid(),
actionId: AchievementsActionIdMap.FIRST_CLAIM_FEED,
type: "checking",
progress: 0,
progressMax: 0,
},
]
}

View File

@ -1,38 +1,9 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useSingleton } from "foxact/use-singleton"
import { atom, useStore } from "jotai"
import { nanoid } from "nanoid"
import type { FC } from "react"
import { useCallback, useEffect } from "react"
import { Trans } from "react-i18next"
import { useCallback } from "react"
import { Button } from "~/components/ui/button"
import { styledButtonVariant } from "~/components/ui/button/variants"
import { LoadingCircle, LoadingWithIcon } from "~/components/ui/loading"
import { useModalStack } from "~/components/ui/modal"
import { SlideUpModal } from "~/components/ui/modal/stacked/custom-modal"
import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { Chain } from "~/lib/chain"
import { cn } from "~/lib/utils"
import achievementAnimationUri from "~/lottie/achievement.lottie?url"
const absoluteachievementAnimationUri = new URL(achievementAnimationUri, import.meta.url).href
enum AchievementsActionIdMap {
FIRST_CLAIM_FEED = 0,
}
const achievementActionIdCopyMap: Record<
AchievementsActionIdMap,
{ title: I18nKeys; description: I18nKeys }
> = {
[AchievementsActionIdMap.FIRST_CLAIM_FEED]: {
title: "achievement.first_claim_feed",
description: "achievement.first_claim_feed_description",
},
}
import { AchievementModalContent } from "./AchievementModalContent"
export const useAchievementModal = () => {
const { present } = useModalStack()
@ -47,253 +18,3 @@ export const useAchievementModal = () => {
})
}, [present])
}
const buildDefaultAchievements = () => {
return [
{
id: nanoid(),
actionId: AchievementsActionIdMap.FIRST_CLAIM_FEED,
type: "checking",
progress: 0,
progressMax: 0,
},
]
}
export const AchievementModalContent: FC = () => {
const jotaiStore = useStore()
const queryClient = useQueryClient()
const defaultAchievements = useSingleton(buildDefaultAchievements)
const _achievementType = apiClient.achievement.$get({
query: {
type: "all",
},
})
type Achievement = Awaited<typeof _achievementType>["data"]
const {
data: achievements,
isLoading,
refetch,
} = useQuery({
queryKey: ["achievements"],
meta: {
persist: true,
},
queryFn: async () => {
const res = await apiClient.achievement.$get({
query: {
type: "all",
},
})
jotaiStore.set(achievementsDataAtom.current, res.data)
return res.data
},
initialData: defaultAchievements.current as Achievement,
})
useEffect(() => {
if (!achievements) return
let shouldPolling = false
const pollingChain = new Chain()
for (const achievement of achievements) {
if (achievement.type === "checking") {
shouldPolling = true
break
}
}
if (shouldPolling) {
pollingChain.wait(2000).next(() => {
refetch()
})
}
return () => {
pollingChain.abort()
}
}, [achievements, refetch])
const { mutateAsync: mintAchievement, isPending: isMinting } = useMutation({
mutationFn: async (actionId: number) => {
return apiClient.achievement.$put({
json: {
actionId,
},
})
},
})
const achievementsDataAtom = useSingleton(() => atom<typeof achievements>())
const { mutateAsync: checkAchievement, isPending: checkPending } = useMutation({
mutationFn: async (actionId: number) => {
return apiClient.achievement.check.$post({
json: {
actionId,
},
})
},
onSuccess: () => {
refetch()
},
})
const t = useI18n()
return (
<div className="relative flex w-full grow flex-col items-center">
<DotLottieReact
className="mt-12 size-[100px]"
autoplay
speed={2}
src={absoluteachievementAnimationUri}
loop={false}
/>
<div className="mt-4 text-xl font-bold">{t("words.achievement")}</div>
<small className="center mt-1 gap-1 text-theme-vibrancyFg">
<Trans
i18nKey={"achievement.mint_more_power"}
components={{
power: (
<span className="center gap-0.5 font-semibold">
<span>{t("words.power")}</span>
<i className="i-mgc-power text-accent" />
</span>
),
}}
/>
</small>
<ul className="mt-10 flex w-full grow flex-col gap-2">
{isLoading ? (
<div className="center pointer-events-none grow -translate-y-16">
<LoadingWithIcon icon={<i className="i-mgc-trophy-cute-re" />} size="large" />
</div>
) : (
achievements?.map((achievement) => {
const copy = achievementActionIdCopyMap[achievement.actionId]
if (!copy) return null
return (
<li key={achievement.id} className="flex items-center justify-between">
<div>
<div className="text-base font-bold">
{t(copy.title)}
{achievement.power && (
<span className="ml-2 inline-flex items-center gap-0.5 text-xs font-normal">
<span className="font-medium opacity-80">{achievement.power}</span>
<i className="i-mgc-power text-sm text-accent" />
</span>
)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
{t(copy.description)}
</div>
</div>
{achievement.type === "checking" && (
<div
className={styledButtonVariant({
variant: "outline",
className: "relative border-0 pointer-events-none",
})}
>
<LoadingCircle size="small" className="center absolute inset-0" />
<span className="select-none opacity-0">{t("words.mint")}</span>
</div>
)}
{achievement.type === "incomplete" && (
<button
type="button"
className={styledButtonVariant({
variant: "ghost",
className: "relative hover:bg-transparent group cursor-pointer",
})}
onClick={() => {
checkAchievement(achievement.actionId)
}}
>
<div className="center absolute z-[1] opacity-0 duration-200 group-hover:opacity-100">
<i
className={cn(
"i-mgc-refresh-2-cute-re size-5 text-accent",
checkPending && "animate-spin",
)}
/>
</div>
<div className="duration-200 group-hover:opacity-30">
<span className="center relative ml-2 inline-flex w-24 -translate-y-1 flex-col *:!m-0">
<small className="shrink-0 text-xs leading-tight text-muted-foreground">
{achievement.progress} / {achievement.progressMax}
</small>
<span className="relative h-1 w-full overflow-hidden rounded-full bg-accent/10">
<span
className="absolute -left-3 top-0 inline-block h-1 rounded-full bg-accent"
style={{
width: `calc(${Math.min(
(achievement.progress / achievement.progressMax) * 100,
100,
)}% + 0.75rem)`,
}}
/>
</span>
</span>
</div>
</button>
)}
{achievement.type === "received" && (
<div
className={styledButtonVariant({
variant: "outline",
className:
"relative !bg-green-100/50 gap-1 border-green-200 text-green-800 dark:text-foreground dark:!bg-green-100/5 dark:border-green-200/20",
})}
>
<i className="i-mgc-check-filled" />
{t("achievement.all_done")}
</div>
)}
{achievement.type === "completed" && (
<Button
isLoading={isMinting}
variant="primary"
onClick={async () => {
const res = await mintAchievement(achievement.actionId)
const currentData = jotaiStore.get(achievementsDataAtom.current)
if (!currentData) return
let shouldInvalidate = false
const newData = currentData.map((item) => {
if (item.id === achievement.id && res.data.result) {
shouldInvalidate = true
return {
...item,
done: true,
doneAt: new Date().toISOString(),
type: "received",
} as const
}
return item
})
jotaiStore.set(achievementsDataAtom.current, newData)
if (shouldInvalidate) {
queryClient.invalidateQueries({ queryKey: ["achievements"] })
}
}}
>
{t("words.mint")}
</Button>
)}
</li>
)
})
)}
</ul>
</div>
)
}