Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack. Made-with: Cursor
415 lines
20 KiB
TypeScript
415 lines
20 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Search, ArrowRight, Sparkles, Zap, X } from "lucide-react";
|
|
import { useLang } from "@/contexts/LanguageContext";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import ComingSoonModal from "@/components/ComingSoonModal";
|
|
|
|
const agents = [
|
|
{ nameKey: "ai.aism.name", tagKey: "ai.aism.tag", descKey: "ai.aism.desc", emoji: "🤖", tags: ["ai.tag.instantResponse", "ai.tag.multiRound", "ai.tag.problemTriage"], gradient: "from-blue-500/10 to-cyan-500/5", status: "online", category: "ai.customerService" },
|
|
{ nameKey: "ai.meeting.name", tagKey: "ai.meeting.tag", descKey: "ai.meeting.desc", emoji: "📝", tags: ["ai.tag.speechToText", "ai.tag.keyPoints", "ai.tag.taskAllocation"], gradient: "from-violet-500/10 to-purple-500/5", status: "online", category: "ai.efficiency" },
|
|
{ nameKey: "ai.deepseek.name", tagKey: "ai.deepseek.tag", descKey: "ai.deepseek.desc", emoji: "🧠", tags: ["ai.tag.deepUnderstanding", "ai.tag.accurate", "ai.tag.knowledgeable"], gradient: "from-emerald-500/10 to-teal-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.adminAssist.name", tagKey: "ai.adminAssist.tag", descKey: "ai.adminAssist.desc", emoji: "📋", tags: ["ai.tag.scheduling", "ai.tag.documents", "ai.tag.reminders"], gradient: "from-amber-500/10 to-orange-500/5", status: "busy", category: "ai.admin" },
|
|
{ nameKey: "ai.strategic.name", tagKey: "ai.strategic.tag", descKey: "ai.strategic.desc", emoji: "🎯", tags: ["ai.tag.swotAnalysis", "ai.tag.competitorResearch", "ai.tag.marketInsights"], gradient: "from-rose-500/10 to-pink-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.bizplan.name", tagKey: "ai.bizplan.tag", descKey: "ai.bizplan.desc", emoji: "📈", tags: ["ai.tag.templates", "ai.tag.dataAnalysis", "ai.tag.oneClickExport"], gradient: "from-indigo-500/10 to-blue-500/5", status: "online", category: "ai.efficiency" },
|
|
{ nameKey: "ai.marketReport.name", tagKey: "ai.marketReport.tag", descKey: "ai.marketReport.desc", emoji: "📊", tags: ["ai.tag.trendScan", "ai.tag.sectorBenchmark", "ai.tag.reportStructured"], gradient: "from-sky-500/10 to-blue-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.techReport.name", tagKey: "ai.techReport.tag", descKey: "ai.techReport.desc", emoji: "🔬", tags: ["ai.tag.techFeasibility", "ai.tag.riskReview", "ai.tag.implementationPath"], gradient: "from-teal-500/10 to-emerald-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.factorySourcing.name", tagKey: "ai.factorySourcing.tag", descKey: "ai.factorySourcing.desc", emoji: "🏭", tags: ["ai.tag.capacityMatch", "ai.tag.certAudit", "ai.tag.geoSourcing"], gradient: "from-orange-500/10 to-amber-500/5", status: "online", category: "ai.industrial" },
|
|
{ nameKey: "ai.patentCompare.name", tagKey: "ai.patentCompare.tag", descKey: "ai.patentCompare.desc", emoji: "📑", tags: ["ai.tag.claimDiff", "ai.tag.featureOverlap", "ai.tag.priorArtHint"], gradient: "from-fuchsia-500/10 to-purple-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.zoneInspection.name", tagKey: "ai.zoneInspection.tag", descKey: "ai.zoneInspection.desc", emoji: "🔍", tags: ["ai.tag.innovationClaims", "ai.tag.listingConsistency", "ai.tag.policyAlignment"], gradient: "from-lime-500/10 to-green-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.techPrice.name", tagKey: "ai.techPrice.tag", descKey: "ai.techPrice.desc", emoji: "💰", tags: ["ai.tag.comparableTx", "ai.tag.licensingContext", "ai.tag.priceRange"], gradient: "from-rose-500/10 to-orange-500/5", status: "online", category: "ai.analyze" },
|
|
{ nameKey: "ai.patentMicroNav.name", tagKey: "ai.patentMicroNav.tag", descKey: "ai.patentMicroNav.desc", emoji: "🧭", tags: ["ai.tag.citationMap", "ai.tag.techBranches", "ai.tag.landscapeBrief"], gradient: "from-cyan-500/10 to-sky-500/5", status: "online", category: "ai.analyze" },
|
|
];
|
|
|
|
const categories = ["ai.all", ...Array.from(new Set(agents.map((a) => a.category)))];
|
|
|
|
// Optional .env overrides: VITE_*_CHATBOT_URL for each bot (see vite-env.d.ts)
|
|
const MEETING_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_MEETING_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chat/CjmrnoUyQKBa9wEP";
|
|
|
|
const DEEPSEEK_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_DEEPSEEK_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/pOcS3NYaXCkOP9K6";
|
|
|
|
const AISM_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_AISM_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/grHow5qCsnZd7PYq";
|
|
|
|
const ADMIN_ASSIST_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_ADMIN_ASSIST_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/FIIxbn1rsEtmw2Yt";
|
|
|
|
const STRATEGIC_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_STRATEGIC_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/bKP9rLpTdWQgWSLA";
|
|
|
|
const BIZPLAN_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_BIZPLAN_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/u0mmSsO0YCtjh37K";
|
|
|
|
const MARKET_REPORT_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_MARKET_REPORT_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/SqVX9saxCfTdJq6F";
|
|
|
|
const TECH_REPORT_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_TECH_REPORT_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/S8fdxgEVKa3V1kWb";
|
|
|
|
const PATENT_COMPARE_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_PATENT_COMPARE_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/tvX9ytDVu0BonRZp";
|
|
|
|
const ZONE_INSPECTION_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_ZONE_INSPECTION_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/RUeFYK8U6gt9cncx";
|
|
|
|
const TECH_PRICE_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_TECH_PRICE_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/VDLRBetGCViMjEEy";
|
|
|
|
const PATENT_MICRO_NAV_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_PATENT_MICRO_NAV_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/pU0QjcVNDaz0mBzJ";
|
|
|
|
// Override with VITE_FACTORY_SOURCING_CHATBOT_URL if this id is not your factory bot.
|
|
const FACTORY_SOURCING_CHATBOT_IFRAME_SRC =
|
|
(import.meta.env.VITE_FACTORY_SOURCING_CHATBOT_URL as string | undefined)?.trim() ||
|
|
"http://nw.sgcode.cn:18181/chatbot/OstO0weOb2iRy4Ng";
|
|
|
|
const CHATBOT_IFRAME_STYLE = {
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: 700,
|
|
border: 0,
|
|
} as const;
|
|
|
|
type ChatSession = { src: string; title: string } | null;
|
|
|
|
export default function AITools() {
|
|
const { t, lang } = useLang();
|
|
const [activeCategory, setActiveCategory] = useState("ai.all");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [chatSession, setChatSession] = useState<ChatSession>(null);
|
|
const [comingSoonOpen, setComingSoonOpen] = useState(false);
|
|
|
|
const filteredAgents = agents.filter((agent) => {
|
|
const matchesCategory = activeCategory === "ai.all" || agent.category === activeCategory;
|
|
const matchesSearch =
|
|
!searchQuery ||
|
|
t(agent.nameKey).toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t(agent.descKey).toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
return matchesCategory && matchesSearch;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!chatSession) return;
|
|
|
|
const html = document.documentElement;
|
|
const body = document.body;
|
|
const prevHtmlOverflow = html.style.overflow;
|
|
const prevBodyOverflow = body.style.overflow;
|
|
const scrollY = window.scrollY;
|
|
|
|
html.style.overflow = "hidden";
|
|
body.style.overflow = "hidden";
|
|
body.style.position = "fixed";
|
|
body.style.top = `-${scrollY}px`;
|
|
body.style.left = "0";
|
|
body.style.right = "0";
|
|
body.style.width = "100%";
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setChatSession(null);
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
|
|
return () => {
|
|
html.style.overflow = prevHtmlOverflow;
|
|
body.style.overflow = prevBodyOverflow;
|
|
body.style.position = "";
|
|
body.style.top = "";
|
|
body.style.left = "";
|
|
body.style.right = "";
|
|
body.style.width = "";
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.scrollTo(0, scrollY);
|
|
};
|
|
}, [chatSession]);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
|
|
>
|
|
<div>
|
|
<div className="section-tag w-fit mb-4">
|
|
<Sparkles className="w-3 h-3" />
|
|
{t("ai.tag")}
|
|
</div>
|
|
<h3 className="text-3xl font-bold text-foreground tracking-tight">{t("ai.title")}</h3>
|
|
<p className="text-sm text-muted-foreground mt-2 max-w-md">{t("ai.subtitle")}</p>
|
|
</div>
|
|
<div className="relative w-full md:w-72">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder={t("ai.search")}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 rounded-xl glass-card text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 placeholder:text-muted-foreground border-0"
|
|
/>
|
|
{searchQuery && (
|
|
<motion.button
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
✕
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Category pills with animated indicator */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
className="flex flex-wrap gap-2"
|
|
>
|
|
{categories.map((cat) => (
|
|
<motion.button
|
|
key={cat}
|
|
onClick={() => setActiveCategory(cat)}
|
|
className={`relative px-4 py-2 rounded-xl text-xs font-medium transition-colors duration-200 ${
|
|
activeCategory === cat
|
|
? "text-background"
|
|
: "glass-pill text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
{activeCategory === cat && (
|
|
<motion.div
|
|
layoutId="activeCat"
|
|
className="absolute inset-0 bg-foreground rounded-xl shadow-lg shadow-foreground/10"
|
|
transition={{ type: "spring", stiffness: 400, damping: 28 }}
|
|
/>
|
|
)}
|
|
<span className="relative z-10">{t(cat)}</span>
|
|
</motion.button>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Agent grid */}
|
|
<motion.div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
|
{filteredAgents.length === 0 && (
|
|
<motion.div
|
|
key="empty"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="col-span-full text-center py-12 text-muted-foreground text-sm"
|
|
>
|
|
{lang === "zh" ? "没有找到匹配的智能体" : "No matching agents found"}
|
|
</motion.div>
|
|
)}
|
|
|
|
{filteredAgents.map((agent, i) => (
|
|
<motion.div
|
|
key={agent.nameKey}
|
|
initial="rest"
|
|
animate="rest"
|
|
whileHover="hover"
|
|
variants={{ rest: { y: 0, scale: 1 }, hover: { y: -8, scale: 1.03 } }}
|
|
transition={{ duration: 0.25, delay: i * 0.04 }}
|
|
className={`glass-card p-0 flex flex-col overflow-hidden cursor-pointer ${
|
|
i === 0 && filteredAgents.length >= 3 ? "lg:col-span-2 lg:row-span-1" : ""
|
|
}`}
|
|
style={{ boxShadow: "none" }}
|
|
>
|
|
{/* Gradient header */}
|
|
<div className={`bg-gradient-to-br ${agent.gradient} p-6 pb-4 relative`}>
|
|
<div className="flex items-start justify-between">
|
|
<motion.div
|
|
className="text-4xl origin-center"
|
|
variants={{
|
|
rest: { scale: 1, rotate: 0 },
|
|
hover: { scale: 1.3, rotate: [0, -12, 10, -6, 0] },
|
|
}}
|
|
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
|
>
|
|
{agent.emoji}
|
|
</motion.div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
|
<div
|
|
className={`w-2 h-2 rounded-full ${agent.status === "online" ? "bg-emerald-400" : "bg-amber-400"}`}
|
|
/>
|
|
{agent.status}
|
|
</div>
|
|
<span className="section-tag !text-[10px] !px-2.5 !py-1">{t(agent.tagKey)}</span>
|
|
</div>
|
|
</div>
|
|
<h5 className="font-bold text-foreground text-base mt-3 leading-tight">{t(agent.nameKey)}</h5>
|
|
</div>
|
|
|
|
<div className="p-6 pt-4 flex flex-col flex-1">
|
|
<p className="text-xs text-muted-foreground flex-1 mb-4 leading-relaxed">{t(agent.descKey)}</p>
|
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
|
{agent.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="glass-pill !rounded-lg px-2.5 py-1 text-[10px] text-muted-foreground font-medium"
|
|
>
|
|
{t(tag)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="btn-primary w-full !rounded-xl !text-xs relative isolate !shadow-none hover:!shadow-none hover:[filter:none] flex items-center justify-center gap-2 transition-transform active:scale-[0.98]"
|
|
onClick={() => {
|
|
if (agent.nameKey === "ai.aism.name") {
|
|
setChatSession({ src: AISM_CHATBOT_IFRAME_SRC, title: t("ai.aism.name") });
|
|
} else if (agent.nameKey === "ai.meeting.name") {
|
|
setChatSession({
|
|
src: MEETING_CHATBOT_IFRAME_SRC,
|
|
title: lang === "zh" ? "会议纪要助手" : "Meeting minutes assistant",
|
|
});
|
|
} else if (agent.nameKey === "ai.deepseek.name") {
|
|
setChatSession({
|
|
src: DEEPSEEK_CHATBOT_IFRAME_SRC,
|
|
title: lang === "zh" ? "Deepseek问答助手" : "Deepseek Q&A assistant",
|
|
});
|
|
} else if (agent.nameKey === "ai.adminAssist.name") {
|
|
setChatSession({
|
|
src: ADMIN_ASSIST_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.adminAssist.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.strategic.name") {
|
|
setChatSession({
|
|
src: STRATEGIC_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.strategic.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.bizplan.name") {
|
|
setChatSession({
|
|
src: BIZPLAN_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.bizplan.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.marketReport.name") {
|
|
setChatSession({
|
|
src: MARKET_REPORT_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.marketReport.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.techReport.name") {
|
|
setChatSession({
|
|
src: TECH_REPORT_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.techReport.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.patentCompare.name") {
|
|
setChatSession({
|
|
src: PATENT_COMPARE_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.patentCompare.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.zoneInspection.name") {
|
|
setChatSession({
|
|
src: ZONE_INSPECTION_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.zoneInspection.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.techPrice.name") {
|
|
setChatSession({
|
|
src: TECH_PRICE_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.techPrice.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.patentMicroNav.name") {
|
|
setChatSession({
|
|
src: PATENT_MICRO_NAV_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.patentMicroNav.name"),
|
|
});
|
|
} else if (agent.nameKey === "ai.factorySourcing.name") {
|
|
setChatSession({
|
|
src: FACTORY_SOURCING_CHATBOT_IFRAME_SRC,
|
|
title: t("ai.factorySourcing.name"),
|
|
});
|
|
} else {
|
|
setComingSoonOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
<Zap className="w-3 h-3" />
|
|
{t("ai.startChat")}
|
|
<ArrowRight className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Portal modal with viewport padding (not full-bleed) */}
|
|
{typeof document !== "undefined" &&
|
|
createPortal(
|
|
<AnimatePresence>
|
|
{chatSession && (
|
|
<motion.div
|
|
key={`chat-fullscreen-${chatSession.src}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-6 overflow-hidden overscroll-none bg-zinc-950/80 backdrop-blur-sm"
|
|
style={{
|
|
overscrollBehavior: "none",
|
|
width: "100vw",
|
|
minHeight: "100dvh",
|
|
height: "100dvh",
|
|
}}
|
|
onClick={() => setChatSession(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
|
transition={{ duration: 0.2 }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={chatSession.title}
|
|
className="relative w-full max-w-5xl h-[min(86dvh,820px)] sm:min-h-[700px] rounded-2xl overflow-hidden border border-zinc-700/80 bg-zinc-950 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<iframe
|
|
title={chatSession.title}
|
|
src={chatSession.src}
|
|
className="absolute inset-0 bg-zinc-950"
|
|
style={CHATBOT_IFRAME_STYLE}
|
|
allow="microphone"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setChatSession(null)}
|
|
className="absolute z-10 flex h-11 w-11 items-center justify-center rounded-full border border-zinc-600/80 bg-zinc-900/90 text-zinc-100 shadow-lg backdrop-blur-sm transition-colors hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400"
|
|
style={{
|
|
top: "max(0.75rem, env(safe-area-inset-top, 0px))",
|
|
right: "max(0.75rem, env(safe-area-inset-right, 0px))",
|
|
}}
|
|
aria-label={lang === "zh" ? "关闭" : "Close"}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>,
|
|
document.body,
|
|
)}
|
|
|
|
<ComingSoonModal open={comingSoonOpen} onClose={() => setComingSoonOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|