365 lines
17 KiB
TypeScript
365 lines
17 KiB
TypeScript
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 } from "../../services/feedService";
|
||
import { usePageTracking } from "../../hooks/usePageTracking";
|
||
import { AppHeader } from "./AppHeader";
|
||
import { BottomNav } from "./BottomNav";
|
||
import { AppBackground } from "./shared/AppBackground";
|
||
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();
|
||
// Support both /challenges/:topicId and legacy /challenges (defaults to topic 1)
|
||
const { topicId = "1" } = useParams<{ topicId: string }>();
|
||
|
||
const topicConfig = getTopicConfig(topicId);
|
||
|
||
usePageTracking(`انتخاب چالش ${topicConfig.title}`);
|
||
|
||
const handleChallengeSelectCallback = useCallback((mission: MissionData) => {
|
||
const missionId = mission.mission_workflowID;
|
||
|
||
localStorage.setItem("current_mission_type", topicConfig.title);
|
||
localStorage.setItem("current_mission_id", missionId);
|
||
localStorage.setItem("current_mission_title", mission.title);
|
||
|
||
const params = new URLSearchParams({
|
||
continueMode: "true",
|
||
missionId,
|
||
missionType: topicConfig.title,
|
||
});
|
||
|
||
navigate(`/chatbot/${topicId}?${params.toString()}`, {
|
||
state: {
|
||
selectedMissionTitle: mission.title,
|
||
missionType: topicConfig.title,
|
||
},
|
||
});
|
||
}, [topicConfig.title, topicId, navigate]);
|
||
|
||
const [missions, setMissions] = useState<MissionData[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [autoNavigating, setAutoNavigating] = useState(false);
|
||
|
||
const handleBack = useCallback(() => {
|
||
navigate(`/feed/${topicId}`);
|
||
}, [navigate, topicId]);
|
||
|
||
useEffect(() => {
|
||
const fetchMissions = async () => {
|
||
setLoading(true);
|
||
const response = await loadMissions(topicConfig.title);
|
||
setMissions(response.missions);
|
||
setLoading(false);
|
||
|
||
// برای دفترچه یادداشت (topicId === "3"): به طور خودکار چالش را انتخاب کن
|
||
if (topicId === "3") {
|
||
if (response.missions.length > 0) {
|
||
// اگر چالشی وجود داشت، به طور خودکار آن را انتخاب کن
|
||
setAutoNavigating(true);
|
||
handleChallengeSelectCallback(response.missions[0]);
|
||
} else {
|
||
// اگر چالشی وجود نداشت، پیام خطا نمایش بده
|
||
alert("چالشی برای این بخش وجود ندارد");
|
||
// بازگشت به فید
|
||
navigate(`/feed/${topicId}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
fetchMissions();
|
||
}, [topicConfig.title, topicId, navigate, handleChallengeSelectCallback]);
|
||
|
||
return (
|
||
<div className="fixed inset-0 w-full h-screen overflow-hidden">
|
||
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.challenges} />
|
||
|
||
{/* Auto-navigation Loading Overlay - برای دفترچه یادداشت */}
|
||
{autoNavigating && (
|
||
<div
|
||
className="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm"
|
||
style={{
|
||
background:
|
||
"radial-gradient(120% 120% at 50% 0%, rgba(124, 58, 237, 0.36) 0%, rgba(46, 27, 61, 0.9) 55%, rgba(35, 24, 62, 0.96) 100%)",
|
||
}}
|
||
>
|
||
<div className="text-center">
|
||
<div className="inline-block w-12 h-12 border-4 border-[#ffd6f0]/30 border-t-[#ff79cf] rounded-full animate-spin mb-3" />
|
||
<p className="text-white text-base font-bold">در حال بارگذاری چالش...</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
<div className="relative z-10 max-w-md mx-auto h-full flex flex-col">
|
||
{/* Header */}
|
||
<AppHeader showBack onBack={handleBack} centerTitle={topicConfig.title} centerSubtitle="انتخاب چالش" />
|
||
|
||
{/* Main Content - Scrollable */}
|
||
<div
|
||
className="mt-2 flex-1 overflow-y-auto px-4 pb-20"
|
||
style={{
|
||
scrollbarWidth: "none",
|
||
msOverflowStyle: "none",
|
||
}}
|
||
>
|
||
<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">
|
||
{loading ? (
|
||
/* Loading State */
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-center py-10"
|
||
>
|
||
<div className="inline-block w-10 h-10 border-4 border-[#ffd6f0]/30 border-t-[#ff79cf] rounded-full animate-spin mb-3" />
|
||
<p className="text-[#ffd6f0] text-xs">در حال بارگذاری چالشها...</p>
|
||
</motion.div>
|
||
) : missions.length === 0 ? (
|
||
/* Empty State */
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-center py-10"
|
||
>
|
||
<p className="text-teal-200 text-xs">هیچ چالشی یافت نشد</p>
|
||
</motion.div>
|
||
) : (
|
||
/* Missions from Server */
|
||
missions.map((mission, index) => (
|
||
<motion.div
|
||
key={mission.mission_workflowID}
|
||
initial={{ x: -80, opacity: 0 }}
|
||
animate={{ x: 0, opacity: 1 }}
|
||
transition={{ delay: 0.3 + index * 0.1, duration: 0.4 }}
|
||
className="relative"
|
||
>
|
||
<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="h-full w-full rounded-[15px] object-cover"
|
||
loading="eager"
|
||
onError={(e) => {
|
||
// Fallback به آیکون پیشفرض در صورت خطا
|
||
e.currentTarget.src = challengeIcon;
|
||
e.currentTarget.style.objectFit = "contain";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative z-10 min-w-0 flex-1 text-right">
|
||
<div className="mb-1 flex items-center justify-start gap-2">
|
||
<h3 className="truncate text-[18px] font-extrabold leading-7 text-white">
|
||
{mission.title}
|
||
</h3>
|
||
<span
|
||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-[0.5px] border-transparent text-[#ffb7dd]"
|
||
style={arrowButtonStyle}
|
||
>
|
||
<Star size={13} fill="currentColor" strokeWidth={1.5} />
|
||
</span>
|
||
</div>
|
||
<p
|
||
className="mb-3 text-[12px] font-medium leading-5 text-[#F4EAF6]/88"
|
||
style={{
|
||
display: "-webkit-box",
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: "vertical",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{mission.description}
|
||
</p>
|
||
|
||
<div className="flex w-full flex-nowrap items-center justify-between gap-1 text-[10px] font-normal leading-none text-white/90">
|
||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||
<span className="leading-none">{toPersianNumber(mission.coin_count, "250")}</span>
|
||
<img src={coinImage} alt="سکه" className="h-4 w-4 shrink-0 object-contain" />
|
||
</span>
|
||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||
<span className="leading-none">{mission.difficulty || "متوسط"}</span>
|
||
<DifficultySignalIcon />
|
||
</span>
|
||
<span className="inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap px-0">
|
||
<Clock3 size={13} className="shrink-0 text-[#ffb7dd]" strokeWidth={2.1} />
|
||
<span className="leading-none">{formatMissionDuration(mission.duration)}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<span
|
||
className="relative z-10 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full border-[0.5px] border-transparent text-white"
|
||
style={headerBackButtonStyle}
|
||
aria-hidden="true"
|
||
>
|
||
<ChevronLeft size={22} color="#ffffff" />
|
||
</span>
|
||
</div>
|
||
</motion.button>
|
||
</motion.div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom Navigation */}
|
||
<BottomNav />
|
||
|
||
<style>{`
|
||
.flex-1.overflow-y-auto::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|