امروز ۳۱ اردیبهشت

چت بات عمومی خرابه
This commit is contained in:
reza7321 2026-05-21 14:50:03 +03:30
parent 28c14ebd15
commit 213c0a70f0
40 changed files with 1666 additions and 871 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -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>

View File

@ -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>

View File

@ -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)",
}}

View File

@ -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);
const handleChallengeSelectCallback = useCallback((mission: MissionData) => {
const missionId = mission.mission_workflowID;
// ذخیره اطلاعات ماموریت در localStorage برای بازگشت‌های بعدی
localStorage.setItem('current_mission_type', topicConfig.title);
localStorage.setItem('current_mission_id', missionId);
localStorage.setItem("current_mission_type", topicConfig.title);
localStorage.setItem("current_mission_id", missionId);
localStorage.setItem("current_mission_title", mission.title);
// ذخیره workflow_ID از doing_mission
if (response.doing_mission?.workflow_ID) {
localStorage.setItem('current_workflow_ID', response.doing_mission.workflow_ID);
}
const params = new URLSearchParams({
continueMode: "true",
missionId,
missionType: topicConfig.title,
});
// Refresh inbox to load new messages
await refreshInbox();
// Navigate to chatbot page with mission data
navigate(`/chatbot/${topicId}`, {
navigate(`/chatbot/${topicId}?${params.toString()}`, {
state: {
chats: response.chats,
doingMission: response.doing_mission,
selectedMissionTitle: mission.title,
missionType: topicConfig.title,
},
});
} catch (error) {
console.error("Error starting mission:", error);
alert("خطا در شروع چالش. لطفاً دوباره تلاش کنید.");
setAutoNavigating(false);
} finally {
setStartingMission(false);
}
}, [topicConfig.title, topicId, navigate, refreshInbox]);
}, [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,14 +248,43 @@ 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" />
<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
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)" }}
/>
<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}
>
<img
src={getMissionImageUrl(mission.StageID)}
alt={mission.title}
className="w-full h-full object-cover rounded-full relative z-10 drop-shadow-xl"
className="h-full w-full rounded-[15px] object-cover"
loading="eager"
onError={(e) => {
// Fallback به آیکون پیش‌فرض در صورت خطا
@ -175,26 +294,55 @@ export function ChallengeSelectionPage() {
/>
</div>
{/* Content */}
<div className="flex-1 text-right">
<h3 className="text-white font-bold text-base mb-1">
<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>
<p className="text-teal-100 text-xs leading-relaxed mb-2 opacity-90">
<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>
{/* 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="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>
</motion.button>
</div>
</div>
</motion.div>
))
)}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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,13 +139,9 @@ 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);
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
@ -177,12 +167,15 @@ export function PublicChatPage() {
} else {
alert(result.message || "خطا در ارسال پیام");
}
} catch (error) {
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
alert("خطا در ارسال پیام");
} finally {
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 || "خطا در بارگذاری تاریخچه");
}
}, []);
useEffect(() => {
const onHistoryRequest = () => {
void handleHistoryClick();
};
const handleNewChat = () => {
setMessages([]);
setCurrentChatWorkflowID("");
setShouldAutoScroll(true);
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}

View File

@ -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">
<div ref={headerWrapperRef}>
{/* Header */}
<FeedHeader topicTitle={`ثبت چالش ${topicConfig.title}`} onBack={handleBack} />
<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 */}

View File

@ -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>
);

View File

@ -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) {
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>

View File

@ -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,15 +337,22 @@ export function ChatMessageItem({ message, animationDelay, onButtonClick }: Chat
{/* Bot Avatar */}
{isBot && (
<div className="flex-shrink-0">
<div
className="flex h-12 w-12 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="w-12 h-12 rounded-full object-contain"
style={{
filter: "drop-shadow(0 2px 8px rgba(138, 206, 224, 0.5))",
}}
className="h-[43px] w-[43px] object-contain"
/>
</div>
</div>
)}
</motion.div>
);

View File

@ -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">

View File

@ -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) {

View 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)}</>;
}

View File

@ -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>
);
}

View File

@ -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)",
},
};
@ -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,9 +122,9 @@ 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>

View File

@ -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>
);
})}

View File

@ -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" />
)}

View File

@ -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>
)}

View File

@ -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,17 +367,20 @@ 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>
)}

View File

@ -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,17 +243,20 @@ 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>
)}

View File

@ -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>
)}

View File

@ -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" />

View File

@ -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}
/>
)}

View File

@ -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"

View 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)",
}}
>
ارسال مجدد کد

View File

@ -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" />

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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,13 +115,11 @@ 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 (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
return;
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
}
// Build action buttons
// 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) {
@ -114,7 +130,6 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
}));
}
// Add mission end button if applicable
if (isMissionEnd) {
if (!buttons) buttons = [];
buttons.push({
@ -128,59 +143,91 @@ export const useChatFlow = ({ workflowId, onMissionEnd }: UseChatFlowOptions): U
}
}
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,
};
let index = 0;
const fullText = response.message;
setMessages((prev) => [...prev, botMessage]);
});
typingIntervalRef.current = setInterval(() => {
if (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
return;
}
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;
}
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
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",
setMessages((prev) =>
prev.map((msg) =>
msg.id === botMessageId
? {
...msg,
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
mediaType: "text",
timestamp: new Date(),
datetime1: new Date().toLocaleString("fa-IR"),
};
setMessages((prev) => [...prev, errorMessage]);
isTyping: false,
}
: msg,
),
);
}
} catch (error) {
if (!isMountedRef.current || currentRequestId !== requestIdRef.current) {
return;
}
const errorMessage: ChatFlowMessage = {
id: `bot-${Date.now()}`,
type: "bot",
setMessages((prev) =>
prev.map((msg) =>
msg.type === "bot" && msg.isTyping
? {
...msg,
content: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
mediaType: "text",
timestamp: new Date(),
datetime1: new Date().toLocaleString("fa-IR"),
};
setMessages((prev) => [...prev, errorMessage]);
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: "",
};
};

View File

@ -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,

View File

@ -55,8 +55,9 @@ export const router = createBrowserRouter([
handle: {
header: {
title: "چت با ربات",
showBack: false,
action: "profile",
showBack: true,
backTo: "/",
action: "history",
},
},
},

View File

@ -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", {
return toPersianDigits(
timestamp.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
}),
);
};

View 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)]);
};