All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
next() 漏写了 step===1 且 type 已选的分支,按钮只在「未选」时 拦截,「已选」时进入空函数体直接返回,没有 setStep(2)。 补上 step===1 已选时 setStep(2),行为: - 直接点卡片:保持原有自动下一步(onClick 里 setStep) - 选中后用底部「下一步」按钮:现在也能正常推进 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
512 lines
23 KiB
TypeScript
512 lines
23 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import {
|
||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
||
type LucideIcon,
|
||
} from 'lucide-react';
|
||
import { fetchJson } from '../auth/api-client';
|
||
import { useAuth } from '../auth/useAuth';
|
||
import { canManageFeedback } from '../shared/auth/roles';
|
||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||
import RotatingFooterHint from './RotatingFooterHint';
|
||
|
||
const MAX_SCREENSHOTS = 6;
|
||
const MAX_IMG_SIZE_MB = 5;
|
||
|
||
interface UploadedImg {
|
||
url: string;
|
||
thumbDataUrl: string;
|
||
}
|
||
|
||
async function uploadImage(file: File): Promise<string> {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const token = sessionStorage.getItem('bi_jwt');
|
||
const res = await fetch('/api/feedback/upload', {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: fd,
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||
return json.url as string;
|
||
}
|
||
|
||
function readAsDataUrl(file: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const r = new FileReader();
|
||
r.onload = () => resolve(String(r.result || ''));
|
||
r.onerror = reject;
|
||
r.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||
|
||
interface TypeOption {
|
||
key: FeedbackType;
|
||
icon: LucideIcon;
|
||
label: string;
|
||
sub: string;
|
||
iconBg: string;
|
||
iconFg: string;
|
||
ring: string;
|
||
}
|
||
|
||
const TYPE_OPTIONS: TypeOption[] = [
|
||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||
];
|
||
|
||
const MODULE_LABELS: Record<string, string> = {
|
||
assets: '资产管理',
|
||
mileage: '里程管理',
|
||
energy: '能源管理',
|
||
scheduling: '智能调度',
|
||
ele: '充电导入',
|
||
'': '通用',
|
||
};
|
||
|
||
function detectModule(): string {
|
||
const hash = (window.location.hash || '').slice(1);
|
||
const path = window.location.pathname;
|
||
if (path.includes('/ele/')) return 'ele';
|
||
if (hash.includes('ele')) return 'ele';
|
||
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
||
return hash || '';
|
||
}
|
||
|
||
interface Props {
|
||
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
||
module?: string;
|
||
}
|
||
|
||
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||
const { user } = useAuth();
|
||
const isAdmin = canManageFeedback(user?.roles);
|
||
const [open, setOpen] = useState(false);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||
const [type, setType] = useState<FeedbackType | null>(null);
|
||
const [mod, setMod] = useState<string>('');
|
||
const [content, setContent] = useState('');
|
||
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const addFiles = async (files: FileList | File[]) => {
|
||
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||
if (list.length === 0) return;
|
||
setError(null);
|
||
setUploading(true);
|
||
try {
|
||
for (const f of list) {
|
||
if (shots.length >= MAX_SCREENSHOTS) break;
|
||
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||
continue;
|
||
}
|
||
const thumbDataUrl = await readAsDataUrl(f);
|
||
const url = await uploadImage(f);
|
||
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
// Open: detect current module
|
||
useEffect(() => {
|
||
if (open && step === 1) {
|
||
setMod(moduleProp ?? detectModule());
|
||
}
|
||
}, [open, step, moduleProp]);
|
||
|
||
// Lock scroll when open
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, [open]);
|
||
|
||
const reset = () => {
|
||
setStep(1);
|
||
setType(null);
|
||
setContent('');
|
||
setShots([]);
|
||
setError(null);
|
||
};
|
||
|
||
const close = () => {
|
||
setOpen(false);
|
||
setTimeout(reset, 300); // 等动画完
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (!type || !content.trim()) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
await fetchJson('/api/feedback/submit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type,
|
||
module: mod || null,
|
||
content: content.trim(),
|
||
screenshots: shots.map(s => s.url),
|
||
userAgent: navigator.userAgent.slice(0, 500),
|
||
}),
|
||
});
|
||
setStep(3);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const next = () => {
|
||
if (step === 1) {
|
||
if (!type) return;
|
||
setStep(2);
|
||
return;
|
||
}
|
||
if (step === 2) {
|
||
if (!content.trim()) { taRef.current?.focus(); return; }
|
||
submit();
|
||
return;
|
||
}
|
||
};
|
||
|
||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||
|
||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||
|
||
return (
|
||
<>
|
||
{/* Floating Action Button */}
|
||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||
<motion.button
|
||
initial={{ scale: 0, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||
whileHover={{ scale: 1.08 }}
|
||
whileTap={{ scale: 0.92 }}
|
||
onClick={() => setMenuOpen(m => !m)}
|
||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||
aria-label="反馈"
|
||
title="提建议 / 我的反馈"
|
||
>
|
||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||
</motion.button>
|
||
|
||
<AnimatePresence>
|
||
{menuOpen && (
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||
>
|
||
<button
|
||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||
>
|
||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||
</button>
|
||
<button
|
||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||
>
|
||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||
</button>
|
||
{isAdmin && (
|
||
<>
|
||
<div className="h-px bg-slate-100 my-0.5" />
|
||
<a
|
||
href="#/admin/feedback"
|
||
onClick={() => setMenuOpen(false)}
|
||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||
>
|
||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||
</a>
|
||
</>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* 点击外面关菜单 */}
|
||
{menuOpen && (
|
||
<div
|
||
className="fixed inset-0 z-[-1]"
|
||
onClick={() => setMenuOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||
|
||
{/* Modal */}
|
||
<AnimatePresence>
|
||
{open && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||
>
|
||
<motion.div
|
||
initial={{ y: '100%', opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
exit={{ y: '100%', opacity: 0 }}
|
||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Drag handle */}
|
||
<div className="flex justify-center pt-2.5 pb-1">
|
||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||
</div>
|
||
|
||
{/* Header + progress */}
|
||
<div className="px-4 pb-3 border-b border-slate-100">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||
<Sparkles size={14} className="text-white" />
|
||
</div>
|
||
<div>
|
||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||
{step === 3 ? '收到啦~' : '提个建议'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-400 font-bold">
|
||
{step === 1 && '第一步 / 共 2 步'}
|
||
{step === 2 && '第二步 / 共 2 步'}
|
||
{step === 3 && '感谢你的反馈'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||
<motion.div
|
||
initial={false}
|
||
animate={{ width: `${progress}%` }}
|
||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||
<AnimatePresence mode="wait">
|
||
{step === 1 && (
|
||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||
<RotatingFooterHint className="justify-start mb-4" />
|
||
<div className="grid grid-cols-1 gap-2">
|
||
{TYPE_OPTIONS.map((opt, i) => {
|
||
const Icon = opt.icon;
|
||
const selected = type === opt.key;
|
||
return (
|
||
<motion.button
|
||
key={opt.key}
|
||
initial={{ opacity: 0, y: 6 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||
whileHover={{ y: -1 }}
|
||
whileTap={{ scale: 0.99 }}
|
||
onClick={() => { setType(opt.key); setStep(2); }}
|
||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||
>
|
||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||
</div>
|
||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||
</motion.button>
|
||
);
|
||
})}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 2 && (
|
||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
||
<div>
|
||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||
<textarea
|
||
ref={taRef}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onPaste={(e) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
const imgs: File[] = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const it = items[i];
|
||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||
const f = it.getAsFile();
|
||
if (f) imgs.push(f);
|
||
}
|
||
}
|
||
if (imgs.length > 0) {
|
||
e.preventDefault();
|
||
addFiles(imgs);
|
||
}
|
||
}}
|
||
autoFocus
|
||
rows={5}
|
||
maxLength={1000}
|
||
placeholder={
|
||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||
: '随便聊聊你的想法'
|
||
}
|
||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||
/>
|
||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||
</div>
|
||
|
||
{/* 截图上传 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||
</div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||
/>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{shots.map((s, i) => (
|
||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||
>
|
||
<X size={10} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{shots.length < MAX_SCREENSHOTS && (
|
||
<button
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={uploading}
|
||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||
>
|
||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||
<div className="flex gap-1 flex-wrap">
|
||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
||
<button
|
||
key={k}
|
||
onClick={() => setMod(k)}
|
||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
||
<motion.div
|
||
initial={{ scale: 0, rotate: -180 }}
|
||
animate={{ scale: 1, rotate: 0 }}
|
||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||
>
|
||
<Check size={28} strokeWidth={3} className="text-white" />
|
||
</motion.div>
|
||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{step < 3 && (
|
||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||
{step > 1 && (
|
||
<button
|
||
onClick={back}
|
||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||
>
|
||
<ChevronLeft size={14} /> 上一步
|
||
</button>
|
||
)}
|
||
<div className="flex-1" />
|
||
<button
|
||
onClick={next}
|
||
disabled={!canNext || submitting}
|
||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||
>
|
||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{step === 3 && (
|
||
<div className="px-4 py-3 border-t border-slate-100">
|
||
<button
|
||
onClick={close}
|
||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</>
|
||
);
|
||
}
|