diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 897960a..747ebe8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -22,6 +22,7 @@ import { FullScreenLoader } from './components/FullScreenLoader'; import { News } from './pages/News'; import PageHeader from './components/PageHeader'; import { useLocation } from 'react-router-dom'; +import DailyReward from './pages/DailyReward'; const AuthCheck = ({ children }: { children: ReactNode }) => { const [isAuthenticated, setIsAuthenticated] = useState(null); @@ -173,6 +174,14 @@ const AppLayout = () => { } /> + + + + } + /> { + const { accessToken, clientToken } = getAuthTokens(); + + if (!accessToken || !clientToken) { + throw new Error('Нет токенов авторизации'); + } + + const params = new URLSearchParams({ + accessToken, + clientToken, + limit: String(limit), + }); + + const response = await fetch( + `${API_BASE_URL}/users/daily/days?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error('Не удалось получить дни ежедневных наград'); + } + + return await response.json(); +} + export async function fetchDailyStatus(): Promise { const { accessToken, clientToken } = getAuthTokens(); diff --git a/src/renderer/components/CapeCard.tsx b/src/renderer/components/CapeCard.tsx index 7646445..6d39242 100644 --- a/src/renderer/components/CapeCard.tsx +++ b/src/renderer/components/CapeCard.tsx @@ -11,7 +11,7 @@ import { Box, Chip, } from '@mui/material'; -import CustomTooltip from './CustomTooltip'; +import CustomTooltip from './Notifications/CustomTooltip'; // Тип для плаща с необязательными полями для обоих вариантов использования export interface CapeCardProps { cape: { diff --git a/src/renderer/components/CoinsDisplay.tsx b/src/renderer/components/CoinsDisplay.tsx index 83847af..9e1e6b6 100644 --- a/src/renderer/components/CoinsDisplay.tsx +++ b/src/renderer/components/CoinsDisplay.tsx @@ -1,6 +1,6 @@ // CoinsDisplay.tsx import { Box, Typography } from '@mui/material'; -import CustomTooltip from './CustomTooltip'; +import CustomTooltip from './Notifications/CustomTooltip'; import { useEffect, useState } from 'react'; import { fetchCoins } from '../api'; @@ -207,14 +207,14 @@ export default function CoinsDisplay({ // Пример 2: Получение данных по username с автообновлением - // Пример 3: Кастомная стилизация без иконки - // Пример 4: Большой отображение для профиля - void; + + severity?: NotificationSeverity; + position?: NotificationPosition; + + autoHideDuration?: number; + variant?: 'filled' | 'outlined' | 'standard'; +} + +const getAccent = (severity: NotificationSeverity) => { + switch (severity) { + case 'success': + return { + // glow: 'rgba(43, 255, 0, 0.45)', + // a1: 'rgba(43, 255, 0, 0.90)', + // a2: 'rgba(0, 255, 170, 0.55)', + // a3: 'rgba(0, 200, 120, 0.35)', + glow: 'rgba(138, 35, 135, 0.45)', + a1: 'rgba(242, 113, 33, 0.90)', + a2: 'rgba(138, 35, 135, 0.90)', + a3: 'rgba(233, 64, 205, 0.90)', + }; + case 'warning': + return { + glow: 'rgba(255, 193, 7, 0.45)', + a1: 'rgba(255, 193, 7, 0.90)', + a2: 'rgba(255, 120, 0, 0.55)', + a3: 'rgba(255, 80, 0, 0.35)', + }; + case 'error': + return { + glow: 'rgba(255, 77, 77, 0.50)', + a1: 'rgba(255, 77, 77, 0.90)', + a2: 'rgba(233, 64, 87, 0.65)', + a3: 'rgba(138, 35, 135, 0.45)', + }; + case 'info': + default: + return { + glow: 'rgba(33, 150, 243, 0.45)', + a1: 'rgba(33, 150, 243, 0.90)', + a2: 'rgba(0, 255, 255, 0.45)', + a3: 'rgba(120, 60, 255, 0.35)', + }; + } +}; + +export default function CustomNotification({ + open, + message, + onClose, + severity = 'info', + position = { vertical: 'bottom', horizontal: 'center' }, + autoHideDuration = 3000, + variant = 'filled', +}: CustomNotificationProps) { + const accent = getAccent(severity); + + const handleClose = ( + _event?: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason + ) => { + if (reason === 'clickaway') return; + onClose(); + }; + + return ( + + + {message} + + + ); +} diff --git a/src/renderer/components/CustomTooltip.tsx b/src/renderer/components/Notifications/CustomTooltip.tsx similarity index 100% rename from src/renderer/components/CustomTooltip.tsx rename to src/renderer/components/Notifications/CustomTooltip.tsx diff --git a/src/renderer/components/PageHeader.tsx b/src/renderer/components/PageHeader.tsx index aab8ae7..e8cb1dd 100644 --- a/src/renderer/components/PageHeader.tsx +++ b/src/renderer/components/PageHeader.tsx @@ -39,6 +39,13 @@ export default function PageHeader() { }; } + if (path.startsWith('/daily')) { + return { + title: 'Ежедневные награды', + subtitle: 'Ежедневный вход на сервер приносит бонусы и полезные награды!', + }; + } + if (path.startsWith('/profile')) { return { title: 'Профиль пользователя', diff --git a/src/renderer/components/Profile/DailyRewards.tsx b/src/renderer/components/Profile/DailyRewards.tsx index 991cd85..cff3e26 100644 --- a/src/renderer/components/Profile/DailyRewards.tsx +++ b/src/renderer/components/Profile/DailyRewards.tsx @@ -10,6 +10,7 @@ import { } from '@mui/material'; import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api'; import CoinsDisplay from '../CoinsDisplay'; +import { useNavigate } from 'react-router-dom'; function formatHHMMSS(totalSeconds: number) { const s = Math.max(0, Math.floor(totalSeconds)); @@ -37,6 +38,7 @@ type DailyStatusCompat = DailyStatusResponse & { }; export default function DailyRewards({ onClaimed, onOpenGame }: Props) { + const navigate = useNavigate(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); const [tick, setTick] = useState(0); @@ -129,11 +131,14 @@ export default function DailyRewards({ onClaimed, onOpenGame }: Props) { return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`; }, [status, wasOnlineToday, canClaim, clientSecondsLeft]); + const navigateDaily = () => { + navigate('/daily'); + }; + return ( {loading ? 'Забираем...' : 'Забрать награду'} + )} diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index 0da5b7d..f6f20d7 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -5,7 +5,7 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useEffect, useRef, useState } from 'react'; import { Tooltip } from '@mui/material'; import { fetchCoins } from '../api'; -import CustomTooltip from './CustomTooltip'; +import CustomTooltip from './Notifications/CustomTooltip'; import CoinsDisplay from './CoinsDisplay'; declare global { interface Window { diff --git a/src/renderer/pages/DailyReward.tsx b/src/renderer/pages/DailyReward.tsx new file mode 100644 index 0000000..cdec473 --- /dev/null +++ b/src/renderer/pages/DailyReward.tsx @@ -0,0 +1,483 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Typography, + IconButton, + Stack, + Paper, + ButtonBase, + Divider, + LinearProgress, + Alert, + Button, +} from '@mui/material'; +import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import TodayRoundedIcon from '@mui/icons-material/TodayRounded'; +import CustomTooltip from '../components/Notifications/CustomTooltip'; +import CoinsDisplay from '../components/CoinsDisplay'; +import { claimDaily, fetchDailyStatus, DailyStatusResponse, fetchDailyClaimDays } from '../api'; + +const RU_MONTHS = [ + 'Январь','Февраль','Март','Апрель','Май','Июнь', + 'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь', +]; +const RU_WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + +const pad2 = (n: number) => String(n).padStart(2, '0'); +const keyOf = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); +const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +const weekdayMonFirst = (date: Date) => (date.getDay() + 6) % 7; + +const EKATERINBURG_TZ = 'Asia/Yekaterinburg'; + +function keyOfInTZ(date: Date, timeZone: string) { + // en-CA даёт ровно YYYY-MM-DD + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +type Cell = { date: Date; inCurrentMonth: boolean }; + +function buildCalendarGrid(viewYear: number, viewMonth: number): Cell[] { + const first = new Date(viewYear, viewMonth, 1); + const lead = weekdayMonFirst(first); + const total = 42; + const cells: Cell[] = []; + for (let i = 0; i < total; i++) { + const d = new Date(viewYear, viewMonth, 1 - lead + i); + cells.push({ date: d, inCurrentMonth: d.getMonth() === viewMonth }); + } + return cells; +} + +function formatHHMMSS(totalSeconds: number) { + const s = Math.max(0, Math.floor(totalSeconds)); + const hh = String(Math.floor(s / 3600)).padStart(2, '0'); + const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'); + const ss = String(s % 60).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} + +function calcRewardByStreak(streak: number) { + return Math.min(10 + Math.max(0, streak - 1) * 10, 50); +} + +type Props = { + onClaimed?: (coinsAdded: number) => void; + onOpenGame?: () => void; +}; + +type DailyStatusCompat = DailyStatusResponse & { + was_online_today?: boolean; + next_claim_at_utc?: string; + next_claim_at_local?: string; +}; + +export default function DailyReward({ onClaimed }: Props) { + const today = useMemo(() => startOfDay(new Date()), []); + const [view, setView] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)); + const [selected, setSelected] = useState(today); + + // перенесённая логика статуса/клейма + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [tick, setTick] = useState(0); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const [claimDays, setClaimDays] = useState>(new Set()); + + const viewYear = view.getFullYear(); + const viewMonth = view.getMonth(); + const grid = useMemo(() => buildCalendarGrid(viewYear, viewMonth), [viewYear, viewMonth]); + + const streak = status?.streak ?? 0; + const wasOnlineToday = status?.was_online_today ?? false; + const canClaim = (status?.can_claim ?? false) && wasOnlineToday; + + const goPrev = () => setView((v) => new Date(v.getFullYear(), v.getMonth() - 1, 1)); + const goNext = () => setView((v) => new Date(v.getFullYear(), v.getMonth() + 1, 1)); + const goToday = () => { + const t = new Date(today.getFullYear(), today.getMonth(), 1); + setView(t); + setSelected(today); + }; + + const selectedKey = keyOf(selected); + + const loadStatus = async () => { + setError(''); + try { + const s = (await fetchDailyStatus()) as DailyStatusCompat; + setStatus(s); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса'); + } + }; + + const loadClaimDays = async () => { + try { + const r = await fetchDailyClaimDays(180); + if (r.ok) setClaimDays(new Set(r.days)); + } catch (e) { + console.error('Ошибка загрузки дней наград:', e); + // можно setError(...) если хочешь показывать + } + }; + + useEffect(() => { + loadStatus(); + loadClaimDays(); + }, []); + + useEffect(() => { + const id = setInterval(() => setTick((x) => x + 1), 1000); + return () => clearInterval(id); + }, []); + + const clientSecondsLeft = useMemo(() => { + if (!status) return 0; + if (canClaim) return 0; + return Math.max(0, (status.seconds_to_next ?? 0) - tick); + }, [status, tick, canClaim]); + + // ✅ фикс прогресса: считаем от clientSecondsLeft, а не от status.seconds_to_next (который не меняется) + const progressValue = useMemo(() => { + const day = 24 * 3600; + const remaining = Math.min(day, Math.max(0, clientSecondsLeft)); + return ((day - remaining) / day) * 100; + }, [clientSecondsLeft]); + + const todaysReward = useMemo(() => { + const effectiveStreak = canClaim ? Math.max(1, streak === 0 ? 1 : streak) : streak; + return calcRewardByStreak(effectiveStreak); + }, [streak, canClaim]); + + const subtitle = useMemo(() => { + if (!status) return ''; + if (!wasOnlineToday) return 'Награда откроется после входа на сервер сегодня.'; + if (canClaim) return 'Можно забрать прямо сейчас 🎁'; + return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`; + }, [status, wasOnlineToday, canClaim, clientSecondsLeft]); + + const handleClaim = async () => { + setLoading(true); + setError(''); + setSuccess(''); + try { + const res = await claimDaily(); + + if (res.claimed) { + const added = res.coins_added ?? 0; + setSuccess(`Вы получили ${added} монет!`); + onClaimed?.(added); + } else { + if (res.reason === 'not_online_today') { + setError('Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.'); + } else { + setError(res.reason || 'Награда недоступна'); + } + } + + await loadStatus(); + await loadClaimDays(); + setTick(0); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка при получении награды'); + } finally { + setLoading(false); + } + }; + + return ( + + + {/* alerts */} + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + + + {/* Header */} + + + + + + + + Серия дней: {streak} + + + + + + + + + + + + + + + + + {RU_MONTHS[viewMonth]} {viewYear} + + + + + + + + + + + + {/* Calendar */} + + + {RU_WEEKDAYS.map((w, i) => ( + = 5 ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.6)', + userSelect: 'none', + }} + > + {w} + + ))} + + + + {grid.map(({ date, inCurrentMonth }) => { + const d = startOfDay(date); + const isToday = isSameDay(d, today); + const isSelected = isSameDay(d, selected); + + const dayKeyEkb = keyOfInTZ(d, EKATERINBURG_TZ); + const claimed = claimDays.has(dayKeyEkb); + + return ( + setSelected(d)} + sx={{ + width: '100%', + aspectRatio: '1 / 1', + borderRadius: 3, + position: 'relative', + overflow: 'hidden', + border: isSelected ? '1px solid rgba(242,113,33,0.85)' : 'none', + bgcolor: inCurrentMonth ? 'rgba(0,0,0,0.24)' : 'rgba(0,0,0,0.12)', + transition: 'transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease', + transform: isSelected ? 'scale(1.02)' : 'scale(1)', + '&:hover': { + bgcolor: inCurrentMonth ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.04)', + transform: 'translateY(-1px)', + }, + }} + > + {isToday && ( + + )} + + + + {d.getDate()} + + + + {claimed ? 'получено' : isToday ? 'сегодня' : ''} + + + {claimed && ( + + )} + + + ); + })} + + + {/* Footer actions */} + + + + Выбрано: {selectedKey} + + + + + + + + + + + + setSelected(today)} + sx={{ + color: 'rgba(255,255,255,0.9)', + bgcolor: 'rgba(0,0,0,0.22)', + '&:hover': { bgcolor: 'rgba(255,255,255,0.08)' }, + }} + > + + + + + + + + + ); +} diff --git a/src/renderer/pages/News.tsx b/src/renderer/pages/News.tsx index 46c2741..39fe9f1 100644 --- a/src/renderer/pages/News.tsx +++ b/src/renderer/pages/News.tsx @@ -169,7 +169,7 @@ export const News = () => { borderRadius: '1.5vw', background: 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))', - border: '1px solid rgba(255, 255, 255, 0.15)', + // border: '1px solid rgba(255, 255, 255, 0.15)', boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)', backdropFilter: 'blur(14px)', }} @@ -350,7 +350,7 @@ export const News = () => { borderRadius: '1.5vw', background: 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))', - border: '1px solid rgba(255, 255, 255, 0.08)', + border: '1px solid rgba(255, 255, 255, 0)', boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)', backdropFilter: 'blur(14px)', width: '80vw', @@ -415,7 +415,7 @@ export const News = () => { fontSize: '0.7vw', color: 'rgba(255,255,255,0.85)', borderRadius: '999px', - border: '1px solid rgba(242,113,33,0.6)', + // border: '1px solid rgba(242,113,33,0.6)', background: 'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))', backdropFilter: 'blur(12px)', diff --git a/src/renderer/pages/Profile.tsx b/src/renderer/pages/Profile.tsx index 34e56c8..77fbbe0 100644 --- a/src/renderer/pages/Profile.tsx +++ b/src/renderer/pages/Profile.tsx @@ -25,8 +25,12 @@ import CapeCard from '../components/CapeCard'; import { FullScreenLoader } from '../components/FullScreenLoader'; import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel'; import DailyRewards from '../components/Profile/DailyRewards'; +import CustomNotification from '../components/Notifications/CustomNotification'; +import type { NotificationPosition } from '../components/Notifications/CustomNotification'; +import { useNavigate } from 'react-router-dom'; export default function Profile() { + const navigate = useNavigate(); const fileInputRef = useRef(null); const [walkingSpeed, setWalkingSpeed] = useState(0.5); const [skin, setSkin] = useState(''); @@ -46,6 +50,22 @@ export default function Profile() { const [viewerWidth, setViewerWidth] = useState(500); const [viewerHeight, setViewerHeight] = useState(600); + // notification + const [notifOpen, setNotifOpen] = useState(false); + const [notifMsg, setNotifMsg] = useState(''); + const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' + >('success'); + + const [notifPos, setNotifPos] = useState({ + vertical: 'top', + horizontal: 'right', + }); + + const navigateDaily = () => { + navigate('/daily'); + }; + useEffect(() => { const savedConfig = localStorage.getItem('launcher_config'); if (savedConfig) { @@ -154,8 +174,16 @@ export default function Profile() { const handleUploadSkin = async () => { setLoading(true); if (!skinFile || !username) { - setStatusMessage('Необходимо выбрать файл и указать имя пользователя'); + const msg = 'Необходимо выбрать файл и указать имя пользователя'; + setStatusMessage(msg); setUploadStatus('error'); + + // notification + setNotifMsg(msg); + setNotifSeverity('error'); + setNotifOpen(true); + + setLoading(false); return; } @@ -163,10 +191,15 @@ export default function Profile() { try { await uploadSkin(username, skinFile, skinModel); - setStatusMessage('Скин успешно загружен!'); setUploadStatus('success'); + // notification + setNotifMsg('Скин успешно загружен!'); + setNotifSeverity('success'); + setNotifPos({ vertical: 'bottom', horizontal: 'left' }); + setNotifOpen(true); + // Обновляем информацию о игроке, чтобы увидеть новый скин const config = JSON.parse( localStorage.getItem('launcher_config') || '{}', @@ -175,10 +208,18 @@ export default function Profile() { loadPlayerData(config.uuid); } } catch (error) { - setStatusMessage( - `Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`, - ); + const msg = `Ошибка: ${ + error instanceof Error ? error.message : 'Не удалось загрузить скин' + }`; + + setStatusMessage(msg); setUploadStatus('error'); + + // notification + setNotifMsg(msg); + setNotifSeverity('error'); + setNotifPos({ vertical: 'bottom', horizontal: 'left' }); + setNotifOpen(true); } finally { setLoading(false); } @@ -187,16 +228,25 @@ export default function Profile() { return ( + setNotifOpen(false)} + autoHideDuration={2500} + /> {loading ? ( ) : ( @@ -208,6 +258,9 @@ export default function Profile() { borderRadius: 2, overflow: 'hidden', bgcolor: 'transparent', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', }} > {/* Используем переработанный компонент SkinViewer */} @@ -243,16 +296,20 @@ export default function Profile() { display: 'flex', flexDirection: 'column', gap: '1vw', + width: '100%', + maxWidth: '44vw', + justifySelf: 'start' }} > Классическая (Steve) - - {uploadStatus === 'error' && ( - - {statusMessage} - - )} - - {uploadStatus === 'success' && ( - - {statusMessage} - - )} - )} diff --git a/src/renderer/pages/VersionsExplorer.tsx b/src/renderer/pages/VersionsExplorer.tsx index 32d9bed..1af65bf 100644 --- a/src/renderer/pages/VersionsExplorer.tsx +++ b/src/renderer/pages/VersionsExplorer.tsx @@ -52,7 +52,6 @@ export const VersionCard: React.FC = ({ display: 'flex', flexDirection: 'column', borderRadius: '2.5vw', - border: '1px solid rgba(255,255,255,0.06)', boxShadow: isHovered ? '0 0 10px rgba(233,64,205,0.55)' : '0 14px 40px rgba(0, 0, 0, 0.6)', @@ -249,23 +248,22 @@ export const VersionsExplorer = () => { display: 'flex', flexDirection: 'column', borderRadius: '2.5vw', - border: '1px dashed rgba(255,255,255,0.3)', + position: 'relative', + border: 'none', boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)', - transition: 'transform 0.35s ease, box-shadow 0.35s ease', overflow: 'hidden', justifyContent: 'center', alignItems: 'center', cursor: 'pointer', - transform: hoveredCardId === 'add' ? 'scale(1.04)' : 'scale(1)', - zIndex: hoveredCardId === 'add' ? 10 : 1, + transition: 'transform 0.35s ease, box-shadow 0.35s ease', + willChange: 'transform, box-shadow', '&:hover': { boxShadow: '0 0 40px rgba(242,113,33,0.7)', - borderStyle: 'solid', + transform: 'scale(1.02)', + zIndex: 10, }, }} onClick={handleAddVersion} - onMouseEnter={() => setHoveredCardId('add')} - onMouseLeave={() => setHoveredCardId(null)} > { return ( { maxHeight: '80vh', overflowY: 'auto', background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)', - border: '1px solid rgba(255,255,255,0.16)', boxShadow: '0 20px 60px rgba(0,0,0,0.85)', p: 4, borderRadius: '2.5vw', @@ -426,7 +423,7 @@ export const VersionsExplorer = () => { borderRadius: '1vw', mb: 1, backgroundColor: 'rgba(0, 0, 0, 0.35)', - border: '1px solid rgba(255,255,255,0.08)', + border: '1px solid rgba(20,20,20,0.2)', cursor: 'pointer', transition: 'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',