feat: 优化整体项目ui
This commit is contained in:
235
src/features/game/components/shared/entry-notice-gate-modal.tsx
Normal file
235
src/features/game/components/shared/entry-notice-gate-modal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import checkIcon from '@/assets/system/right.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import {
|
||||
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
|
||||
} from '@/constants'
|
||||
import { getNoticeList } from '@/features/game/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
function getLastConfirmedAt(storageKey: string) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = Number(localStorage.getItem(storageKey))
|
||||
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function setLastConfirmedAt(storageKey: string, timestamp: number) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, String(timestamp))
|
||||
}
|
||||
|
||||
export function EntryNoticeGateModal() {
|
||||
const { t } = useTranslation()
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const currentUserId = useAuthStore((state) => state.currentUser?.id)
|
||||
const [hasEntered, setHasEntered] = useState(false)
|
||||
const [hasAgreed, setHasAgreed] = useState(false)
|
||||
const [shouldGateEntry, setShouldGateEntry] = useState(false)
|
||||
|
||||
const hasStoredLoginInfo =
|
||||
authStatus === 'authenticated' && Boolean(accessToken)
|
||||
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
|
||||
currentUserId ?? 'authenticated'
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!authIsHydrated) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasEntered(false)
|
||||
setHasAgreed(false)
|
||||
|
||||
if (!hasStoredLoginInfo) {
|
||||
setShouldGateEntry(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const lastConfirmedAt = getLastConfirmedAt(confirmedAtStorageKey)
|
||||
|
||||
setShouldGateEntry(
|
||||
!lastConfirmedAt ||
|
||||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||
)
|
||||
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
|
||||
|
||||
const noticeListQuery = useQuery({
|
||||
queryKey: ['game', 'entry-notice-list'],
|
||||
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
|
||||
enabled: authIsHydrated && shouldGateEntry,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const popoutNotices = useMemo(
|
||||
() =>
|
||||
(noticeListQuery.data?.list ?? []).filter(
|
||||
(notice) => notice.notice_type === 'popout',
|
||||
),
|
||||
[noticeListQuery.data],
|
||||
)
|
||||
|
||||
const shouldShowModal =
|
||||
authIsHydrated &&
|
||||
shouldGateEntry &&
|
||||
!hasEntered &&
|
||||
(noticeListQuery.isPending ||
|
||||
noticeListQuery.isError ||
|
||||
popoutNotices.length > 0)
|
||||
const canEnter =
|
||||
hasAgreed && !noticeListQuery.isPending && popoutNotices.length > 0
|
||||
|
||||
if (!shouldShowModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={shouldShowModal}
|
||||
isShowClose={false}
|
||||
isNormalBg={true}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-26">
|
||||
{t('game.modals.entryNotice.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
|
||||
{noticeListQuery.isPending ? (
|
||||
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] items-center justify-center text-design-22 text-[#9CE8F2]">
|
||||
{t('game.modals.entryNotice.loading')}
|
||||
</div>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
|
||||
<div className="text-design-22">
|
||||
{t('game.modals.entryNotice.loadFailed')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void noticeListQuery.refetch()
|
||||
}}
|
||||
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
|
||||
>
|
||||
<RotateCw className="h-design-18 w-design-18" />
|
||||
{t('game.modals.entryNotice.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-design-16">
|
||||
{popoutNotices.map((notice, index) => (
|
||||
<article
|
||||
key={notice.notice_id}
|
||||
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
|
||||
>
|
||||
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
|
||||
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
|
||||
{index + 1}. {notice.title}
|
||||
</div>
|
||||
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
|
||||
{dayjs(notice.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
|
||||
{notice.content ?? ''}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-center gap-design-28">
|
||||
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAgreed}
|
||||
disabled={noticeListQuery.isPending || noticeListQuery.isError}
|
||||
onChange={(event) => setHasAgreed(event.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
|
||||
hasAgreed
|
||||
? 'border-[#4AFF49]/80 bg-[#071F11]'
|
||||
: 'border-[#6CCDCF]/70 bg-[#031D25]',
|
||||
)}
|
||||
>
|
||||
{hasAgreed ? (
|
||||
<SmartImage
|
||||
src={checkIcon}
|
||||
alt=""
|
||||
priority={true}
|
||||
showSkeleton={false}
|
||||
className="h-design-34 w-design-38 overflow-visible"
|
||||
imgClassName="object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{t('game.modals.entryNotice.agreement')}</span>
|
||||
</label>
|
||||
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="106% 108%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
disabled={!canEnter}
|
||||
onClick={() => {
|
||||
if (canEnter) {
|
||||
if (hasStoredLoginInfo) {
|
||||
setLastConfirmedAt(confirmedAtStorageKey, Date.now())
|
||||
}
|
||||
|
||||
setHasEntered(true)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
|
||||
canEnter
|
||||
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
|
||||
: 'cursor-not-allowed text-white opacity-80 grayscale',
|
||||
)}
|
||||
style={
|
||||
canEnter
|
||||
? undefined
|
||||
: {
|
||||
filter: 'grayscale(100%)',
|
||||
WebkitFilter: 'grayscale(100%)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{t('game.modals.entryNotice.enterGame')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface GameOverlayAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
tone?: GameTone
|
||||
}
|
||||
|
||||
interface GameAnnouncementModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: ReactNode
|
||||
eyebrow?: string
|
||||
tone?: GameTone
|
||||
primaryAction?: GameOverlayAction
|
||||
secondaryAction?: GameOverlayAction
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const toneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-slate-950/92',
|
||||
brand: 'border-cyan-300/25 bg-slate-950/94',
|
||||
success: 'border-emerald-300/25 bg-slate-950/94',
|
||||
warning: 'border-amber-300/25 bg-slate-950/94',
|
||||
danger: 'border-rose-300/25 bg-slate-950/94',
|
||||
}
|
||||
|
||||
const actionToneClasses: Record<GameTone, string> = {
|
||||
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
|
||||
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
|
||||
success:
|
||||
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
|
||||
warning:
|
||||
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
|
||||
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
|
||||
}
|
||||
|
||||
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
|
||||
actionToneClasses[tone],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GameAnnouncementModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
eyebrow = '',
|
||||
tone = 'brand',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
children,
|
||||
}: GameAnnouncementModalProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-announcement-title"
|
||||
className={cn(
|
||||
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
|
||||
toneClasses[tone],
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h2
|
||||
id="game-announcement-title"
|
||||
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div className="text-sm leading-7 text-slate-300">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{secondaryAction || primaryAction ? (
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
|
||||
{primaryAction ? <ModalAction {...primaryAction} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user