feat: achievement badge
Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
a15f23a5c7
commit
aceb4c964f
|
|
@ -156,7 +156,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
[state, controls, src, variant],
|
||||
)}
|
||||
>
|
||||
{variant === "preview" && <FloatMutedButton />}
|
||||
{variant === "preview" && state.hasAudio && <FloatMutedButton />}
|
||||
{isPlayer && <ControlBar />}
|
||||
</VideoPlayerContext.Provider>
|
||||
</Focusable>
|
||||
|
|
|
|||
|
|
@ -97,3 +97,33 @@ export const DrawerModalLayout: FC<PropsWithChildren> = ({ children }) => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ScaleModal: ModalTemplateType = (props) => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
return (
|
||||
<div className={"container center h-full"} onPointerDown={dismiss} onClick={stopPropagation}>
|
||||
<m.div
|
||||
onPointerDown={stopPropagation}
|
||||
transition={{
|
||||
type: "spring",
|
||||
mass: 0.4,
|
||||
tension: 100,
|
||||
friction: 1,
|
||||
}}
|
||||
initial={{ transform: "scale(0)", opacity: 0 }}
|
||||
animate={{ transform: "scale(1)", opacity: 1 }}
|
||||
exit={{ transform: "scale(0.6)", opacity: 0 }}
|
||||
className="relative"
|
||||
>
|
||||
{props.children}
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ScaleModal.class = (className: string) => {
|
||||
return (props: ComponentType) => (
|
||||
<ScaleModal {...props} className={cn(props.className, className)} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { RiNftFill } from "@follow/components/icons/nft.jsx"
|
||||
import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { styledButtonVariant } from "@follow/components/ui/button/variants.js"
|
||||
import { Input } from "@follow/components/ui/input/Input.js"
|
||||
import { LoadingCircle, LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
|
||||
import { useOnce } from "@follow/hooks"
|
||||
import type { ExtractBizResponse } from "@follow/models/types"
|
||||
import { Chain } from "@follow/utils/chain"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
|
|
@ -17,6 +19,8 @@ import { useTranslation } from "react-i18next"
|
|||
|
||||
import { useServerConfigs } from "~/atoms/server-configs"
|
||||
import { LazyDotLottie } from "~/components/common/LazyDotLottie"
|
||||
import { VideoPlayer } from "~/components/ui/media/VideoPlayer"
|
||||
import { ScaleModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
|
|
@ -36,33 +40,39 @@ enum AchievementsActionIdMap {
|
|||
FEED_BOOSTER = 8,
|
||||
}
|
||||
|
||||
const achievementActionIdCopyMap: Record<
|
||||
const achievementActionIdMetaMap: Record<
|
||||
AchievementsActionIdMap,
|
||||
{ title: I18nKeys; description: I18nKeys }
|
||||
{ title: I18nKeys; description: I18nKeys; video?: string }
|
||||
> = {
|
||||
[AchievementsActionIdMap.FIRST_CLAIM_FEED]: {
|
||||
title: "achievement.first_claim_feed",
|
||||
description: "achievement.first_claim_feed_description",
|
||||
video: "https://assets.follow.is/FeedOwnerBadge.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.FIRST_CREATE_LIST]: {
|
||||
title: "achievement.first_create_list",
|
||||
description: "achievement.first_create_list_description",
|
||||
video: "https://assets.follow.is/ListBadge.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.LIST_SUBSCRIBE_50]: {
|
||||
title: "achievement.list_subscribe_50",
|
||||
description: "achievement.list_subscribe_50_description",
|
||||
video: "https://assets.follow.is/50Subs.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.LIST_SUBSCRIBE_100]: {
|
||||
title: "achievement.list_subscribe_100",
|
||||
description: "achievement.list_subscribe_100_description",
|
||||
video: "https://assets.follow.is/100Subs.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.LIST_SUBSCRIBE_500]: {
|
||||
title: "achievement.list_subscribe_500",
|
||||
description: "achievement.list_subscribe_500_description",
|
||||
video: "https://assets.follow.is/500Subs.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.PRODUCT_HUNT_VOTE]: {
|
||||
title: "achievement.product_hunt_vote",
|
||||
description: "achievement.product_hunt_vote_description",
|
||||
video: "https://assets.follow.is/ProductHunt.webm",
|
||||
},
|
||||
// [AchievementsActionIdMap.FOLLOW_SPECIAL_FEED]: {
|
||||
// title: "achievement.follow_special_feed",
|
||||
|
|
@ -71,6 +81,7 @@ const achievementActionIdCopyMap: Record<
|
|||
[AchievementsActionIdMap.ALPHA_TESTER]: {
|
||||
title: "achievement.alpha_tester",
|
||||
description: "achievement.alpha_tester_description",
|
||||
video: "https://assets.follow.is/AlphaBadge.webm",
|
||||
},
|
||||
[AchievementsActionIdMap.FEED_BOOSTER]: {
|
||||
title: "achievement.feed_booster",
|
||||
|
|
@ -78,10 +89,25 @@ const achievementActionIdCopyMap: Record<
|
|||
},
|
||||
}
|
||||
|
||||
const prefetchVideos = () => {
|
||||
Object.values(achievementActionIdMetaMap).forEach(({ video }) => {
|
||||
if (video) {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "prefetch"
|
||||
link.href = video
|
||||
document.head.append(link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Achievement = ExtractBizResponse<typeof apiClient.achievement.$get>["data"]
|
||||
export const AchievementModalContent: FC = () => {
|
||||
const jotaiStore = useStore()
|
||||
|
||||
useOnce(() => {
|
||||
prefetchVideos()
|
||||
})
|
||||
|
||||
const defaultAchievements = useState(buildDefaultAchievements)[0]
|
||||
|
||||
const {
|
||||
|
|
@ -140,6 +166,33 @@ export const AchievementModalContent: FC = () => {
|
|||
return 0
|
||||
})
|
||||
}, [achievements])
|
||||
const { present } = useModalStack()
|
||||
|
||||
const presentBadgeVideo = (type: keyof typeof achievementActionIdMetaMap) => {
|
||||
const copy = achievementActionIdMetaMap[type]
|
||||
if (!copy) return
|
||||
const { video } = copy
|
||||
if (!video) return
|
||||
present({
|
||||
title: "Congratulations!",
|
||||
CustomModalComponent: ScaleModal,
|
||||
content: ({ dismiss }) => (
|
||||
<VideoPlayer
|
||||
variant="preview"
|
||||
onClick={dismiss}
|
||||
aria-label="Close video"
|
||||
src={video}
|
||||
autoPlay
|
||||
muted
|
||||
height={500}
|
||||
width={500}
|
||||
loop
|
||||
/>
|
||||
),
|
||||
|
||||
overlay: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full grow flex-col items-center">
|
||||
|
|
@ -166,13 +219,26 @@ export const AchievementModalContent: FC = () => {
|
|||
</div>
|
||||
) : (
|
||||
sortedAchievements?.map((achievement) => {
|
||||
const copy = achievementActionIdCopyMap[achievement.actionId]
|
||||
const copy = achievementActionIdMetaMap[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)}</div>
|
||||
<div className="flex items-center text-base font-bold">
|
||||
{t(copy.title)}
|
||||
|
||||
{copy.video && (
|
||||
<MotionButtonBase
|
||||
className="p-1 duration-200 hover:text-accent"
|
||||
onClick={() => {
|
||||
presentBadgeVideo(achievement.actionId)
|
||||
}}
|
||||
>
|
||||
<RiNftFill className="size-4" />
|
||||
</MotionButtonBase>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
{t(copy.description)}
|
||||
</div>
|
||||
|
|
@ -217,6 +283,9 @@ export const AchievementModalContent: FC = () => {
|
|||
)}
|
||||
{achievement.type === "completed" && (
|
||||
<MintButton
|
||||
onMinted={() => {
|
||||
presentBadgeVideo(achievement.actionId)
|
||||
}}
|
||||
achievementsDataAtom={achievementsDataAtom}
|
||||
achievement={achievement}
|
||||
/>
|
||||
|
|
@ -228,13 +297,15 @@ export const AchievementModalContent: FC = () => {
|
|||
</ul>
|
||||
</ScrollArea>
|
||||
|
||||
<p className="mt-4 pb-2 text-xs text-muted-foreground">{t("achievement.nft_coming_soon")}</p>
|
||||
<p className="mt-4 pb-2 text-xs text-muted-foreground">
|
||||
* {t("achievement.nft_coming_soon")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const buildDefaultAchievements = () => {
|
||||
return Object.keys(achievementActionIdCopyMap).map((key) => {
|
||||
return Object.keys(achievementActionIdMetaMap).map((key) => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
actionId: Number(key),
|
||||
|
|
@ -246,7 +317,8 @@ const buildDefaultAchievements = () => {
|
|||
const MintButton: FC<{
|
||||
achievementsDataAtom: PrimitiveAtom<Achievement | undefined>
|
||||
achievement: Achievement[number]
|
||||
}> = ({ achievementsDataAtom, achievement }) => {
|
||||
onMinted: () => void
|
||||
}> = ({ achievementsDataAtom, achievement, onMinted }) => {
|
||||
const { mutateAsync: mintAchievement, isPending: isMinting } = useMutation({
|
||||
mutationFn: async (actionId: number) => {
|
||||
return apiClient.achievement.$put({
|
||||
|
|
@ -287,6 +359,7 @@ const MintButton: FC<{
|
|||
if (shouldInvalidate) {
|
||||
queryClient.invalidateQueries({ queryKey: ["achievements"] })
|
||||
}
|
||||
onMinted()
|
||||
}}
|
||||
>
|
||||
{t("words.mint")}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import type { SVGProps } from "react"
|
||||
import * as React from "react"
|
||||
|
||||
export function RiNftFill(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 12a2 2 0 1 0 0-4a2 2 0 0 0 0 4m3-11l9.5 5.5v11L12 23l-9.5-5.5v-11zM4.5 7.653v8.694l2.372 1.373l8.073-5.92l4.555 2.734v-6.88L12 3.31z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue