امروز ۳۱ اردیبهشت
چت بات عمومی خرابه
This commit is contained in:
parent
28c14ebd15
commit
213c0a70f0
BIN
dist/assets/chatbot-bot-avatar-Bbf-v3Pj.png
vendored
Normal file
BIN
dist/assets/chatbot-bot-avatar-Bbf-v3Pj.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
398
dist/assets/index-B28_Ysnv.js
vendored
Normal file
398
dist/assets/index-B28_Ysnv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-B5jzgFDg.css
vendored
Normal file
1
dist/assets/index-B5jzgFDg.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BppR-T9V.css
vendored
1
dist/assets/index-BppR-T9V.css
vendored
File diff suppressed because one or more lines are too long
377
dist/assets/index-D_YYDgvN.js
vendored
377
dist/assets/index-D_YYDgvN.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -16,8 +16,8 @@
|
|||
background: #23183E;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-D_YYDgvN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BppR-T9V.css">
|
||||
<script type="module" crossorigin src="/assets/index-B28_Ysnv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5jzgFDg.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const pageTransition = {
|
|||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
const outlet = useOutlet();
|
||||
const isPublicChatPage = location.pathname === "/public-chat";
|
||||
const reduceMotion = useReducedMotion();
|
||||
const previousPathRef = useRef(location.pathname);
|
||||
const pageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -65,7 +66,17 @@ export function AnimatedOutlet() {
|
|||
...pageTransition,
|
||||
duration: reduceMotion ? 0.12 : pageTransition.duration,
|
||||
}}
|
||||
style={{ willChange: "opacity, transform" }}
|
||||
style={
|
||||
isPublicChatPage
|
||||
? {
|
||||
willChange: "opacity, transform",
|
||||
overflowY: "hidden",
|
||||
paddingLeft: "0px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
}
|
||||
: { willChange: "opacity, transform" }
|
||||
}
|
||||
>
|
||||
{outlet}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
|
|||
} as const;
|
||||
|
||||
return (
|
||||
<div className="relative z-20 flex items-center justify-between px-4 pt-3 pb-2" dir="rtl">
|
||||
<div className="relative z-20 flex items-center justify-between px-4 pt-3 pb-0" dir="rtl">
|
||||
{showBack ? (
|
||||
<button
|
||||
onClick={onBack}
|
||||
|
|
@ -105,9 +105,12 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
|
|||
<div
|
||||
className="font-extrabold text-[22px]"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #ff8ccf 0%, #ff6dbe 32%, #f88bd4 100%)",
|
||||
display: "inline-block",
|
||||
background:
|
||||
"linear-gradient(90deg, #F6D8A5 0%, #F3A599 20%, #DB7EB2 48%, #AA6798 72%, #CB75AB 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
color: "transparent",
|
||||
textShadow: "0 2px 10px rgba(255, 119, 202, 0.4)",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,105 @@
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { motion } from "motion/react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, Clock3, Star } from "lucide-react";
|
||||
import challengeIcon from "figma:asset/c11973053d8410ffeb3c76aa4d1da6991076e7e1.png";
|
||||
import { getTopicConfig } from "../../config/topicConfig";
|
||||
import { loadMissions, getMissionImageUrl, MissionData, startMission } from "../../services/feedService";
|
||||
import { loadMissions, getMissionImageUrl, MissionData } from "../../services/feedService";
|
||||
import { usePageTracking } from "../../hooks/usePageTracking";
|
||||
import { FeedHeader } from "./feed/FeedHeader";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import { AppBackground } from "./shared/AppBackground";
|
||||
import { useInbox } from "../context/InboxContext";
|
||||
import { backgroundImages } from "../../config/backgroundConfig";
|
||||
import coinImage from "../../assets/coin-star.png";
|
||||
|
||||
const PERSIAN_NUMBERS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] as const;
|
||||
|
||||
const toPersianNumber = (input: string | number | undefined, fallback = "") => {
|
||||
const value = input === undefined || input === null || input === "" ? fallback : String(input);
|
||||
return value.replace(/\d/g, (digit) => PERSIAN_NUMBERS[parseInt(digit, 10)]);
|
||||
};
|
||||
|
||||
const formatMissionDuration = (duration: MissionData["duration"]) => {
|
||||
const value = duration === undefined || duration === null || duration === "" ? "15" : String(duration);
|
||||
return value.includes("دقیقه") ? toPersianNumber(value) : `${toPersianNumber(value)} دقیقه`;
|
||||
};
|
||||
|
||||
function DifficultySignalIcon() {
|
||||
const bars = [
|
||||
{ height: "h-[5px]", color: "#D7B6FF" },
|
||||
{ height: "h-[8px]", color: "#F2A8D8" },
|
||||
{ height: "h-[11px]", color: "#F7C47D" },
|
||||
];
|
||||
|
||||
return (
|
||||
<span className="inline-flex h-4 w-4 -translate-y-[2px] items-end justify-center gap-[2px] align-middle" aria-hidden="true">
|
||||
{bars.map((bar, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`w-[3px] rounded-full ${bar.height}`}
|
||||
style={{ backgroundColor: bar.color }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const glassPanelStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(180deg, rgba(46, 27, 61, 0.82) 0%, rgba(35, 24, 62, 0.8) 100%),
|
||||
linear-gradient(128deg, rgba(255, 164, 222, 0.92) 0%, rgba(168, 120, 255, 0.88) 38%, rgba(249, 115, 22, 0.78) 72%, rgba(250, 204, 21, 0.68) 100%)
|
||||
`,
|
||||
border: "1px solid transparent",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.52), 0 10px 22px rgba(5, 2, 12, 0.38), 0 0 14px rgba(255, 121, 207, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 2px 5px rgba(255, 222, 255, 0.06), inset 0 -2px 0 rgba(12, 7, 27, 0.74), inset 0 -8px 14px rgba(8, 4, 18, 0.38), inset 0 0 0 1px rgba(255, 255, 255, 0.03), inset 0 0 0 2px rgba(17, 10, 35, 0.36)",
|
||||
backdropFilter: "blur(14px) saturate(118%)",
|
||||
WebkitBackdropFilter: "blur(14px) saturate(118%)",
|
||||
} as const;
|
||||
|
||||
const cardInnerGlassStyle = {
|
||||
background:
|
||||
"linear-gradient(132deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.018) 28%, rgba(255, 121, 207, 0.065) 58%, rgba(124, 58, 237, 0.055) 100%), linear-gradient(180deg, rgba(46, 27, 61, 0.72) 0%, rgba(35, 24, 62, 0.8) 100%)",
|
||||
border: "1px solid rgba(255, 191, 229, 0.2)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 rgba(255,255,255,0.15), inset 0 0 18px rgba(255,121,207,0.05), inset 0 -12px 20px rgba(8,4,18,0.34)",
|
||||
} as const;
|
||||
|
||||
const imageFrameStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(180deg, rgba(46, 27, 61, 0.78) 0%, rgba(35, 24, 62, 0.78) 100%),
|
||||
linear-gradient(135deg, rgba(124, 58, 237, 0.95) 0%, rgba(255, 121, 207, 0.72) 48%, rgba(249, 115, 22, 0.88) 100%)
|
||||
`,
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 10px 22px rgba(0,0,0,0.38), 0 0 14px rgba(255,121,207,0.16), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
} as const;
|
||||
|
||||
const arrowButtonStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(145deg, rgba(46, 27, 61, 0.82) 0%, rgba(35, 24, 62, 0.88) 100%),
|
||||
linear-gradient(132deg, rgba(255, 121, 207, 0.74) 0%, rgba(124, 58, 237, 0.7) 48%, rgba(249, 115, 22, 0.64) 100%)
|
||||
`,
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 8px 18px rgba(0,0,0,0.34), 0 0 12px rgba(255,121,207,0.14), inset 0 1px 0 rgba(255,255,255,0.16), inset 0 -4px 8px rgba(8,4,18,0.38)",
|
||||
} as const;
|
||||
|
||||
const headerBackButtonStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(180deg, #2E1B3D 0%, #23183E 100%),
|
||||
linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)
|
||||
`,
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.5), 0 6px 14px rgba(5, 2, 12, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 2px 5px rgba(255, 222, 255, 0.09), inset 0 -2px 0 rgba(12, 7, 27, 0.72), inset 0 -8px 14px rgba(8, 4, 18, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.045), inset 0 0 0 2px rgba(17, 10, 35, 0.32)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
} as const;
|
||||
|
||||
export function ChallengeSelectionPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -19,45 +109,30 @@ export function ChallengeSelectionPage() {
|
|||
const topicConfig = getTopicConfig(topicId);
|
||||
|
||||
usePageTracking(`انتخاب چالش ${topicConfig.title}`);
|
||||
const { refreshInbox } = useInbox();
|
||||
|
||||
const handleChallengeSelectCallback = useCallback(async (missionId: string) => {
|
||||
setStartingMission(true);
|
||||
try {
|
||||
const response = await startMission(topicConfig.title, missionId);
|
||||
|
||||
// ذخیره اطلاعات ماموریت در localStorage برای بازگشتهای بعدی
|
||||
localStorage.setItem('current_mission_type', topicConfig.title);
|
||||
localStorage.setItem('current_mission_id', missionId);
|
||||
|
||||
// ذخیره workflow_ID از doing_mission
|
||||
if (response.doing_mission?.workflow_ID) {
|
||||
localStorage.setItem('current_workflow_ID', response.doing_mission.workflow_ID);
|
||||
}
|
||||
const handleChallengeSelectCallback = useCallback((mission: MissionData) => {
|
||||
const missionId = mission.mission_workflowID;
|
||||
|
||||
// Refresh inbox to load new messages
|
||||
await refreshInbox();
|
||||
localStorage.setItem("current_mission_type", topicConfig.title);
|
||||
localStorage.setItem("current_mission_id", missionId);
|
||||
localStorage.setItem("current_mission_title", mission.title);
|
||||
|
||||
// Navigate to chatbot page with mission data
|
||||
navigate(`/chatbot/${topicId}`, {
|
||||
state: {
|
||||
chats: response.chats,
|
||||
doingMission: response.doing_mission,
|
||||
missionType: topicConfig.title,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting mission:", error);
|
||||
alert("خطا در شروع چالش. لطفاً دوباره تلاش کنید.");
|
||||
setAutoNavigating(false);
|
||||
} finally {
|
||||
setStartingMission(false);
|
||||
}
|
||||
}, [topicConfig.title, topicId, navigate, refreshInbox]);
|
||||
const params = new URLSearchParams({
|
||||
continueMode: "true",
|
||||
missionId,
|
||||
missionType: topicConfig.title,
|
||||
});
|
||||
|
||||
navigate(`/chatbot/${topicId}?${params.toString()}`, {
|
||||
state: {
|
||||
selectedMissionTitle: mission.title,
|
||||
missionType: topicConfig.title,
|
||||
},
|
||||
});
|
||||
}, [topicConfig.title, topicId, navigate]);
|
||||
|
||||
const [missions, setMissions] = useState<MissionData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startingMission, setStartingMission] = useState(false);
|
||||
const [autoNavigating, setAutoNavigating] = useState(false);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
|
|
@ -76,7 +151,7 @@ export function ChallengeSelectionPage() {
|
|||
if (response.missions.length > 0) {
|
||||
// اگر چالشی وجود داشت، به طور خودکار آن را انتخاب کن
|
||||
setAutoNavigating(true);
|
||||
await handleChallengeSelectCallback(response.missions[0].mission_workflowID);
|
||||
handleChallengeSelectCallback(response.missions[0]);
|
||||
} else {
|
||||
// اگر چالشی وجود نداشت، پیام خطا نمایش بده
|
||||
alert("چالشی برای این بخش وجود ندارد");
|
||||
|
|
@ -112,20 +187,35 @@ export function ChallengeSelectionPage() {
|
|||
{/* Content */}
|
||||
<div className="relative z-10 max-w-md mx-auto h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<FeedHeader topicTitle={topicConfig.title} subtitle="انتخاب چالش" onBack={handleBack} />
|
||||
<AppHeader showBack onBack={handleBack} centerTitle={topicConfig.title} centerSubtitle="انتخاب چالش" />
|
||||
|
||||
{/* Main Content - Scrollable */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-4 pb-20"
|
||||
className="mt-2 flex-1 overflow-y-auto px-4 pb-20"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 48px)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 48px)",
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
<div className="pt-8">
|
||||
|
||||
<div className="pt-7">
|
||||
<div
|
||||
className="mb-5 rounded-[22px] border-[0.5px] border-transparent px-4 py-3 text-right"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[13px] font-bold text-[#FBE7F5]">چالش مناسب رو انتخاب کن و شروع کن</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#EED3EC]/90">
|
||||
امتیاز، سختی و زمان هر چالش رو ببین و مستقیم وارد مرحله گفتگو با ربات شو.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Challenges List */}
|
||||
<div className="w-full space-y-4" dir="rtl">
|
||||
|
|
@ -158,43 +248,101 @@ export function ChallengeSelectionPage() {
|
|||
transition={{ delay: 0.3 + index * 0.1, duration: 0.4 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-teal-700/60 to-teal-900/70 backdrop-blur-md rounded-2xl p-4 border border-teal-500/30 shadow-xl shadow-teal-500/20 flex items-center gap-3 hover:scale-[1.01] transition-transform">
|
||||
{/* Challenge Icon */}
|
||||
<div className="flex-shrink-0 w-16 h-16 relative">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-purple-500/30 to-blue-500/30 blur-lg animate-pulse" />
|
||||
<img
|
||||
src={getMissionImageUrl(mission.StageID)}
|
||||
alt={mission.title}
|
||||
className="w-full h-full object-cover rounded-full relative z-10 drop-shadow-xl"
|
||||
loading="eager"
|
||||
onError={(e) => {
|
||||
// Fallback به آیکون پیشفرض در صورت خطا
|
||||
e.currentTarget.src = challengeIcon;
|
||||
e.currentTarget.style.objectFit = "contain";
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
onClick={() => handleChallengeSelectCallback(mission)}
|
||||
className="block w-full rounded-[20px] border-[0.5px] border-transparent p-[1px] text-right"
|
||||
style={glassPanelStyle}
|
||||
aria-label={`شروع چالش ${mission.title}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-[132px] items-center gap-3 overflow-hidden rounded-[19px] px-3 py-3"
|
||||
style={cardInnerGlassStyle}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(115deg, rgba(255,255,255,0.055) 0%, transparent 34%, rgba(255,121,207,0.05) 62%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute -left-8 top-0 h-20 w-36 rotate-[-18deg] rounded-full blur-2xl"
|
||||
style={{ background: "rgba(255, 121, 207, 0.12)" }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 right-8 h-16 w-32 rounded-full blur-2xl"
|
||||
style={{ background: "rgba(249, 115, 22, 0.1)" }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 text-right">
|
||||
<h3 className="text-white font-bold text-base mb-1">
|
||||
{mission.title}
|
||||
</h3>
|
||||
<p className="text-teal-100 text-xs leading-relaxed mb-2 opacity-90">
|
||||
{mission.description}
|
||||
</p>
|
||||
|
||||
{/* Start Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => handleChallengeSelectCallback(mission.mission_workflowID)}
|
||||
className="bg-gradient-to-r from-yellow-400 via-yellow-500 to-yellow-400 text-gray-900 font-bold text-sm rounded-full px-6 py-1.5 shadow-lg shadow-yellow-500/50 border-2 border-yellow-300/50"
|
||||
<div
|
||||
className="relative z-10 h-[104px] w-[104px] flex-shrink-0 overflow-hidden rounded-[16px] border-[0.5px] border-transparent p-[1px]"
|
||||
style={imageFrameStyle}
|
||||
>
|
||||
شروع
|
||||
</motion.button>
|
||||
<img
|
||||
src={getMissionImageUrl(mission.StageID)}
|
||||
alt={mission.title}
|
||||
className="h-full w-full rounded-[15px] object-cover"
|
||||
loading="eager"
|
||||
onError={(e) => {
|
||||
// Fallback به آیکون پیشفرض در صورت خطا
|
||||
e.currentTarget.src = challengeIcon;
|
||||
e.currentTarget.style.objectFit = "contain";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-w-0 flex-1 text-right">
|
||||
<div className="mb-1 flex items-center justify-start gap-2">
|
||||
<h3 className="truncate text-[18px] font-extrabold leading-7 text-white">
|
||||
{mission.title}
|
||||
</h3>
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-[0.5px] border-transparent text-[#ffb7dd]"
|
||||
style={arrowButtonStyle}
|
||||
>
|
||||
<Star size={13} fill="currentColor" strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="mb-3 text-[12px] font-medium leading-5 text-[#F4EAF6]/88"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{mission.description}
|
||||
</p>
|
||||
|
||||
<div className="flex w-full flex-nowrap items-center justify-between gap-1 text-[10px] font-normal leading-none text-white/90">
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||||
<span className="leading-none">{toPersianNumber(mission.coin_count, "250")}</span>
|
||||
<img src={coinImage} alt="سکه" className="h-4 w-4 shrink-0 object-contain" />
|
||||
</span>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||||
<span className="leading-none">{mission.difficulty || "متوسط"}</span>
|
||||
<DifficultySignalIcon />
|
||||
</span>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||||
<Clock3 size={13} className="shrink-0 text-[#ffb7dd]" strokeWidth={2.1} />
|
||||
<span className="leading-none">{formatMissionDuration(mission.duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="relative z-10 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full border-[0.5px] border-transparent text-white"
|
||||
style={headerBackButtonStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronLeft size={22} color="#ffffff" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { getTopicConfig } from "../../config/topicConfig";
|
||||
import { useMissionSession } from "../../hooks/useMissionSession";
|
||||
|
|
@ -9,19 +9,24 @@ import { FeedHeader } from "./feed/FeedHeader";
|
|||
import { AppBackground } from "./shared/AppBackground";
|
||||
import { ChatMessageList, ChatMessageListRef } from "./chatbot/ChatMessageList";
|
||||
import { ChatInputBar } from "./chatbot/ChatInputBar";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import { usePageTracking } from "../../hooks/usePageTracking";
|
||||
import { backgroundImages } from "../../config/backgroundConfig";
|
||||
|
||||
export function ChatbotPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { topicId = "1" } = useParams<{ topicId: string }>();
|
||||
const topicConfig = getTopicConfig(topicId);
|
||||
const locationState = location.state as { selectedMissionTitle?: string } | null;
|
||||
|
||||
usePageTracking(`چتبات ${topicConfig.title}`);
|
||||
|
||||
const { sessionData, isLoading: isLoadingMission, error: missionError } =
|
||||
useMissionSession();
|
||||
const missionTitle =
|
||||
locationState?.selectedMissionTitle?.trim() ||
|
||||
localStorage.getItem("current_mission_title")?.trim() ||
|
||||
sessionData?.doingMission?.title?.trim();
|
||||
|
||||
const [isMissionEnd, setIsMissionEnd] = useState(false);
|
||||
|
||||
|
|
@ -104,7 +109,10 @@ export function ChatbotPage() {
|
|||
<div className="relative h-[100dvh] w-full overflow-hidden">
|
||||
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.chatbot} />
|
||||
<div className="relative z-10 flex h-full items-center justify-center text-center text-white">
|
||||
<p className="text-base">در حال بارگذاری...</p>
|
||||
<div>
|
||||
<div className="mx-auto mb-3 h-10 w-10 animate-spin rounded-full border-4 border-[#ffd6f0]/30 border-t-[#ff79cf]" />
|
||||
<p className="text-base font-bold">در حال بارگذاری...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -135,7 +143,11 @@ export function ChatbotPage() {
|
|||
|
||||
<div className="relative z-10 mx-auto grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<div className="shrink-0">
|
||||
<FeedHeader topicTitle={topicConfig.title} onBack={handleBack} />
|
||||
<FeedHeader
|
||||
topicTitle={missionTitle || topicConfig.title}
|
||||
subtitle={missionTitle ? topicConfig.title : undefined}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main
|
||||
|
|
@ -162,7 +174,7 @@ export function ChatbotPage() {
|
|||
<footer
|
||||
className="shrink-0"
|
||||
style={{
|
||||
paddingBottom: "0px",
|
||||
paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 12px)",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 pt-2">
|
||||
|
|
@ -171,10 +183,6 @@ export function ChatbotPage() {
|
|||
disabled={isSending || isTyping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pt-6">
|
||||
<BottomNav fixed={false} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,38 @@ interface HeaderProps {
|
|||
action?: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
onActionClick?: () => void;
|
||||
}
|
||||
|
||||
export function Header({ showBack = false, onBack }: HeaderProps) {
|
||||
const headerActionStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(180deg, #2E1B3D 0%, #23183E 100%),
|
||||
linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)
|
||||
`,
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.5), 0 6px 14px rgba(5, 2, 12, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 2px 5px rgba(255, 222, 255, 0.09), inset 0 -2px 0 rgba(12, 7, 27, 0.72), inset 0 -8px 14px rgba(8, 4, 18, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.045), inset 0 0 0 2px rgba(17, 10, 35, 0.32)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
} as const;
|
||||
|
||||
export function Header({ showBack = false, onBack, action, onActionClick }: HeaderProps) {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<header className="app-header relative">
|
||||
<AppHeader showBack={showBack} onBack={onBack} />
|
||||
{action === "history" && onActionClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onActionClick}
|
||||
className="absolute right-[64px] top-3 h-10 px-3 rounded-full text-white text-xs font-bold border-[0.5px] border-transparent z-30"
|
||||
style={headerActionStyle}
|
||||
aria-label="تاریخچه چت"
|
||||
>
|
||||
تاریخچه
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ const PERSIAN_NUMBERS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "
|
|||
const ARABIC_NUMBERS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"] as const;
|
||||
const CODE_LENGTH = 5;
|
||||
|
||||
const loadingDotTransition = {
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror" as const,
|
||||
ease: "easeInOut" as const,
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -385,6 +392,7 @@ export function LoginPage() {
|
|||
id="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoFocus={step === "phone"}
|
||||
value={toPersianNumber(phoneNumber)}
|
||||
onChange={(e) => setPhoneNumber(normalizeNumber(e.target.value))}
|
||||
className="w-full bg-transparent text-left text-lg text-white outline-none placeholder:text-lg placeholder:text-white/45"
|
||||
|
|
@ -447,6 +455,7 @@ export function LoginPage() {
|
|||
}}
|
||||
className="h-13 w-13 rounded-xl border border-[#d680ff66] bg-[#2f1b59]/85 text-center text-2xl text-white outline-none"
|
||||
ref={index === 0 ? firstInputRef : undefined}
|
||||
autoFocus={step === "code" && index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -478,7 +487,38 @@ export function LoginPage() {
|
|||
boxShadow: "0 10px 26px rgba(196, 87, 255, 0.35), inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
}}
|
||||
>
|
||||
{isLoading ? "در حال پردازش..." : step === "phone" ? "دریافت کد تایید" : "تایید و ورود"}
|
||||
{isLoading ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>در حال پردازش</span>
|
||||
<span className="inline-flex items-center" dir="ltr" aria-hidden="true">
|
||||
<motion.span
|
||||
className="inline-block"
|
||||
animate={{ opacity: [0.25, 1], y: [0, -2] }}
|
||||
transition={{ ...loadingDotTransition, delay: 0 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="inline-block"
|
||||
animate={{ opacity: [0.25, 1], y: [0, -2] }}
|
||||
transition={{ ...loadingDotTransition, delay: 0.15 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="inline-block"
|
||||
animate={{ opacity: [0.25, 1], y: [0, -2] }}
|
||||
transition={{ ...loadingDotTransition, delay: 0.3 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
</span>
|
||||
</span>
|
||||
) : step === "phone" ? (
|
||||
"دریافت کد تایید"
|
||||
) : (
|
||||
"تایید و ورود"
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import { ChatHeader } from "./public-chat/ChatHeader";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ChatMessages, ChatMessage } from "./public-chat/ChatMessages";
|
||||
import { ChatInput } from "./public-chat/ChatInput";
|
||||
import { ChatInputBar } from "./chatbot/ChatInputBar";
|
||||
import { ChatHistoryModal } from "./public-chat/ChatHistoryModal";
|
||||
import { AppBackground } from "./shared/AppBackground";
|
||||
import {
|
||||
loadChatList,
|
||||
loadChat,
|
||||
|
|
@ -14,14 +10,13 @@ import {
|
|||
PublicChatMessage,
|
||||
} from "../../services/publicChatService";
|
||||
import { usePageTracking } from "../../hooks/usePageTracking";
|
||||
import { backgroundImages } from "../../config/backgroundConfig";
|
||||
import { toPersianDigits } from "../../utils/persianNumberUtils";
|
||||
import chatbotAvatarIcon from "../../assets/chatbot-bot-avatar.png";
|
||||
|
||||
export function PublicChatPage() {
|
||||
const navigate = useNavigate();
|
||||
usePageTracking("چت عمومی");
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [showChatHistory, setShowChatHistory] = useState(false);
|
||||
const [historyItems, setHistoryItems] = useState<ChatListItem[]>([]);
|
||||
const [currentChatWorkflowID, setCurrentChatWorkflowID] = useState<string>("");
|
||||
|
|
@ -31,7 +26,6 @@ export function PublicChatPage() {
|
|||
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const isNearBottom = () => {
|
||||
const container = messagesContainerRef.current;
|
||||
|
|
@ -78,7 +72,7 @@ export function PublicChatPage() {
|
|||
id: crypto.randomUUID(),
|
||||
type: "user",
|
||||
content: msg.question,
|
||||
timestamp: msg.datetime1,
|
||||
timestamp: toPersianDigits(msg.datetime1),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +82,7 @@ export function PublicChatPage() {
|
|||
type: "other",
|
||||
content: msg.answer,
|
||||
author: "ربات",
|
||||
timestamp: msg.datetime1,
|
||||
timestamp: toPersianDigits(msg.datetime1),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -120,8 +114,8 @@ export function PublicChatPage() {
|
|||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const trimmedText = inputText.trim();
|
||||
const handleSendMessage = async (message: string) => {
|
||||
const trimmedText = message.trim();
|
||||
if (!trimmedText || isSending) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
|
|
@ -145,44 +139,43 @@ export function PublicChatPage() {
|
|||
|
||||
setShouldAutoScroll(true);
|
||||
setMessages((prev) => [...prev, userMessage, loadingMessage]);
|
||||
setInputText("");
|
||||
setIsSending(true);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
}
|
||||
try {
|
||||
const result = await sendPublicChatMessage(trimmedText, currentChatWorkflowID);
|
||||
|
||||
const result = await sendPublicChatMessage(trimmedText, currentChatWorkflowID);
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
|
||||
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
|
||||
if (result.success && result.answer) {
|
||||
if (result.newChatlistWorkflowID) {
|
||||
setCurrentChatWorkflowID(result.newChatlistWorkflowID);
|
||||
}
|
||||
|
||||
if (result.success && result.answer) {
|
||||
if (result.newChatlistWorkflowID) {
|
||||
setCurrentChatWorkflowID(result.newChatlistWorkflowID);
|
||||
const botMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
type: "other",
|
||||
content: result.answer,
|
||||
author: "ربات",
|
||||
timestamp: new Date().toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
isTyping: true,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} else {
|
||||
alert(result.message || "خطا در ارسال پیام");
|
||||
}
|
||||
|
||||
const botMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
type: "other",
|
||||
content: result.answer,
|
||||
author: "ربات",
|
||||
timestamp: new Date().toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
isTyping: true,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} else {
|
||||
alert(result.message || "خطا در ارسال پیام");
|
||||
} catch (error) {
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
|
||||
alert("خطا در ارسال پیام");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
|
||||
setIsSending(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleHistoryClick = async () => {
|
||||
const handleHistoryClick = useCallback(async () => {
|
||||
setShowChatHistory(true);
|
||||
|
||||
const result = await loadChatList();
|
||||
|
|
@ -193,52 +186,60 @@ export function PublicChatPage() {
|
|||
console.error("Failed to load chat list:", result.message);
|
||||
alert(result.message || "خطا در بارگذاری تاریخچه");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewChat = () => {
|
||||
setMessages([]);
|
||||
setCurrentChatWorkflowID("");
|
||||
setShouldAutoScroll(true);
|
||||
useEffect(() => {
|
||||
const onHistoryRequest = () => {
|
||||
void handleHistoryClick();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
};
|
||||
window.addEventListener("public-chat:history", onHistoryRequest);
|
||||
return () => window.removeEventListener("public-chat:history", onHistoryRequest);
|
||||
}, [handleHistoryClick]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[100dvh] w-full overflow-hidden bg-black">
|
||||
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.publicChat} />
|
||||
|
||||
<div className="relative z-10 mx-auto grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<div className="shrink-0">
|
||||
<ChatHeader onBack={() => navigate("/")} />
|
||||
</div>
|
||||
|
||||
<div className="relative h-full min-h-0 overflow-hidden">
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto]">
|
||||
<main className="relative min-h-0 overflow-hidden">
|
||||
<button
|
||||
onClick={handleHistoryClick}
|
||||
className="absolute left-4 top-3 z-20 px-3 py-1.5 rounded-full text-xs text-white font-bold"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(138, 206, 224, 0.9) 0%, rgba(76, 127, 137, 0.9) 100%)",
|
||||
border: "1px solid rgba(208, 240, 255, 0.6)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
>
|
||||
تاریخچه
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center px-5">
|
||||
<p className="text-sm text-white">در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-5">
|
||||
<div className="max-w-sm rounded-2xl border-2 border-purple-400/50 bg-gradient-to-br from-purple-500/20 to-pink-500/20 p-5 shadow-2xl backdrop-blur-md">
|
||||
<div
|
||||
className="max-w-sm rounded-[22px] border-[0.5px] border-transparent px-5 py-4 text-center"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-3xl">🤖✨</div>
|
||||
<p className="mb-1.5 text-base font-bold text-white">
|
||||
<div
|
||||
className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full border border-[#F0A6D8]/45"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 50% 36%, rgba(255, 187, 232, 0.38) 0%, rgba(134, 74, 164, 0.28) 48%, rgba(26, 18, 54, 0.7) 100%)",
|
||||
boxShadow:
|
||||
"0 0 14px rgba(255,104,205,0.48), 0 0 28px rgba(255,104,205,0.22), inset 0 1px 0 rgba(255,255,255,0.22)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={chatbotAvatarIcon}
|
||||
alt="چتبات"
|
||||
className="h-11 w-11 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-1.5 text-base font-bold text-[#FBE7F5]">
|
||||
با ربات همدست چت کن!
|
||||
</p>
|
||||
<p className="text-xs text-purple-200">
|
||||
<p className="text-xs text-[#EED3EC]/90">
|
||||
سوالاتت رو بپرس و جواب بگیر
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -261,23 +262,13 @@ export function PublicChatPage() {
|
|||
</main>
|
||||
|
||||
<footer
|
||||
className="shrink-0 border-white/10 "
|
||||
className="shrink-0"
|
||||
style={{
|
||||
paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 12px)",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 pt-2">
|
||||
<ChatInput
|
||||
inputText={inputText}
|
||||
onInputChange={setInputText}
|
||||
onSend={handleSendMessage}
|
||||
onNewChat={handleNewChat}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pt-6">
|
||||
<BottomNav />
|
||||
<ChatInputBar onSendMessage={handleSendMessage} disabled={isSending} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
@ -288,7 +279,7 @@ export function PublicChatPage() {
|
|||
historyItems={historyItems.map((item) => ({
|
||||
id: item.chatlist_workflowID,
|
||||
title: item.title || "چت عمومی",
|
||||
date: item.datetime1,
|
||||
date: toPersianDigits(item.datetime1),
|
||||
lastMessage: "",
|
||||
}))}
|
||||
onSelectChat={handleSelectChat}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, useLayoutEffect } from "react";
|
||||
import { useMagicBag } from "../context/MagicBagContext";
|
||||
import { RewardModal } from "./RewardModal";
|
||||
import { getTopicConfig } from "../../config/topicConfig";
|
||||
import { usePageTracking } from "../../hooks/usePageTracking";
|
||||
import { FeedHeader } from "./feed/FeedHeader";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
import { useInbox } from "../context/InboxContext";
|
||||
import { AppBackground } from "./shared/AppBackground";
|
||||
import { backgroundImages } from "../../config/backgroundConfig";
|
||||
|
|
@ -20,9 +20,21 @@ export function SubmitChallengePage() {
|
|||
const { addNewItem } = useMagicBag();
|
||||
const { refreshInbox } = useInbox();
|
||||
const [showRewardModal, setShowRewardModal] = useState(false);
|
||||
const headerWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(172);
|
||||
|
||||
// دریافت doingMission از location state
|
||||
const doingMission = (location.state as any)?.doingMission;
|
||||
const selectedMissionTitleFromState = (location.state as any)?.selectedMissionTitle;
|
||||
|
||||
const selectedChallengeTitle = useMemo(() => {
|
||||
return (
|
||||
selectedMissionTitleFromState ||
|
||||
localStorage.getItem("current_mission_title") ||
|
||||
doingMission?.title ||
|
||||
"چالش انتخابشده"
|
||||
);
|
||||
}, [selectedMissionTitleFromState, doingMission]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
|
|
@ -47,27 +59,72 @@ export function SubmitChallengePage() {
|
|||
// Dynamically render the form component from config
|
||||
const FormComponent = topicConfig.formComponent;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const measureHeader = () => {
|
||||
const measuredHeight = headerWrapperRef.current?.getBoundingClientRect().height ?? 0;
|
||||
if (measuredHeight > 0) {
|
||||
setHeaderHeight(Math.ceil(measuredHeight));
|
||||
}
|
||||
};
|
||||
|
||||
measureHeader();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
measureHeader();
|
||||
});
|
||||
|
||||
if (headerWrapperRef.current) {
|
||||
observer.observe(headerWrapperRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", measureHeader);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measureHeader);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full relative overflow-hidden">
|
||||
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.submitChallenge} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<FeedHeader topicTitle={`ثبت چالش ${topicConfig.title}`} onBack={handleBack} />
|
||||
<div ref={headerWrapperRef}>
|
||||
{/* Header */}
|
||||
<AppHeader showBack onBack={handleBack} />
|
||||
<div className="px-4 pb-2 text-center">
|
||||
<h2
|
||||
className="mt-1 text-[20px] font-extrabold leading-8"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background:
|
||||
"linear-gradient(90deg, #F6D8A5 0%, #F3A599 20%, #DB7EB2 48%, #AA6798 72%, #CB75AB 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
color: "transparent",
|
||||
textShadow: "0 2px 10px rgba(255, 119, 202, 0.4)",
|
||||
}}
|
||||
>
|
||||
{`ثبت چالش ${selectedChallengeTitle}`}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Scrollable */}
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 max-w-md mx-auto overflow-hidden"
|
||||
style={{ paddingTop: "110px", zIndex: 1 }}
|
||||
style={{ paddingTop: `${headerHeight}px`, zIndex: 1 }}
|
||||
>
|
||||
<div
|
||||
className="h-full overflow-y-auto relative px-[24px] pt-[48px] pb-[132px]"
|
||||
className="h-full overflow-y-auto relative px-[24px] pt-[2px] pb-[132px]"
|
||||
style={{
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 60px)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 60px)",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 4px)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 4px)",
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Form Component */}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,16 @@ export function ChatDateGroup({ date }: ChatDateGroupProps) {
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="px-4 py-2 rounded-full text-xs text-white font-bold backdrop-blur-md"
|
||||
className="rounded-full px-4 py-1.5 text-[11px] font-bold text-[#F7D8EF] backdrop-blur-md"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.85) 0%, rgba(32, 76, 106, 0.85) 100%)",
|
||||
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(138, 206, 224, 0.2)",
|
||||
border: "1px solid rgba(138, 206, 224, 0.3)",
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(35, 28, 69, 0.76) 0%, rgba(25, 22, 55, 0.72) 100%)",
|
||||
boxShadow:
|
||||
"0 0 18px rgba(203,117,171,0.22), 0 8px 18px rgba(8,6,28,0.28), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(198, 111, 177, 0.36)",
|
||||
}}
|
||||
>
|
||||
{date}
|
||||
{`• ${date} •`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
|
|
@ -27,11 +27,13 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
|
|||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
};
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
|
|
@ -47,11 +49,17 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
|
|||
return (
|
||||
<div className="px-3">
|
||||
<div
|
||||
className="rounded-3xl p-2.5 flex items-center gap-1.5"
|
||||
className="flex min-h-[62px] items-center gap-2 rounded-[32px] p-2"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.95) 0%, rgba(32, 76, 106, 0.95) 100%)",
|
||||
boxShadow: "0 -3px 16px rgba(0, 0, 0, 0.3), 0 3px 12px rgba(138, 206, 224, 0.2)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.3)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, #2E1B3D 0%, #23183E 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.5), 0 6px 14px rgba(5, 2, 12, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 2px 5px rgba(255, 222, 255, 0.09), inset 0 -2px 0 rgba(12, 7, 27, 0.72), inset 0 -8px 14px rgba(8, 4, 18, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.045), inset 0 0 0 2px rgba(17, 10, 35, 0.32)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
{/* Text Input */}
|
||||
|
|
@ -65,10 +73,12 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
|
|||
dir="rtl"
|
||||
disabled={disabled}
|
||||
aria-label="پیام خود را بنویسید"
|
||||
className="chat-input-textarea flex-1 bg-transparent text-white placeholder-white/50 resize-none outline-none text-right disabled:opacity-50"
|
||||
className="chat-input-textarea flex-1 bg-transparent text-white placeholder:text-[rgba(207,168,212,0.7)] resize-none outline-none text-right disabled:opacity-50"
|
||||
style={{
|
||||
fontFamily: "Alibaba, sans-serif",
|
||||
textAlign: "right",
|
||||
minHeight: "36px",
|
||||
maxHeight: "96px",
|
||||
lineHeight: "1.4",
|
||||
overflow: "hidden",
|
||||
fontSize: "16px", // حداقل 16px برای جلوگیری از zoom در iOS Safari
|
||||
|
|
@ -77,19 +87,23 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
|
|||
|
||||
{/* Send Button */}
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
aria-label="ارسال پیام"
|
||||
className="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center"
|
||||
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: canSend
|
||||
? "linear-gradient(135deg, #FFB800 0%, #FF9500 100%)"
|
||||
: "rgba(255, 255, 255, 0.1)",
|
||||
boxShadow: canSend ? "0 3px 10px rgba(255, 165, 0, 0.4)" : "none",
|
||||
? "linear-gradient(145deg, #F06EA8 0%, #C9579C 52%, #8A4FCF 100%)"
|
||||
: "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)",
|
||||
boxShadow: canSend
|
||||
? "0 0 18px rgba(240, 110, 168, 0.42), inset 0 1px 0 rgba(255,255,255,0.22)"
|
||||
: "inset 0 1px 0 rgba(255,255,255,0.12), 0 0 12px rgba(203,117,171,0.12)",
|
||||
border: canSend ? "1px solid rgba(255, 189, 228, 0.5)" : "1px solid rgba(198, 111, 177, 0.22)",
|
||||
}}
|
||||
>
|
||||
<Send className={`w-4 h-4 ${canSend ? "text-white" : "text-white/30"}`} />
|
||||
<Send className={`h-5 w-5 ${canSend ? "text-white" : "text-[#CFA8D4]/70"}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
|
|
@ -105,4 +119,4 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
|
|||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ChatFlowMessage } from "../../../hooks/useChatFlow";
|
||||
import { extractTime, formatTimestamp } from "../../../utils/chatDateUtils";
|
||||
import { extractTime } from "../../../utils/chatDateUtils";
|
||||
import { AudioPlayer } from "../AudioPlayer";
|
||||
import { VideoPlayer } from "../VideoPlayer";
|
||||
import chatbotAvatarIcon from "figma:asset/c11973053d8410ffeb3c76aa4d1da6991076e7e1.png";
|
||||
import { EmojiText } from "./EmojiText";
|
||||
import chatbotAvatarIcon from "../../../assets/chatbot-bot-avatar.png";
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatFlowMessage;
|
||||
|
|
@ -12,9 +15,159 @@ interface ChatMessageItemProps {
|
|||
onButtonClick: (buttonId: string, action: string) => void;
|
||||
}
|
||||
|
||||
// استایل پیام کاربر باید مستقل از دکمه ارسال و پیام بات بماند.
|
||||
const USER_BUBBLE_STYLE = {
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(218, 94, 142, 0.96) 0%, rgba(162, 56, 110, 0.95) 100%)",
|
||||
boxShadow:
|
||||
"0 0 24px rgba(240,110,168,0.28), 0 12px 28px rgba(84, 22, 60, 0.38), inset 0 1px 0 rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255, 178, 214, 0.58)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
} as const;
|
||||
|
||||
const BOT_BUBBLE_STYLE = {
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(52, 34, 76, 0.94) 0%, rgba(35, 24, 62, 0.94) 100%)",
|
||||
backgroundImage:
|
||||
"linear-gradient(145deg, rgba(52, 34, 76, 0.94) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow:
|
||||
"0 0 24px rgba(152,104,235,0.24), 0 12px 28px rgba(12, 8, 30, 0.4), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "0.5px solid transparent",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
} as const;
|
||||
|
||||
const USER_MEDIA_BUBBLE_STYLE = {
|
||||
background: USER_BUBBLE_STYLE.background,
|
||||
boxShadow: "0 12px 28px rgba(84, 22, 60, 0.38)",
|
||||
border: USER_BUBBLE_STYLE.border,
|
||||
} as const;
|
||||
|
||||
const BOT_MEDIA_BUBBLE_STYLE = {
|
||||
background: BOT_BUBBLE_STYLE.background,
|
||||
boxShadow: "0 12px 28px rgba(12, 8, 30, 0.4)",
|
||||
border: "1px solid rgba(186, 145, 235, 0.4)",
|
||||
} as const;
|
||||
|
||||
const USER_FILE_BUBBLE_STYLE = {
|
||||
background: USER_BUBBLE_STYLE.background,
|
||||
boxShadow:
|
||||
"0 12px 28px rgba(84, 22, 60, 0.38), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: USER_BUBBLE_STYLE.border,
|
||||
} as const;
|
||||
|
||||
const BOT_FILE_BUBBLE_STYLE = {
|
||||
background: BOT_BUBBLE_STYLE.background,
|
||||
boxShadow:
|
||||
"0 12px 28px rgba(12, 8, 30, 0.4), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(186, 145, 235, 0.4)",
|
||||
} as const;
|
||||
|
||||
const chatToastStyle = {
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.95) 0%, rgba(35, 24, 62, 0.97) 100%), linear-gradient(120deg, rgba(124, 58, 237, 0.5) 0%, rgba(249, 115, 22, 0.32) 58%, rgba(250, 204, 21, 0.25) 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "1px solid transparent",
|
||||
boxShadow:
|
||||
"0 14px 34px rgba(10, 5, 24, 0.42), 0 0 22px rgba(255, 121, 207, 0.18), inset 0 1px 0 rgba(255,255,255,0.16)",
|
||||
color: "#FBE7F5",
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
width: "fit-content",
|
||||
minWidth: "unset",
|
||||
maxWidth: "max-content",
|
||||
whiteSpace: "nowrap",
|
||||
margin: "0 auto",
|
||||
} as const;
|
||||
|
||||
export function ChatMessageItem({ message, animationDelay, onButtonClick }: ChatMessageItemProps) {
|
||||
const isBot = message.type === "bot";
|
||||
const isUser = message.type === "user";
|
||||
const isBot = !isUser;
|
||||
const timeDisplay = extractTime(message.datetime1, message.timestamp);
|
||||
const textBubbleStyle = isUser ? USER_BUBBLE_STYLE : BOT_BUBBLE_STYLE;
|
||||
const mediaBubbleStyle = isUser ? USER_MEDIA_BUBBLE_STYLE : BOT_MEDIA_BUBBLE_STYLE;
|
||||
const fileBubbleStyle = isUser ? USER_FILE_BUBBLE_STYLE : BOT_FILE_BUBBLE_STYLE;
|
||||
const pressStartRef = useRef<number>(0);
|
||||
const copyableText =
|
||||
message.mediaType === "file"
|
||||
? message.mediaUrl || ""
|
||||
: message.content?.trim() || "";
|
||||
|
||||
const copyWithFallback = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
};
|
||||
|
||||
const copyMessageToClipboard = async () => {
|
||||
if (!copyableText) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(copyableText);
|
||||
} else if (!copyWithFallback(copyableText)) {
|
||||
throw new Error("fallback copy failed");
|
||||
}
|
||||
|
||||
toast.success("پیام کپی شد", {
|
||||
position: "bottom-center",
|
||||
style: chatToastStyle,
|
||||
});
|
||||
} catch {
|
||||
if (copyWithFallback(copyableText)) {
|
||||
toast.success("پیام کپی شد", {
|
||||
position: "bottom-center",
|
||||
style: chatToastStyle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("کپی پیام انجام نشد", {
|
||||
position: "bottom-center",
|
||||
style: chatToastStyle,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startLongPressCopy = () => {
|
||||
if (!copyableText) return;
|
||||
pressStartRef.current = Date.now();
|
||||
};
|
||||
|
||||
const stopLongPressCopy = () => {
|
||||
if (!copyableText) return;
|
||||
const pressDuration = Date.now() - pressStartRef.current;
|
||||
pressStartRef.current = 0;
|
||||
if (pressDuration >= 450) {
|
||||
void copyMessageToClipboard();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLongPressCopy = () => {
|
||||
pressStartRef.current = 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -28,18 +181,47 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
|
|||
{/* Text Message */}
|
||||
{message.mediaType === "text" && (
|
||||
<div
|
||||
className="rounded-3xl px-5 py-3"
|
||||
style={{
|
||||
background: isBot
|
||||
? "linear-gradient(135deg, rgba(100, 200, 255, 0.9) 0%, rgba(50, 150, 220, 0.9) 100%)"
|
||||
: "linear-gradient(135deg, rgba(150, 100, 200, 0.85) 0%, rgba(100, 60, 150, 0.85) 100%)",
|
||||
boxShadow: isBot
|
||||
? "0 4px 16px rgba(100, 200, 255, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.3)"
|
||||
: "0 4px 16px rgba(150, 100, 200, 0.4), inset 0 1px 2px rgba(200, 150, 255, 0.3)",
|
||||
border: isBot ? "1.5px solid rgba(150, 220, 255, 0.4)" : "1.5px solid rgba(180, 130, 230, 0.4)",
|
||||
className="relative rounded-[18px] px-5 py-3"
|
||||
style={textBubbleStyle}
|
||||
onPointerDown={startLongPressCopy}
|
||||
onPointerUp={stopLongPressCopy}
|
||||
onPointerLeave={cancelLongPressCopy}
|
||||
onPointerCancel={cancelLongPressCopy}
|
||||
onContextMenu={(event) => {
|
||||
if (!copyableText) return;
|
||||
event.preventDefault();
|
||||
void copyMessageToClipboard();
|
||||
}}
|
||||
>
|
||||
<p className="text-white text-sm leading-relaxed whitespace-pre-line">{message.content}</p>
|
||||
{isBot && message.isTyping && !message.content ? (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<motion.span
|
||||
className="h-2 w-2 rounded-full bg-white/70"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, delay: 0 }}
|
||||
/>
|
||||
<motion.span
|
||||
className="h-2 w-2 rounded-full bg-white/70"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, delay: 0.2 }}
|
||||
/>
|
||||
<motion.span
|
||||
className="h-2 w-2 rounded-full bg-white/70"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, delay: 0.4 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white text-sm leading-relaxed whitespace-pre-line">
|
||||
<EmojiText text={message.content} />
|
||||
{isBot && message.isTyping && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-white/70"
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -47,10 +229,15 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
|
|||
{message.mediaType === "image" && message.content && (
|
||||
<div
|
||||
className="rounded-2xl p-3"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(100, 200, 255, 0.9) 0%, rgba(50, 150, 220, 0.9) 100%)",
|
||||
border: "1.5px solid rgba(150, 220, 255, 0.4)",
|
||||
boxShadow: "0 4px 16px rgba(100, 200, 255, 0.4)",
|
||||
style={mediaBubbleStyle}
|
||||
onPointerDown={startLongPressCopy}
|
||||
onPointerUp={stopLongPressCopy}
|
||||
onPointerLeave={cancelLongPressCopy}
|
||||
onPointerCancel={cancelLongPressCopy}
|
||||
onContextMenu={(event) => {
|
||||
if (!copyableText) return;
|
||||
event.preventDefault();
|
||||
void copyMessageToClipboard();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -78,10 +265,15 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
|
|||
{message.mediaType === "file" && message.mediaUrl && (
|
||||
<div
|
||||
className="rounded-3xl px-5 py-4 flex items-center gap-3"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(100, 200, 255, 0.9) 0%, rgba(50, 150, 220, 0.9) 100%)",
|
||||
boxShadow: "0 4px 16px rgba(100, 200, 255, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.3)",
|
||||
border: "1.5px solid rgba(150, 220, 255, 0.4)",
|
||||
style={fileBubbleStyle}
|
||||
onPointerDown={startLongPressCopy}
|
||||
onPointerUp={stopLongPressCopy}
|
||||
onPointerLeave={cancelLongPressCopy}
|
||||
onPointerCancel={cancelLongPressCopy}
|
||||
onContextMenu={(event) => {
|
||||
if (!copyableText) return;
|
||||
event.preventDefault();
|
||||
void copyMessageToClipboard();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -117,15 +309,20 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
|
|||
key={button.id}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => onButtonClick(button.id, button.action)}
|
||||
className="w-full px-4 py-3 rounded-2xl text-white text-sm font-bold text-center transition-all"
|
||||
className="w-full rounded-[18px] px-4 py-3 text-center text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(255, 183, 0, 0.9) 0%, rgba(255, 140, 0, 0.9) 100%)",
|
||||
boxShadow: "0 4px 16px rgba(255, 183, 0, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.3)",
|
||||
border: "1.5px solid rgba(255, 200, 50, 0.5)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(174, 117, 255, 0.96) 0%, rgba(138, 82, 238, 0.95) 46%, rgba(102, 55, 204, 0.94) 100%)",
|
||||
boxShadow:
|
||||
"0 0 18px rgba(155,108,241,0.4), 0 10px 20px rgba(24, 10, 54, 0.34), inset 0 1px 0 rgba(255,255,255,0.28)",
|
||||
border: "1px solid rgba(212, 184, 255, 0.62)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
aria-label={button.label}
|
||||
>
|
||||
{button.label}
|
||||
<EmojiText text={button.label} />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -140,14 +337,21 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
|
|||
{/* Bot Avatar */}
|
||||
{isBot && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={chatbotAvatarIcon}
|
||||
alt="چتبات"
|
||||
className="w-12 h-12 rounded-full object-contain"
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full border border-[#F0A6D8]/45"
|
||||
style={{
|
||||
filter: "drop-shadow(0 2px 8px rgba(138, 206, 224, 0.5))",
|
||||
background:
|
||||
"radial-gradient(circle at 50% 36%, rgba(255, 187, 232, 0.38) 0%, rgba(134, 74, 164, 0.28) 48%, rgba(26, 18, 54, 0.7) 100%)",
|
||||
boxShadow:
|
||||
"0 0 14px rgba(255,104,205,0.48), 0 0 28px rgba(255,104,205,0.22), inset 0 1px 0 rgba(255,255,255,0.22)",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={chatbotAvatarIcon}
|
||||
alt="چتبات"
|
||||
className="h-[43px] w-[43px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const ChatMessageList = forwardRef<ChatMessageListRef, ChatMessageListPro
|
|||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages.length, isTyping, typingText]);
|
||||
}, [messages, isTyping, typingText]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" dir="rtl">
|
||||
|
|
@ -89,4 +89,4 @@ export const ChatMessageList = forwardRef<ChatMessageListRef, ChatMessageListPro
|
|||
}
|
||||
);
|
||||
|
||||
ChatMessageList.displayName = "ChatMessageList";
|
||||
ChatMessageList.displayName = "ChatMessageList";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { motion, AnimatePresence } from "motion/react";
|
||||
import chatbotAvatarIcon from "figma:asset/c11973053d8410ffeb3c76aa4d1da6991076e7e1.png";
|
||||
import chatbotAvatarIcon from "../../../assets/chatbot-bot-avatar.png";
|
||||
|
||||
interface ChatTypingIndicatorProps {
|
||||
isTyping: boolean;
|
||||
|
|
@ -8,9 +8,9 @@ interface ChatTypingIndicatorProps {
|
|||
|
||||
// Shared bubble styles matching bot messages
|
||||
const bubbleBaseStyle = {
|
||||
background: "linear-gradient(135deg, rgba(100, 200, 255, 0.9) 0%, rgba(50, 150, 220, 0.9) 100%)",
|
||||
boxShadow: "0 4px 16px rgba(100, 200, 255, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.3)",
|
||||
border: "1.5px solid rgba(150, 220, 255, 0.4)",
|
||||
background: "linear-gradient(145deg, rgba(52, 34, 76, 0.94) 0%, rgba(35, 24, 62, 0.94) 100%)",
|
||||
boxShadow: "0 0 24px rgba(152,104,235,0.24), 0 12px 28px rgba(12, 8, 30, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||
border: "1px solid rgba(186, 145, 235, 0.4)",
|
||||
};
|
||||
|
||||
export function ChatTypingIndicator({ isTyping, typingText }: ChatTypingIndicatorProps) {
|
||||
|
|
|
|||
54
src/app/components/chatbot/EmojiText.tsx
Normal file
54
src/app/components/chatbot/EmojiText.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getFluentEmojiCDN } from "@lobehub/fluent-emoji/es/getFluentEmojiCDN";
|
||||
|
||||
const emojiRegex =
|
||||
/(\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*)/gu;
|
||||
|
||||
interface EmojiTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function FluentEmojiImage({ emoji }: { emoji: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="mx-0.5 inline-block translate-y-[1px] text-[0.95em] leading-none">
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={getFluentEmojiCDN(emoji, { cdn: "unpkg", type: "3d" })}
|
||||
alt={emoji}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="mx-0.5 inline-block h-[1.45em] w-[1.45em] translate-y-[0.32em] object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderEmojiText(text: string): ReactNode {
|
||||
const parts = text.split(emojiRegex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (!part) return null;
|
||||
|
||||
if (emojiRegex.test(part)) {
|
||||
emojiRegex.lastIndex = 0;
|
||||
|
||||
return <FluentEmojiImage key={`${part}-${index}`} emoji={part} />;
|
||||
}
|
||||
|
||||
emojiRegex.lastIndex = 0;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export function EmojiText({ text }: EmojiTextProps) {
|
||||
return <>{renderEmojiText(text)}</>;
|
||||
}
|
||||
|
|
@ -1,13 +1,38 @@
|
|||
import { AppHeader } from "../AppHeader";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onBack: () => void;
|
||||
onHistoryClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({ onBack }: ChatHeaderProps) {
|
||||
const historyButtonStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(180deg, #2E1B3D 0%, #23183E 100%),
|
||||
linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)
|
||||
`,
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.5), 0 6px 14px rgba(5, 2, 12, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 2px 5px rgba(255, 222, 255, 0.09), inset 0 -2px 0 rgba(12, 7, 27, 0.72), inset 0 -8px 14px rgba(8, 4, 18, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.045), inset 0 0 0 2px rgba(17, 10, 35, 0.32)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
} as const;
|
||||
|
||||
export function ChatHeader({ onHistoryClick }: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="flex-shrink-0">
|
||||
<AppHeader showBack onBack={onBack} />
|
||||
<div className="flex-shrink-0 relative">
|
||||
<AppHeader />
|
||||
<div className="px-4 -mt-1 pb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHistoryClick}
|
||||
className="h-10 px-3 rounded-full text-white text-xs font-bold border-[0.5px] border-transparent"
|
||||
style={historyButtonStyle}
|
||||
aria-label="تاریخچه چت"
|
||||
>
|
||||
تاریخچه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,19 +12,26 @@ interface ChatInputProps {
|
|||
|
||||
const inputStyles = {
|
||||
container: {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(32, 76, 106, 0.9) 0%, rgba(20, 40, 60, 0.9) 100%)",
|
||||
border: "2px solid rgba(138, 206, 224, 0.3)",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, #2E1B3D 0%, #23183E 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow: "0 -7px 20px rgba(7, 0, 18, 0.5), 0 6px 14px rgba(5, 2, 12, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 2px 5px rgba(255, 222, 255, 0.09), inset 0 -2px 0 rgba(12, 7, 27, 0.72), inset 0 -8px 14px rgba(8, 4, 18, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.045), inset 0 0 0 2px rgba(17, 10, 35, 0.32)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
},
|
||||
sendButtonActive: {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 152, 0, 0.95) 100%)",
|
||||
boxShadow: "0 4px 12px rgba(255, 193, 7, 0.4)",
|
||||
"linear-gradient(145deg, #F06EA8 0%, #C9579C 52%, #8A4FCF 100%)",
|
||||
boxShadow: "0 0 18px rgba(240, 110, 168, 0.42), inset 0 1px 0 rgba(255,255,255,0.22)",
|
||||
border: "1px solid rgba(255, 189, 228, 0.5)",
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(96, 147, 157, 0.3) 0%, rgba(76, 127, 137, 0.3) 100%)",
|
||||
"linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)",
|
||||
border: "1px solid rgba(198, 111, 177, 0.22)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.12), 0 0 12px rgba(203,117,171,0.12)",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -35,7 +42,7 @@ export function ChatInput({
|
|||
onNewChat,
|
||||
inputRef,
|
||||
}: ChatInputProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isSelectAll =
|
||||
(e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a";
|
||||
|
||||
|
|
@ -49,10 +56,6 @@ export function ChatInput({
|
|||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
|
@ -66,22 +69,23 @@ export function ChatInput({
|
|||
const isDisabled = !inputText.trim();
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 px-3 mb-16 pb-[env(safe-area-inset-bottom)]">
|
||||
<div className="flex-shrink-0 px-3 pb-[env(safe-area-inset-bottom)]">
|
||||
<div
|
||||
className="flex items-end gap-2 p-2.5 rounded-2xl"
|
||||
className="flex min-h-[62px] items-center gap-2 rounded-[32px] p-2"
|
||||
style={inputStyles.container}
|
||||
>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={onNewChat}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(138, 206, 224, 0.9) 0%, rgba(76, 127, 137, 0.9) 100%)",
|
||||
boxShadow: "0 3px 10px rgba(138, 206, 224, 0.4)",
|
||||
"linear-gradient(145deg, rgba(60, 48, 93, 0.92) 0%, rgba(39, 32, 72, 0.92) 100%)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(198, 111, 177, 0.28)",
|
||||
}}
|
||||
>
|
||||
<FilePlus className="w-4 h-4" style={{ color: "#FFFFFF" }} />
|
||||
<FilePlus className="w-5 h-5" style={{ color: "#FFFFFF" }} />
|
||||
</motion.button>
|
||||
|
||||
<textarea
|
||||
|
|
@ -91,15 +95,14 @@ export function ChatInput({
|
|||
onKeyDown={handleKeyDown}
|
||||
placeholder="پیام خود را بنویسید"
|
||||
dir="auto"
|
||||
className="flex-1 bg-transparent text-white placeholder-white/50 resize-none outline-none"
|
||||
className="flex-1 bg-transparent text-white placeholder:text-[rgba(207,168,212,0.7)] resize-none outline-none text-right"
|
||||
style={{
|
||||
direction: "auto",
|
||||
textAlign: inputText ? "start" : "right",
|
||||
maxHeight: "90px",
|
||||
maxHeight: "96px",
|
||||
minHeight: "36px",
|
||||
lineHeight: "20px",
|
||||
paddingTop: "8px",
|
||||
paddingBottom: "8px",
|
||||
lineHeight: "1.4",
|
||||
overflow: "hidden",
|
||||
fontSize: "16px", // حداقل 16px برای جلوگیری از zoom در iOS Safari
|
||||
}}
|
||||
rows={1}
|
||||
|
|
@ -109,7 +112,7 @@ export function ChatInput({
|
|||
whileTap={{ scale: isDisabled ? 1 : 0.92 }}
|
||||
onClick={onSend}
|
||||
disabled={isDisabled}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
...(isDisabled
|
||||
? inputStyles.sendButtonDisabled
|
||||
|
|
@ -119,13 +122,13 @@ export function ChatInput({
|
|||
}}
|
||||
>
|
||||
<Send
|
||||
className="w-4 h-4"
|
||||
className="w-5 h-5"
|
||||
style={{
|
||||
color: isDisabled ? "rgba(255, 255, 255, 0.3)" : "#5A3800",
|
||||
color: isDisabled ? "rgba(207, 168, 212, 0.7)" : "#FFFFFF",
|
||||
}}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { motion } from "motion/react";
|
||||
import React from "react";
|
||||
import { TypingText } from "./TypingText";
|
||||
import { EmojiText } from "../chatbot/EmojiText";
|
||||
import chatbotAvatarIcon from "../../../assets/chatbot-bot-avatar.png";
|
||||
import { toPersianDigits } from "../../../utils/persianNumberUtils";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
|
|
@ -21,15 +24,24 @@ interface ChatMessagesProps {
|
|||
const messageStyles = {
|
||||
user: {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(138, 206, 224, 0.9) 0%, rgba(76, 127, 137, 0.9) 100%)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.5)",
|
||||
"linear-gradient(145deg, rgba(218, 94, 142, 0.96) 0%, rgba(162, 56, 110, 0.95) 100%)",
|
||||
boxShadow:
|
||||
"0 0 24px rgba(240,110,168,0.28), 0 12px 28px rgba(84, 22, 60, 0.38), inset 0 1px 0 rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255, 178, 214, 0.58)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
},
|
||||
other: {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(32, 76, 106, 0.8) 0%, rgba(20, 40, 60, 0.8) 100%)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.3)",
|
||||
"linear-gradient(145deg, rgba(52, 34, 76, 0.94) 0%, rgba(35, 24, 62, 0.94) 100%)",
|
||||
backgroundImage:
|
||||
"linear-gradient(145deg, rgba(52, 34, 76, 0.94) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: "0 0 24px rgba(152,104,235,0.24), 0 12px 28px rgba(12, 8, 30, 0.4), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "0.5px solid transparent",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -55,10 +67,10 @@ export function ChatMessages({
|
|||
key={message.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${isUser ? "justify-start" : "justify-end"}`}
|
||||
className={`flex ${isUser ? "justify-start" : "justify-end"} items-start gap-2`}
|
||||
>
|
||||
<div
|
||||
className="max-w-[75%] rounded-2xl px-4 py-3"
|
||||
className="relative max-w-[75%] rounded-[18px] px-4 py-3"
|
||||
style={isUser ? messageStyles.user : messageStyles.other}
|
||||
>
|
||||
{message.author && (
|
||||
|
|
@ -92,14 +104,30 @@ export function ChatMessages({
|
|||
<TypingText text={message.content} speed={30} onTyping={onTyping} />
|
||||
) : (
|
||||
<p className="text-white text-sm break-words whitespace-pre-wrap">
|
||||
{message.content}
|
||||
<EmojiText text={message.content} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-white/60 text-xs mt-1 text-left">
|
||||
{message.timestamp}
|
||||
{toPersianDigits(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
{!isUser && (
|
||||
<div
|
||||
className="-mr-1 mt-1 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(255,104,205,0.18) 0%, transparent 68%)",
|
||||
filter:
|
||||
"drop-shadow(0 0 8px rgba(255, 104, 205, 0.55)) drop-shadow(0 0 16px rgba(255, 104, 205, 0.28))",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={chatbotAvatarIcon}
|
||||
alt="چتبات"
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -108,4 +136,4 @@ export function ChatMessages({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { EmojiText } from "../chatbot/EmojiText";
|
||||
|
||||
interface TypingTextProps {
|
||||
text: string;
|
||||
|
|
@ -31,7 +32,7 @@ export function TypingText({ text, speed = 30, onTyping }: TypingTextProps) {
|
|||
|
||||
return (
|
||||
<p className="text-white text-sm break-words whitespace-pre-wrap">
|
||||
{displayedText}
|
||||
<EmojiText text={displayedText} />
|
||||
{currentIndex < text.length && (
|
||||
<span className="inline-block w-1 h-4 bg-white/70 ml-0.5 animate-pulse" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -173,11 +173,15 @@ export function ImageForm({ topicId, topicTitle, onSubmit }: SubmitFormProps) {
|
|||
disabled={isSubmitting}
|
||||
className="w-full py-4 rounded-full text-white text-base font-bold mb-6"
|
||||
style={{
|
||||
background: isSubmitting
|
||||
? "linear-gradient(135deg, rgba(150, 150, 150, 0.95) 0%, rgba(100, 100, 100, 0.95) 100%)"
|
||||
: "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)",
|
||||
boxShadow: "0 8px 24px rgba(255, 165, 0, 0.5)",
|
||||
border: "1.5px solid rgba(255, 200, 50, 0.5)",
|
||||
backgroundImage: isSubmitting
|
||||
? "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)"
|
||||
: "linear-gradient(90deg, rgba(255,139,91,1) 0%, rgba(238,91,166,1) 45%, rgba(147,78,255,1) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: isSubmitting
|
||||
? "inset 0 1px 0 rgba(255,255,255,0.12)"
|
||||
: "0 10px 26px rgba(196, 87, 255, 0.35), inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
border: "1px solid transparent",
|
||||
opacity: isSubmitting ? 0.7 : 1,
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
}}
|
||||
|
|
@ -192,17 +196,19 @@ export function ImageForm({ topicId, topicTitle, onSubmit }: SubmitFormProps) {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-4 p-4 rounded-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(135, 206, 250, 0.15) 0%, rgba(100, 149, 237, 0.15) 100%)",
|
||||
border: "2px solid rgba(135, 206, 250, 0.3)",
|
||||
backgroundImage: "linear-gradient(180deg, rgba(46, 27, 61, 0.88) 0%, rgba(35, 24, 62, 0.92) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-3 border-blue-400 border-t-transparent rounded-full"
|
||||
className="w-5 h-5 border-2 border-[#D8B4FE] border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="text-blue-600 font-bold text-sm">{uploadProgress}</span>
|
||||
<span className="text-[#FBE7F5] font-bold text-sm">{uploadProgress}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -204,12 +204,15 @@ export function ImageVideoForm({ topicId, topicTitle, onSubmit }: SubmitFormProp
|
|||
<div className="space-y-6" dir="rtl">
|
||||
{/* Media Type Tabs */}
|
||||
<div>
|
||||
<label className="block text-white text-sm font-bold mb-3">رسانه چالش</label>
|
||||
<label className="mb-3 block text-sm font-bold text-[#FBE7F5]">رسانه چالش</label>
|
||||
<div
|
||||
className="flex rounded-2xl p-1 mb-4"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(20, 50, 70, 0.7) 0%, rgba(10, 30, 50, 0.7) 100%)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
{(["image", "video"] as const).map((type) => (
|
||||
|
|
@ -221,11 +224,12 @@ export function ImageVideoForm({ topicId, topicTitle, onSubmit }: SubmitFormProp
|
|||
style={
|
||||
mediaType === type
|
||||
? {
|
||||
background: "linear-gradient(135deg, rgba(255, 184, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)",
|
||||
color: "#5A3800",
|
||||
boxShadow: "0 4px 12px rgba(255, 165, 0, 0.4)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(174, 117, 255, 0.96) 0%, rgba(138, 82, 238, 0.95) 46%, rgba(102, 55, 204, 0.94) 100%)",
|
||||
color: "#FFFFFF",
|
||||
boxShadow: "0 0 18px rgba(155,108,241,0.4)",
|
||||
}
|
||||
: { color: "rgba(255,255,255,0.55)" }
|
||||
: { color: "rgba(251,231,245,0.72)" }
|
||||
}
|
||||
>
|
||||
{type === "image" ? (
|
||||
|
|
@ -342,11 +346,15 @@ export function ImageVideoForm({ topicId, topicTitle, onSubmit }: SubmitFormProp
|
|||
disabled={isSubmitDisabled || isSubmitting}
|
||||
className="w-full py-4 rounded-full text-white text-base font-bold mb-6 transition-opacity"
|
||||
style={{
|
||||
background: isSubmitDisabled || isSubmitting
|
||||
? "linear-gradient(135deg, rgba(100,100,100,0.6) 0%, rgba(70,70,70,0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)",
|
||||
boxShadow: isSubmitDisabled || isSubmitting ? "none" : "0 8px 24px rgba(255, 165, 0, 0.5)",
|
||||
border: "1.5px solid rgba(255, 200, 50, 0.5)",
|
||||
backgroundImage: isSubmitDisabled || isSubmitting
|
||||
? "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)"
|
||||
: "linear-gradient(90deg, rgba(255,139,91,1) 0%, rgba(238,91,166,1) 45%, rgba(147,78,255,1) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: isSubmitDisabled || isSubmitting
|
||||
? "inset 0 1px 0 rgba(255,255,255,0.12)"
|
||||
: "0 10px 26px rgba(196, 87, 255, 0.35), inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
border: "1px solid transparent",
|
||||
}}
|
||||
>
|
||||
{isSubmitDisabled ? "ابتدا کاور ویدیو را انتخاب کنید" : isSubmitting ? "در حال ارسال..." : "ثبت نهایی چالش"}
|
||||
|
|
@ -359,20 +367,23 @@ export function ImageVideoForm({ topicId, topicTitle, onSubmit }: SubmitFormProp
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-4 p-4 rounded-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(135, 206, 250, 0.15) 0%, rgba(100, 149, 237, 0.15) 100%)",
|
||||
border: "2px solid rgba(135, 206, 250, 0.3)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.88) 0%, rgba(35, 24, 62, 0.92) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-3 border-blue-400 border-t-transparent rounded-full"
|
||||
className="w-5 h-5 border-2 border-[#D8B4FE] border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="text-blue-600 font-bold text-sm">{uploadProgress}</span>
|
||||
<span className="text-[#FBE7F5] font-bold text-sm">{uploadProgress}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,11 +220,15 @@ export function ImageWithAudioForm({ topicId, topicTitle, onSubmit }: SubmitForm
|
|||
disabled={isSubmitting}
|
||||
className="w-full py-4 rounded-full text-white text-base font-bold mb-6"
|
||||
style={{
|
||||
background: isSubmitting
|
||||
? "linear-gradient(135deg, rgba(150, 150, 150, 0.95) 0%, rgba(100, 100, 100, 0.95) 100%)"
|
||||
: "linear-gradient(135deg, rgba(255, 184, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)",
|
||||
boxShadow: "0 8px 24px rgba(255, 165, 0, 0.4)",
|
||||
border: "2px solid rgba(255, 184, 0, 0.3)",
|
||||
backgroundImage: isSubmitting
|
||||
? "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)"
|
||||
: "linear-gradient(90deg, rgba(255,139,91,1) 0%, rgba(238,91,166,1) 45%, rgba(147,78,255,1) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: isSubmitting
|
||||
? "inset 0 1px 0 rgba(255,255,255,0.12)"
|
||||
: "0 10px 26px rgba(196, 87, 255, 0.35), inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
border: "1px solid transparent",
|
||||
opacity: isSubmitting ? 0.7 : 1,
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
}}
|
||||
|
|
@ -239,20 +243,23 @@ export function ImageWithAudioForm({ topicId, topicTitle, onSubmit }: SubmitForm
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-4 p-4 rounded-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(135, 206, 250, 0.15) 0%, rgba(100, 149, 237, 0.15) 100%)",
|
||||
border: "2px solid rgba(135, 206, 250, 0.3)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.88) 0%, rgba(35, 24, 62, 0.92) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-3 border-blue-400 border-t-transparent rounded-full"
|
||||
className="w-5 h-5 border-2 border-[#D8B4FE] border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="text-blue-600 font-bold text-sm">{uploadProgress}</span>
|
||||
<span className="text-[#FBE7F5] font-bold text-sm">{uploadProgress}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,13 +171,15 @@ export function ImageWithSupervisorForm({ topicId, topicTitle, onSubmit, doingMi
|
|||
disabled={supervisorPhone.length < 10 || supervisorCode.length < 6 || isSubmitting}
|
||||
className="w-full py-4 rounded-full text-white text-base font-bold mb-6"
|
||||
style={{
|
||||
background: supervisorPhone.length >= 10 && supervisorCode.length >= 6 && !isSubmitting
|
||||
? "linear-gradient(135deg, rgba(255, 184, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)"
|
||||
: "rgba(100, 100, 100, 0.5)",
|
||||
backgroundImage: supervisorPhone.length >= 10 && supervisorCode.length >= 6 && !isSubmitting
|
||||
? "linear-gradient(90deg, rgba(255,139,91,1) 0%, rgba(238,91,166,1) 45%, rgba(147,78,255,1) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)"
|
||||
: "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
boxShadow: supervisorPhone.length >= 10 && supervisorCode.length >= 6 && !isSubmitting
|
||||
? "0 8px 24px rgba(255, 165, 0, 0.4)"
|
||||
: "none",
|
||||
border: "2px solid rgba(255, 184, 0, 0.3)",
|
||||
? "0 10px 26px rgba(196, 87, 255, 0.35), inset 0 1px 0 rgba(255,255,255,0.35)"
|
||||
: "inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
border: "1px solid transparent",
|
||||
cursor: supervisorPhone.length >= 10 && supervisorCode.length >= 6 && !isSubmitting ? "pointer" : "not-allowed",
|
||||
opacity: isSubmitting ? 0.7 : 1,
|
||||
}}
|
||||
|
|
@ -196,17 +198,19 @@ export function ImageWithSupervisorForm({ topicId, topicTitle, onSubmit, doingMi
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-4 p-4 rounded-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(135, 206, 250, 0.15) 0%, rgba(100, 149, 237, 0.15) 100%)",
|
||||
border: "2px solid rgba(135, 206, 250, 0.3)",
|
||||
backgroundImage: "linear-gradient(180deg, rgba(46, 27, 61, 0.88) 0%, rgba(35, 24, 62, 0.92) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-3 border-blue-400 border-t-transparent rounded-full"
|
||||
className="w-5 h-5 border-2 border-[#D8B4FE] border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="text-blue-600 font-bold text-sm">{uploadProgress}</span>
|
||||
<span className="text-[#FBE7F5] font-bold text-sm">{uploadProgress}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,24 +21,30 @@ export function AudioUploadBox({
|
|||
<div
|
||||
className="relative overflow-hidden rounded-3xl p-6"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(138, 206, 224, 0.15) 0%, rgba(128, 208, 224, 0.15) 100%)",
|
||||
border: "2px solid rgba(138, 206, 224, 0.3)",
|
||||
boxShadow: "0 8px 24px rgba(138, 206, 224, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #8ACEE0 0%, #80D0E0 100%)",
|
||||
boxShadow: "0 4px 12px rgba(138, 206, 224, 0.4)",
|
||||
background: "linear-gradient(145deg, #A873FF 0%, #8A52EE 55%, #6637CC 100%)",
|
||||
boxShadow: "0 4px 12px rgba(155,108,241,0.45)",
|
||||
}}
|
||||
>
|
||||
<Mic className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-bold text-base">{label}</h3>
|
||||
<p className="text-white/70 text-sm">MP3, WAV, M4A</p>
|
||||
<h3 className="text-base font-bold text-[#FBE7F5]">{label}</h3>
|
||||
<p className="text-sm text-[#EED3EC]/85">MP3, WAV, M4A</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -48,28 +54,28 @@ export function AudioUploadBox({
|
|||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full py-8 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center gap-3 transition-all hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: "rgba(138, 206, 224, 0.4)",
|
||||
borderColor: "rgba(216, 180, 254, 0.52)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(138, 206, 224, 0.3) 0%, rgba(128, 208, 224, 0.3) 100%)",
|
||||
background: "linear-gradient(135deg, rgba(168,115,255,0.25) 0%, rgba(138,82,238,0.25) 100%)",
|
||||
}}
|
||||
>
|
||||
<Upload className="w-8 h-8 text-[#8ACEE0]" />
|
||||
<Upload className="h-8 w-8 text-[#D8B4FE]" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-white font-bold text-base">آپلود فایل صوتی</p>
|
||||
<p className="text-white/60 text-sm mt-1">برای انتخاب فایل کلیک کنید</p>
|
||||
<p className="text-base font-bold text-[#FBE7F5]">آپلود فایل صوتی</p>
|
||||
<p className="mt-1 text-sm text-[#EED3EC]/80">برای انتخاب فایل کلیک کنید</p>
|
||||
</div>
|
||||
</motion.button>
|
||||
) : (
|
||||
<div
|
||||
className="relative rounded-2xl p-4"
|
||||
style={{
|
||||
background: "rgba(10, 20, 35, 0.6)",
|
||||
border: "2px solid rgba(138, 206, 224, 0.4)",
|
||||
background: "rgba(26, 18, 54, 0.6)",
|
||||
border: "1px solid rgba(216, 180, 254, 0.45)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -77,8 +83,8 @@ export function AudioUploadBox({
|
|||
<div
|
||||
className="w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #8ACEE0 0%, #80D0E0 100%)",
|
||||
boxShadow: "0 4px 12px rgba(138, 206, 224, 0.4)",
|
||||
background: "linear-gradient(145deg, #A873FF 0%, #8A52EE 55%, #6637CC 100%)",
|
||||
boxShadow: "0 4px 12px rgba(155,108,241,0.45)",
|
||||
}}
|
||||
>
|
||||
<Music className="w-7 h-7 text-white" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
const inputStyle = {
|
||||
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.3)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "1px solid #d680ff66",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
};
|
||||
|
||||
interface FormInputProps {
|
||||
|
|
@ -16,7 +22,7 @@ interface FormInputProps {
|
|||
export function FormInput({ label, value, onChange, placeholder, multiline, rows = 6 }: FormInputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-white text-sm font-bold mb-3">{label}</label>
|
||||
<label className="mb-3 block text-sm font-bold text-[#FBE7F5]">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
|
|
@ -24,7 +30,7 @@ export function FormInput({ label, value, onChange, placeholder, multiline, rows
|
|||
placeholder={placeholder}
|
||||
dir="rtl"
|
||||
rows={rows}
|
||||
className="w-full px-4 py-3 rounded-2xl text-white text-sm outline-none resize-none"
|
||||
className="w-full resize-none rounded-3xl px-4 py-3 text-sm text-white outline-none placeholder:text-white/45"
|
||||
style={inputStyle}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -34,7 +40,7 @@ export function FormInput({ label, value, onChange, placeholder, multiline, rows
|
|||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
dir="rtl"
|
||||
className="w-full px-4 py-3 rounded-2xl text-white text-sm outline-none"
|
||||
className="h-14 w-full rounded-full px-4 text-base text-white outline-none placeholder:text-white/45"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@ interface MediaUploadBoxProps {
|
|||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.3)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
};
|
||||
|
||||
export function MediaUploadBox({
|
||||
|
|
@ -27,24 +33,26 @@ export function MediaUploadBox({
|
|||
required,
|
||||
}: MediaUploadBoxProps) {
|
||||
const isVideo = type === "video";
|
||||
const isImage = type === "image";
|
||||
const isImageEmptyState = isImage && !uploadedFile && !required;
|
||||
const borderStyle = uploadedFile
|
||||
? "1.5px solid rgba(138, 206, 224, 0.5)"
|
||||
? "0.5px solid transparent"
|
||||
: required
|
||||
? "1.5px dashed rgba(255, 184, 0, 0.6)"
|
||||
: "1.5px dashed rgba(138, 206, 224, 0.5)";
|
||||
? "1px dashed rgba(250, 204, 21, 0.7)"
|
||||
: "1px dashed rgba(216, 180, 254, 0.65)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-white text-sm font-bold mb-3">
|
||||
<label className="mb-3 block text-sm font-bold text-[#FBE7F5]">
|
||||
{label}
|
||||
{required && (
|
||||
<span
|
||||
className="mr-2 text-xs font-normal px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "rgba(255, 184, 0, 0.2)",
|
||||
color: "#FFB800",
|
||||
border: "1px solid rgba(255, 184, 0, 0.4)",
|
||||
background: "rgba(250, 204, 21, 0.22)",
|
||||
color: "#FDE68A",
|
||||
border: "1px solid rgba(250, 204, 21, 0.5)",
|
||||
}}
|
||||
>
|
||||
الزامی
|
||||
|
|
@ -55,7 +63,16 @@ export function MediaUploadBox({
|
|||
|
||||
<div
|
||||
className="relative rounded-2xl overflow-hidden"
|
||||
style={{ ...inputStyle, border: borderStyle }}
|
||||
style={
|
||||
isImageEmptyState
|
||||
? {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(168,115,255,0.28) 0%, rgba(138,82,238,0.24) 100%)",
|
||||
border: borderStyle,
|
||||
boxShadow: "none",
|
||||
}
|
||||
: { ...inputStyle, border: borderStyle }
|
||||
}
|
||||
>
|
||||
{uploadedFile ? (
|
||||
<div className="relative">
|
||||
|
|
@ -96,7 +113,7 @@ export function MediaUploadBox({
|
|||
onClick={onRemove}
|
||||
className="absolute top-2 left-2 w-8 h-8 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(220, 38, 38, 0.9) 0%, rgba(185, 28, 28, 0.9) 100%)",
|
||||
background: "linear-gradient(145deg, rgba(220, 38, 38, 0.9) 0%, rgba(185, 28, 28, 0.9) 100%)",
|
||||
boxShadow: "0 4px 12px rgba(220, 38, 38, 0.4)",
|
||||
}}
|
||||
>
|
||||
|
|
@ -104,23 +121,23 @@ export function MediaUploadBox({
|
|||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-48 cursor-pointer">
|
||||
<label className="flex h-48 cursor-pointer flex-col items-center justify-center">
|
||||
{required ? (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center mb-2"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(255, 184, 0, 0.3) 0%, rgba(255, 140, 0, 0.3) 100%)",
|
||||
border: "1.5px solid rgba(255, 184, 0, 0.5)",
|
||||
background: "linear-gradient(135deg, rgba(250, 204, 21, 0.26) 0%, rgba(249, 115, 22, 0.24) 100%)",
|
||||
border: "1px solid rgba(250, 204, 21, 0.5)",
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="w-6 h-6 text-yellow-400" />
|
||||
<ImageIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
) : isVideo ? (
|
||||
<Video className="w-12 h-12 text-white/70 mb-2" />
|
||||
<Video className="mb-2 h-12 w-12 text-white" />
|
||||
) : (
|
||||
<Upload className="w-12 h-12 text-white/70 mb-2" />
|
||||
<Upload className="mb-2 h-12 w-12 text-[#F6D8F0]" />
|
||||
)}
|
||||
<span className={`text-sm ${required ? "text-white/80" : "text-white/70"}`}>
|
||||
<span className={`text-sm ${isImageEmptyState ? "text-[#F6D8F0]" : "text-white"}`}>
|
||||
{isVideo
|
||||
? required
|
||||
? "انتخاب کاور برای ویدیو"
|
||||
|
|
@ -128,10 +145,10 @@ export function MediaUploadBox({
|
|||
: "کلیک کنید برای آپلود تصویر"}
|
||||
</span>
|
||||
{isVideo && !required && (
|
||||
<span className="text-white/40 text-xs mt-1">MP4، MOV، AVI پشتیبانی میشود</span>
|
||||
<span className="mt-1 text-xs text-white">MP4، MOV، AVI پشتیبانی میشود</span>
|
||||
)}
|
||||
{required && (
|
||||
<span className="text-white/40 text-xs mt-1">تصویری که نمایش داده میشود</span>
|
||||
<span className="mt-1 text-xs text-[#F6D8F0]">تصویری که نمایش داده میشود</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
|
|
|
|||
|
|
@ -86,24 +86,30 @@ export function SupervisorRequestSection({
|
|||
<div
|
||||
className="relative overflow-hidden rounded-3xl p-6"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(168, 144, 224, 0.15) 0%, rgba(138, 206, 224, 0.15) 100%)",
|
||||
border: "2px solid rgba(168, 144, 224, 0.3)",
|
||||
boxShadow: "0 8px 24px rgba(168, 144, 224, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "0.5px solid transparent",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #A890E0 0%, #8ACEE0 100%)",
|
||||
boxShadow: "0 4px 12px rgba(168, 144, 224, 0.4)",
|
||||
background: "linear-gradient(145deg, #A873FF 0%, #8A52EE 55%, #6637CC 100%)",
|
||||
boxShadow: "0 4px 12px rgba(155,108,241,0.45)",
|
||||
}}
|
||||
>
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-bold text-base">درخواست معرفی ناظر</h3>
|
||||
<p className="text-white/70 text-sm mt-0.5">
|
||||
<h3 className="text-base font-bold text-[#FBE7F5]">درخواست معرفی ناظر</h3>
|
||||
<p className="mt-0.5 text-sm text-[#EED3EC]/85">
|
||||
شماره تلفن ناظر خود را وارد کنید
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -120,8 +126,8 @@ export function SupervisorRequestSection({
|
|||
disabled={codeSent}
|
||||
className="w-full px-4 py-3 rounded-2xl text-white text-center text-lg font-bold tracking-wider"
|
||||
style={{
|
||||
background: "rgba(10, 20, 35, 0.6)",
|
||||
border: "2px solid rgba(168, 144, 224, 0.4)",
|
||||
background: "rgba(26, 18, 54, 0.6)",
|
||||
border: "1px solid rgba(216, 180, 254, 0.45)",
|
||||
outline: "none",
|
||||
}}
|
||||
dir="ltr"
|
||||
|
|
@ -137,11 +143,11 @@ export function SupervisorRequestSection({
|
|||
className="w-full py-3 rounded-2xl text-white font-bold flex items-center justify-center gap-2"
|
||||
style={{
|
||||
background: phone.length >= 10
|
||||
? "linear-gradient(135deg, #A890E0 0%, #8ACEE0 100%)"
|
||||
: "rgba(100, 100, 100, 0.5)",
|
||||
? "linear-gradient(135deg, rgba(174, 117, 255, 0.96) 0%, rgba(138, 82, 238, 0.95) 46%, rgba(102, 55, 204, 0.94) 100%)"
|
||||
: "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)",
|
||||
boxShadow: phone.length >= 10
|
||||
? "0 4px 16px rgba(168, 144, 224, 0.4)"
|
||||
: "none",
|
||||
? "0 0 18px rgba(155,108,241,0.4), 0 10px 20px rgba(24, 10, 54, 0.34)"
|
||||
: "inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
cursor: phone.length >= 10 ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
|
|
@ -185,8 +191,8 @@ export function SupervisorRequestSection({
|
|||
onClick={handleChangePhone}
|
||||
className="flex-1 py-2.5 rounded-2xl text-white/80 text-sm font-bold hover:text-white transition-colors"
|
||||
style={{
|
||||
background: "rgba(168, 144, 224, 0.2)",
|
||||
border: "1px solid rgba(168, 144, 224, 0.3)",
|
||||
background: "rgba(138, 82, 238, 0.22)",
|
||||
border: "1px solid rgba(216, 180, 254, 0.45)",
|
||||
}}
|
||||
>
|
||||
تغییر شماره
|
||||
|
|
@ -198,8 +204,8 @@ export function SupervisorRequestSection({
|
|||
}}
|
||||
className="flex-1 py-2.5 rounded-2xl text-white/80 text-sm font-bold hover:text-white transition-colors"
|
||||
style={{
|
||||
background: "rgba(168, 144, 224, 0.2)",
|
||||
border: "1px solid rgba(168, 144, 224, 0.3)",
|
||||
background: "rgba(138, 82, 238, 0.22)",
|
||||
border: "1px solid rgba(216, 180, 254, 0.45)",
|
||||
}}
|
||||
>
|
||||
ارسال مجدد کد
|
||||
|
|
|
|||
|
|
@ -22,9 +22,15 @@ interface TeammatesSectionProps {
|
|||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
|
||||
border: "1.5px solid rgba(138, 206, 224, 0.3)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
|
||||
backgroundOrigin: "border-box",
|
||||
backgroundClip: "padding-box, border-box",
|
||||
border: "1px solid #d680ff66",
|
||||
boxShadow:
|
||||
"0 -7px 20px rgba(7, 0, 18, 0.46), 0 6px 14px rgba(5, 2, 12, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.66)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
};
|
||||
|
||||
// Debounce hook
|
||||
|
|
@ -190,20 +196,20 @@ function TeammateItem({
|
|||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
dir="ltr"
|
||||
maxLength={11}
|
||||
className="w-full rounded-2xl text-white text-sm outline-none pl-[43px] pr-[48px] py-[12px]"
|
||||
className="h-14 w-full rounded-full py-[12px] pl-[43px] pr-[48px] text-base text-white outline-none placeholder:text-white/45"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Verification Status Icon */}
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
||||
{isVerifying && (
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[#D8B4FE]" />
|
||||
)}
|
||||
{!isVerifying && isSuccess && (
|
||||
<CheckCircle className="w-5 h-5 text-green-400 m-[0px]" />
|
||||
<CheckCircle className="m-[0px] h-5 w-5 text-emerald-300" />
|
||||
)}
|
||||
{!isVerifying && hasError && (
|
||||
<XCircle className="w-5 h-5 text-red-400" />
|
||||
<XCircle className="h-5 w-5 text-rose-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,7 +221,7 @@ function TeammateItem({
|
|||
onClick={onRemove}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(220, 38, 38, 0.8) 0%, rgba(185, 28, 28, 0.8) 100%)",
|
||||
background: "linear-gradient(135deg, rgba(220, 38, 38, 0.85) 0%, rgba(185, 28, 28, 0.85) 100%)",
|
||||
boxShadow: "0 4px 12px rgba(220, 38, 38, 0.4)",
|
||||
}}
|
||||
>
|
||||
|
|
@ -226,19 +232,19 @@ function TeammateItem({
|
|||
|
||||
{/* User Name or Error Display */}
|
||||
{!isVerifying && isSuccess && (
|
||||
<div className="text-sm text-green-300 flex items-center gap-1 px-[8px] py-[0px] m-[0px]">
|
||||
<div className="m-[0px] flex items-center gap-1 px-[8px] py-[0px] text-sm text-emerald-300">
|
||||
<span>✓</span>
|
||||
<span>{teammate.fullName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isVerifying && teammate.error && (
|
||||
<div className="px-2 text-sm text-red-300 flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 text-sm text-rose-300">
|
||||
<span>✗</span>
|
||||
<span>{teammate.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isVerifying && !teammate.error && teammate.fullName === "" && /^09\d{9}$/.test(teammate.phone) && (
|
||||
<div className="px-2 text-sm text-red-300 flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 text-sm text-rose-300">
|
||||
<span>✗</span>
|
||||
<span>کاربر یافت نشد</span>
|
||||
</div>
|
||||
|
|
@ -264,7 +270,7 @@ export function TeammatesSection({ teammates, onAdd, onRemove, onChange, onVerif
|
|||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-white text-sm font-bold mb-3">شماره تلفن همتیمیها</label>
|
||||
<label className="mb-3 block text-sm font-bold text-[#FBE7F5]">شماره تلفن همتیمیها</label>
|
||||
<div className="space-y-3">
|
||||
{teammates.map((teammate) => (
|
||||
<TeammateItem
|
||||
|
|
@ -285,8 +291,9 @@ export function TeammatesSection({ teammates, onAdd, onRemove, onChange, onVerif
|
|||
onClick={onAdd}
|
||||
className="w-full px-4 py-3 rounded-2xl text-white text-sm font-bold flex items-center justify-center gap-2"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(100, 200, 255, 0.3) 0%, rgba(50, 150, 220, 0.3) 100%)",
|
||||
border: "1.5px dashed rgba(138, 206, 224, 0.5)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(168,115,255,0.28) 0%, rgba(138,82,238,0.24) 100%)",
|
||||
border: "1px dashed rgba(216, 180, 254, 0.65)",
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
|
|
@ -295,4 +302,4 @@ export function TeammatesSection({ teammates, onAdd, onRemove, onChange, onVerif
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function AppShell() {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const matches = useMatches();
|
||||
const isPublicChatPage = location.pathname === "/public-chat";
|
||||
|
||||
const headerConfig = useMemo(() => {
|
||||
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
||||
|
|
@ -37,11 +38,16 @@ export function AppShell() {
|
|||
action={headerConfig.action}
|
||||
showBack={Boolean(headerConfig.showBack)}
|
||||
onBack={headerConfig.showBack ? () => navigate(headerConfig.backTo ?? "/") : undefined}
|
||||
onActionClick={
|
||||
headerConfig.action === "history"
|
||||
? () => window.dispatchEvent(new CustomEvent("public-chat:history"))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<AnimatedOutlet />
|
||||
|
||||
<BottomNav fixed={false} />
|
||||
{!isPublicChatPage && <BottomNav fixed={false} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
BIN
src/assets/chatbot-bot-avatar.png
Normal file
BIN
src/assets/chatbot-bot-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
import { sendChatMessage } from "../services/feedService";
|
||||
import { useTypingMessage } from "./useTypingMessage";
|
||||
|
||||
export interface ChatFlowMessage {
|
||||
id: string;
|
||||
|
|
@ -12,6 +11,7 @@ export interface ChatFlowMessage {
|
|||
buttons?: Array<{ id: string; label: string; action: string }>;
|
||||
datetime1?: string;
|
||||
duration?: string;
|
||||
isTyping?: boolean;
|
||||
}
|
||||
|
||||
interface UseChatFlowOptions {
|
||||
|
|
@ -37,9 +37,9 @@ const normalizeBooleanValue = (value: any): boolean => {
|
|||
export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): UseChatFlowResult => {
|
||||
const [messages, setMessages] = useState<ChatFlowMessage[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { isTyping, typingText, startTyping } = useTypingMessage();
|
||||
const requestIdRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
const typingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const sendMessage = useCallback(async (messageText: string, options?: { skipUserMessage?: boolean }) => {
|
||||
console.log("sendMessage called:", {
|
||||
|
|
@ -49,9 +49,12 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
|
|||
skipUserMessage: options?.skipUserMessage,
|
||||
});
|
||||
|
||||
if (!messageText.trim() || !workflowId) {
|
||||
const displayMessage = messageText.trim();
|
||||
const normalizedMessage = displayMessage.replace(/\r?\n+/g, " ").trim();
|
||||
|
||||
if (!normalizedMessage || !workflowId) {
|
||||
console.log("sendMessage aborted:", {
|
||||
hasMessage: !!messageText.trim(),
|
||||
hasMessage: !!normalizedMessage,
|
||||
hasWorkflowId: !!workflowId,
|
||||
});
|
||||
return;
|
||||
|
|
@ -62,7 +65,7 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
|
|||
}
|
||||
|
||||
const currentRequestId = ++requestIdRef.current;
|
||||
const trimmedMessage = messageText.trim();
|
||||
const serverMessage = normalizedMessage;
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
|
|
@ -72,7 +75,7 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
|
|||
const userMessage: ChatFlowMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
type: "user",
|
||||
content: trimmedMessage,
|
||||
content: displayMessage,
|
||||
mediaType: "text",
|
||||
timestamp: new Date(),
|
||||
datetime1: new Date().toLocaleString("fa-IR"),
|
||||
|
|
@ -83,11 +86,26 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
|
|||
}
|
||||
}
|
||||
|
||||
// شروع typing indicator برای نشان دادن فکر کردن ربات (با متن خالی)
|
||||
startTyping("", () => {});
|
||||
const botMessageId = `bot-${Date.now()}`;
|
||||
const botTimestamp = new Date();
|
||||
const botDatetime = botTimestamp.toLocaleString("fa-IR");
|
||||
|
||||
// Create a real bot message immediately and keep typing inside this same message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: botMessageId,
|
||||
type: "bot",
|
||||
content: "",
|
||||
mediaType: "text",
|
||||
timestamp: botTimestamp,
|
||||
datetime1: botDatetime,
|
||||
isTyping: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Call API
|
||||
const response = await sendChatMessage(trimmedMessage, workflowId);
|
||||
const response = await sendChatMessage(serverMessage, workflowId);
|
||||
|
||||
// Guard against stale requests
|
||||
if (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
|
||||
|
|
@ -97,90 +115,119 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
|
|||
if (response.success && response.message) {
|
||||
const isMissionEnd = normalizeBooleanValue(response.is_mission_end);
|
||||
|
||||
// شروع نمایش متن پاسخ با افکت typing (این کار typing dots قبلی را هم متوقف میکند)
|
||||
startTyping(response.message, () => {
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
}
|
||||
|
||||
// Build action buttons once, attach after typing completes
|
||||
let buttons: Array<{ id: string; label: string; action: string }> | undefined = undefined;
|
||||
|
||||
if (response.actions && response.actions.length > 0 && response.actions[0].multi_choice) {
|
||||
buttons = response.actions[0].multi_choice.map((label, index) => ({
|
||||
id: `action-${Date.now()}-${index}`,
|
||||
label: label,
|
||||
action: `multi_choice_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isMissionEnd) {
|
||||
if (!buttons) buttons = [];
|
||||
buttons.push({
|
||||
id: `submit-challenge-${Date.now()}`,
|
||||
label: "🎯 رفتن به مرحله بعد و ثبت ماموریت",
|
||||
action: "submit-challenge",
|
||||
});
|
||||
|
||||
if (onMissionEnd) {
|
||||
onMissionEnd();
|
||||
}
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const fullText = response.message;
|
||||
|
||||
typingIntervalRef.current = setInterval(() => {
|
||||
if (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build action buttons
|
||||
let buttons: Array<{ id: string; label: string; action: string }> | undefined = undefined;
|
||||
|
||||
if (response.actions && response.actions.length > 0 && response.actions[0].multi_choice) {
|
||||
buttons = response.actions[0].multi_choice.map((label, index) => ({
|
||||
id: `action-${Date.now()}-${index}`,
|
||||
label: label,
|
||||
action: `multi_choice_${index}`,
|
||||
}));
|
||||
if (index < fullText.length) {
|
||||
const nextContent = fullText.slice(0, index + 1);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === botMessageId
|
||||
? { ...msg, content: nextContent, isTyping: true }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
index++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add mission end button if applicable
|
||||
if (isMissionEnd) {
|
||||
if (!buttons) buttons = [];
|
||||
buttons.push({
|
||||
id: `submit-challenge-${Date.now()}`,
|
||||
label: "🎯 رفتن به مرحله بعد و ثبت ماموریت",
|
||||
action: "submit-challenge",
|
||||
});
|
||||
|
||||
if (onMissionEnd) {
|
||||
onMissionEnd();
|
||||
}
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
const botMessage: ChatFlowMessage = {
|
||||
id: `bot-${Date.now()}`,
|
||||
type: "bot",
|
||||
content: response.message!,
|
||||
mediaType: "text",
|
||||
timestamp: new Date(),
|
||||
datetime1: new Date().toLocaleString("fa-IR"),
|
||||
buttons: buttons,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === botMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: fullText,
|
||||
isTyping: false,
|
||||
buttons,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
}, 30);
|
||||
} else {
|
||||
// Show error message
|
||||
const errorMessage: ChatFlowMessage = {
|
||||
id: `bot-${Date.now()}`,
|
||||
type: "bot",
|
||||
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
mediaType: "text",
|
||||
timestamp: new Date(),
|
||||
datetime1: new Date().toLocaleString("fa-IR"),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === botMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
isTyping: false,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage: ChatFlowMessage = {
|
||||
id: `bot-${Date.now()}`,
|
||||
type: "bot",
|
||||
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
mediaType: "text",
|
||||
timestamp: new Date(),
|
||||
datetime1: new Date().toLocaleString("fa-IR"),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.type === "bot" && msg.isTyping
|
||||
? {
|
||||
...msg,
|
||||
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
isTyping: false,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (isMountedRef.current && currentRequestId === requestIdRef.current) {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
}, [workflowId, isSending, startTyping, onMissionEnd]);
|
||||
}, [workflowId, isSending, onMissionEnd]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
isSending,
|
||||
sendMessage,
|
||||
isTyping,
|
||||
typingText,
|
||||
isTyping: false,
|
||||
typingText: "",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { useSearchParams, useLocation } from "react-router-dom";
|
||||
import { ChatMessage, DoingMission, startMission } from "../services/feedService";
|
||||
import { useInbox } from "../app/context/InboxContext";
|
||||
|
||||
interface MissionSessionData {
|
||||
chats: ChatMessage[];
|
||||
|
|
@ -17,6 +18,7 @@ interface UseMissionSessionResult {
|
|||
export const useMissionSession = (): UseMissionSessionResult => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refreshInbox } = useInbox();
|
||||
|
||||
const [sessionData, setSessionData] = useState<MissionSessionData | null>(() => {
|
||||
const locationState = location.state as any;
|
||||
|
|
@ -80,6 +82,8 @@ export const useMissionSession = (): UseMissionSessionResult => {
|
|||
localStorage.setItem("current_workflow_ID", response.doing_mission.workflow_ID);
|
||||
}
|
||||
|
||||
await refreshInbox();
|
||||
|
||||
setSessionData({
|
||||
chats: response.chats,
|
||||
doingMission: response.doing_mission,
|
||||
|
|
@ -100,7 +104,7 @@ export const useMissionSession = (): UseMissionSessionResult => {
|
|||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [location.pathname, searchParams]);
|
||||
}, [location.pathname, searchParams, refreshInbox]);
|
||||
|
||||
return {
|
||||
sessionData,
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ export const router = createBrowserRouter([
|
|||
handle: {
|
||||
header: {
|
||||
title: "چت با ربات",
|
||||
showBack: false,
|
||||
action: "profile",
|
||||
showBack: true,
|
||||
backTo: "/",
|
||||
action: "history",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,19 +1,43 @@
|
|||
import { toPersianDigits } from "./persianNumberUtils";
|
||||
|
||||
const splitDateTime = (datetime1: string): { date: string; time: string } => {
|
||||
const normalized = datetime1.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return { date: "", time: "" };
|
||||
}
|
||||
|
||||
// Legacy backend format: "date - time"
|
||||
if (normalized.includes(" - ")) {
|
||||
const [datePart = "", timePart = ""] = normalized.split(" - ");
|
||||
return { date: datePart.trim(), time: timePart.trim() };
|
||||
}
|
||||
|
||||
// Locale format from toLocaleString: "date, time" or "date، time"
|
||||
const commaMatch = normalized.match(/^(.+?)[،,]\s*(.+)$/);
|
||||
if (commaMatch) {
|
||||
return { date: commaMatch[1].trim(), time: commaMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Date-only value
|
||||
return { date: normalized, time: "" };
|
||||
};
|
||||
|
||||
export const extractDate = (datetime1?: string, timestamp?: Date): string => {
|
||||
if (datetime1) {
|
||||
// Extract only date part (before " - ")
|
||||
return datetime1.split(" - ")[0] || "";
|
||||
return toPersianDigits(splitDateTime(datetime1).date);
|
||||
}
|
||||
if (timestamp) {
|
||||
// Format timestamp to Persian date only (no time)
|
||||
return timestamp.toLocaleDateString("fa-IR");
|
||||
return toPersianDigits(timestamp.toLocaleDateString("fa-IR"));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const extractTime = (datetime1?: string, timestamp?: Date): string => {
|
||||
if (datetime1) {
|
||||
// Extract only time part (after " - ")
|
||||
return datetime1.split(" - ")[1] || "";
|
||||
const { time } = splitDateTime(datetime1);
|
||||
if (time) return toPersianDigits(time);
|
||||
}
|
||||
if (timestamp) {
|
||||
// Format timestamp to time only
|
||||
|
|
@ -23,8 +47,10 @@ export const extractTime = (datetime1?: string, timestamp?: Date): string => {
|
|||
};
|
||||
|
||||
export const formatTimestamp = (timestamp: Date): string => {
|
||||
return timestamp.toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return toPersianDigits(
|
||||
timestamp.toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
5
src/utils/persianNumberUtils.ts
Normal file
5
src/utils/persianNumberUtils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const EN_TO_FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] as const;
|
||||
|
||||
export const toPersianDigits = (value: string): string => {
|
||||
return value.replace(/\d/g, (digit) => EN_TO_FA_DIGITS[Number(digit)]);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user