nahaie 2 khordad

This commit is contained in:
reza7321 2026-05-23 12:45:11 +03:30
parent a535e43e81
commit a1118cb2fc
18 changed files with 1196 additions and 1231 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

1
dist/assets/index-BINX1_Nm.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

381
dist/assets/index-zxxbrZom.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -16,8 +16,8 @@
background: #23183E; background: #23183E;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-Dvo_8Nz_.js"></script> <script type="module" crossorigin src="/assets/index-zxxbrZom.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5jzgFDg.css"> <link rel="stylesheet" crossorigin href="/assets/index-BINX1_Nm.css">
</head> </head>
<body> <body>

View File

@ -12,6 +12,7 @@ export function AnimatedOutlet() {
const location = useLocation(); const location = useLocation();
const outlet = useOutlet(); const outlet = useOutlet();
const isPublicChatPage = location.pathname === "/public-chat"; const isPublicChatPage = location.pathname === "/public-chat";
const isEditProfilePage = location.pathname === "/edit-profile";
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const previousPathRef = useRef(location.pathname); const previousPathRef = useRef(location.pathname);
const pageRef = useRef<HTMLDivElement | null>(null); const pageRef = useRef<HTMLDivElement | null>(null);
@ -75,6 +76,11 @@ export function AnimatedOutlet() {
paddingRight: "0px", paddingRight: "0px",
paddingBottom: "0px", paddingBottom: "0px",
} }
: isEditProfilePage
? {
willChange: "opacity, transform",
paddingBottom: "0px",
}
: { willChange: "opacity, transform" } : { willChange: "opacity, transform" }
} }
> >

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus, SquarePen } from "lucide-react";
import { useProfile } from "../context/ProfileContext"; import { useProfile } from "../context/ProfileContext";
import { getCachedProfile } from "../../services/profileService"; import { getCachedProfile } from "../../services/profileService";
import { getProfileImageUrl } from "../../services/feedService"; import { getProfileImageUrl } from "../../services/feedService";
@ -13,6 +13,8 @@ interface AppHeaderProps {
onBack?: () => void; onBack?: () => void;
centerTitle?: string; centerTitle?: string;
centerSubtitle?: string; centerSubtitle?: string;
useNewChatAction?: boolean;
onNewChatClick?: () => void;
} }
const toPersianNumber = (num: number | null | undefined): string => { const toPersianNumber = (num: number | null | undefined): string => {
@ -21,7 +23,14 @@ const toPersianNumber = (num: number | null | undefined): string => {
return String(num).replace(/\d/g, (digit) => persianDigits[parseInt(digit, 10)]); return String(num).replace(/\d/g, (digit) => persianDigits[parseInt(digit, 10)]);
}; };
export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitle }: AppHeaderProps) { export function AppHeader({
showBack = false,
onBack,
centerTitle,
centerSubtitle,
useNewChatAction = false,
onNewChatClick,
}: AppHeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { profile } = useProfile(); const { profile } = useProfile();
@ -44,6 +53,9 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
return profileFallbackImage; return profileFallbackImage;
}, [hasCustomAvatar, profile?.image, profile?.user_stage_id]); }, [hasCustomAvatar, profile?.image, profile?.user_stage_id]);
const displayName = `${profile?.name || ""} ${profile?.family || ""}`.trim() || profile?.username || "همراه";
const displayGrade = profile?.base ? `پایه ${profile.base}` : profile?.education_level || "";
const navLikePanelStyle = { const navLikePanelStyle = {
backgroundImage: ` backgroundImage: `
linear-gradient(180deg, #2E1B3D 0%, #23183E 100%), linear-gradient(180deg, #2E1B3D 0%, #23183E 100%),
@ -58,11 +70,43 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
} as const; } as const;
return ( return (
<div className="relative z-20 flex items-center justify-between px-4 pt-3 pb-0" dir="rtl"> <div className="relative z-20 h-[68px] min-h-[68px] px-4" dir="rtl">
{useNewChatAction ? (
<button
type="button"
onClick={onNewChatClick}
className="absolute left-4 top-1/2 z-20 flex h-10 w-fit -translate-y-1/2 items-center gap-1.5 rounded-full border-[0.5px] border-transparent px-2.5 text-xs font-bold text-white"
style={navLikePanelStyle}
aria-label="چت جدید"
>
<SquarePen size={15} color="#facc15" />
<span className="leading-none">چت جدید</span>
</button>
) : (
<div
className="absolute left-4 top-1/2 z-20 flex h-10 w-fit -translate-y-1/2 items-center gap-[6px] rounded-full border-[0.5px] border-transparent px-2.5"
style={navLikePanelStyle}
>
<div
className="flex h-5 w-5 items-center justify-center rounded-full border-[0.5px] border-transparent"
style={navLikePanelStyle}
>
<Plus size={11} color="#ffd6f0" strokeWidth={2.25} />
</div>
<span
className="font-semibold leading-none text-white tracking-tight"
style={{ fontSize: 16 }}
>
{toPersianNumber(displayCoins)}
</span>
<img src={coinImage} alt="سکه" className="-my-0.5 h-7 w-7 object-contain" />
</div>
)}
{showBack ? ( {showBack ? (
<button <button
onClick={onBack} onClick={onBack}
className="w-11 h-11 rounded-full flex items-center justify-center border-[0.5px] border-transparent" className="absolute right-4 top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border-[0.5px] border-transparent"
aria-label="بازگشت" aria-label="بازگشت"
style={navLikePanelStyle} style={navLikePanelStyle}
> >
@ -71,39 +115,56 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
) : ( ) : (
<button <button
onClick={() => navigate("/profile")} onClick={() => navigate("/profile")}
className="relative w-12 h-12 rounded-full p-[2px] border-[0.5px] border-transparent overflow-hidden" className="absolute right-4 top-1/2 z-20 flex max-w-[148px] -translate-y-1/2 items-center gap-2 text-right"
aria-label="پروفایل" aria-label="پروفایل"
>
<span
className="h-12 w-12 shrink-0 overflow-hidden rounded-full border-[0.5px] border-transparent p-[2px]"
style={navLikePanelStyle} style={navLikePanelStyle}
> >
{hasCustomAvatar ? ( {hasCustomAvatar ? (
<img <img
src={avatarUrl} src={avatarUrl}
alt="پروفایل" alt="پروفایل"
className="w-full h-full rounded-full object-cover" className="h-full w-full rounded-full object-cover"
onError={(event) => { onError={(event) => {
event.currentTarget.src = profileFallbackImage; event.currentTarget.src = profileFallbackImage;
}} }}
/> />
) : ( ) : (
<div <span
className="w-full h-full rounded-full flex items-center justify-center" className="flex h-full w-full items-center justify-center rounded-full"
style={navLikePanelStyle} style={navLikePanelStyle}
> >
<img <img
src={profileFallbackImage} src={profileFallbackImage}
alt="پروفایل پیش‌فرض" alt="پروفایل پیش‌فرض"
className=" object-cover rounded-full" className="rounded-full object-cover"
/> />
</div> </span>
)} )}
</span>
<span className="min-w-0 leading-none">
<span className="block max-w-[86px] truncate text-[12px] font-extrabold leading-4 text-[#FBE7F5]">
{displayName}
</span>
{displayGrade && (
<span className="mt-0.5 block max-w-[86px] truncate text-[10px] font-medium leading-3 text-[#F2DFF0]/72">
{displayGrade}
</span>
)}
</span>
</button> </button>
)} )}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center text-center translate-y-1">
{centerTitle ? (
<div className="leading-none">
<div <div
className="font-extrabold text-[22px]" className="pointer-events-none absolute left-1/2 top-1/2 z-10 flex w-[min(170px,42vw)] -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center"
>
{centerTitle ? (
<div className="w-full">
<div
className="mx-auto max-w-full truncate font-extrabold text-[18px] leading-6"
style={{ style={{
display: "inline-block", display: "inline-block",
background: background:
@ -119,7 +180,7 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
</div> </div>
{centerSubtitle && ( {centerSubtitle && (
<div <div
className="mt-1 text-[11px] font-medium" className="-mt-2 text-[11px] font-medium"
style={{ style={{
color: "#ffb7dd", color: "#ffb7dd",
textShadow: "0 1px 6px rgba(255, 119, 202, 0.35)", textShadow: "0 1px 6px rgba(255, 119, 202, 0.35)",
@ -138,25 +199,6 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
/> />
)} )}
</div> </div>
<div
className="relative h-10 w-fit rounded-full px-2.5 flex items-center gap-[6px] border-[0.5px] border-transparent"
style={navLikePanelStyle}
>
<div
className="w-5 h-5 rounded-full flex items-center justify-center border-[0.5px] border-transparent"
style={navLikePanelStyle}
>
<Plus size={11} color="#ffd6f0" strokeWidth={2.25} />
</div>
<span
className="font-semibold leading-none text-white tracking-tight"
style={{ fontSize: 16 }}
>
{toPersianNumber(displayCoins)}
</span>
<img src={coinImage} alt="سکه" className="w-7 h-7 object-contain -my-0.5" />
</div>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ArrowRight, Save } from "lucide-react"; import { Save, ChevronDown } from "lucide-react";
import { getUserProfile, saveUserProfile, getCachedProfile, type UserProfile } from "../../services/profileService"; import { getUserProfile, saveUserProfile, getCachedProfile, type UserProfile } from "../../services/profileService";
import { getUsername } from "../../utils/auth"; import { getUsername } from "../../utils/auth";
import { usePageTracking } from "../../hooks/usePageTracking"; import { usePageTracking } from "../../hooks/usePageTracking";
@ -164,138 +164,104 @@ export function EditProfilePage() {
// Loading state // Loading state
if (isLoading) { if (isLoading) {
return ( return (
<div <div className="flex h-full min-h-0 items-center justify-center" dir="rtl">
className="min-h-screen flex items-center justify-center" <div className="text-center">
style={{ <div className="mx-auto mb-3 h-10 w-10 animate-spin rounded-full border-4 border-[#ffd6f0]/30 border-t-[#ff79cf]" />
background: <p className="text-sm font-bold text-[#FBE7F5]">در حال بارگذاری پروفایل...</p>
"radial-gradient(120% 120% at 50% 0%, rgba(124, 58, 237, 0.32) 0%, rgba(46, 27, 61, 0.98) 52%, rgba(35, 24, 62, 1) 100%)", </div>
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<motion.div
className="w-16 h-16 border-4 rounded-full mx-auto mb-4"
style={{
borderColor: "rgba(255, 214, 240, 0.28)",
borderTopColor: "#ff79cf",
}}
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
<p
className="text-lg font-bold"
style={{
color: "#ffd6f0",
textShadow: "0 2px 4px rgba(0, 0, 0, 0.6)",
}}
dir="rtl"
>
در حال بارگذاری پروفایل...
</p>
</motion.div>
</div> </div>
); );
} }
return ( return (
<div className="max-w-2xl mx-auto px-6 py-8" dir="rtl"> <div className="relative h-full min-h-0 overflow-hidden" dir="rtl">
{/* Header */} <main
<div className="flex items-center gap-4 mb-8"> className="h-full overflow-y-auto px-4 pb-4 pt-4"
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => navigate("/profile")}
className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.6) 0%, rgba(20, 40, 60, 0.6) 100%)", maxHeight: "100%",
border: "2px solid rgba(138, 206, 224, 0.3)",
}}
>
<ArrowRight className="text-white" size={20} />
</motion.button>
<h1
className="text-2xl font-bold text-white"
style={{
textShadow: "0 4px 12px rgba(138, 206, 224, 0.6), 0 2px 4px rgba(0, 0, 0, 0.8)",
}} }}
> >
<div className="mx-auto w-full max-w-md">
<div className="mb-4 text-center">
<h1 className="text-[20px] font-extrabold text-[#FBE7F5]">
{userProfile?.user_workflowID ? "ویرایش پروفایل" : "تکمیل پروفایل"} {userProfile?.user_workflowID ? "ویرایش پروفایل" : "تکمیل پروفایل"}
</h1> </h1>
<p className="mt-1 text-xs text-[#EED3EC]/82">اطلاعات پایه حساب کاربری را ثبت یا بهروزرسانی کنید</p>
</div> </div>
{/* Success/Info Message */}
{successMessage && ( {successMessage && (
<motion.div <motion.div
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 rounded-xl text-center font-bold" className="mb-4 rounded-2xl px-4 py-3 text-center text-sm font-bold"
style={{ style={{
background: "linear-gradient(135deg, rgba(76, 175, 80, 0.2) 0%, rgba(56, 142, 60, 0.2) 100%)", backgroundImage:
border: "2px solid rgba(76, 175, 80, 0.4)", "linear-gradient(180deg, rgba(24, 67, 46, 0.88) 0%, rgba(16, 56, 39, 0.92) 100%), linear-gradient(120deg, #34d399 0%, #10b981 100%)",
color: "#A7F3D0", backgroundOrigin: "border-box",
textShadow: "0 2px 4px rgba(0, 0, 0, 0.6)", backgroundClip: "padding-box, border-box",
border: "1px solid rgba(167, 243, 208, 0.4)",
color: "#D1FAE5",
}} }}
> >
{successMessage} {successMessage}
</motion.div> </motion.div>
)} )}
{/* Form */} <form id="edit-profile-form" onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-6"> <div
{/* Name */} className="rounded-[22px] border-[0.5px] border-transparent px-4 py-4"
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.36), 0 8px 16px rgba(5, 2, 12, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
}}
>
<div className="space-y-4">
<div> <div>
<label className="block text-white mb-2 text-sm font-bold">نام</label> <label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">نام</label>
<input <input
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => handleChange("name", e.target.value)} onChange={(e) => handleChange("name", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold" className="w-full rounded-xl px-3 py-2.5 text-sm font-bold text-white outline-none"
style={{ style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)", background: "rgba(255,255,255,0.08)",
border: errors.name ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)", border: errors.name ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
outline: "none",
}} }}
placeholder="نام خود را وارد کنید" placeholder="نام خود را وارد کنید"
/> />
{errors.name && ( {errors.name && <p className="mt-1 text-xs text-red-300">{errors.name}</p>}
<p className="text-red-400 text-xs mt-1">{errors.name}</p>
)}
</div> </div>
{/* Family */}
<div> <div>
<label className="block text-white mb-2 text-sm font-bold">نام خانوادگی</label> <label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">نام خانوادگی</label>
<input <input
type="text" type="text"
value={formData.family} value={formData.family}
onChange={(e) => handleChange("family", e.target.value)} onChange={(e) => handleChange("family", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold" className="w-full rounded-xl px-3 py-2.5 text-sm font-bold text-white outline-none"
style={{ style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)", background: "rgba(255,255,255,0.08)",
border: errors.family ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)", border: errors.family ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
outline: "none",
}} }}
placeholder="نام خانوادگی خود را وارد کنید" placeholder="نام خانوادگی خود را وارد کنید"
/> />
{errors.family && ( {errors.family && <p className="mt-1 text-xs text-red-300">{errors.family}</p>}
<p className="text-red-400 text-xs mt-1">{errors.family}</p>
)}
</div> </div>
{/* Education Level */}
<div> <div>
<label className="block text-white mb-2 text-sm font-bold">مقطع تحصیلی</label> <label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">مقطع تحصیلی</label>
<div className="relative">
<select <select
value={formData.education_level} value={formData.education_level}
onChange={(e) => handleChange("education_level", e.target.value)} onChange={(e) => handleChange("education_level", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold" className="w-full appearance-none rounded-xl px-3 py-2.5 pl-10 text-sm font-bold text-white outline-none"
style={{ style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)", background: "rgba(255,255,255,0.08)",
border: errors.education_level ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)", border: errors.education_level ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
outline: "none",
}} }}
> >
<option value="">انتخاب کنید</option> <option value="">انتخاب کنید</option>
@ -303,22 +269,23 @@ export function EditProfilePage() {
<option value="متوسطه اول">متوسطه اول</option> <option value="متوسطه اول">متوسطه اول</option>
<option value="متوسطه دوم">متوسطه دوم</option> <option value="متوسطه دوم">متوسطه دوم</option>
</select> </select>
{errors.education_level && ( <ChevronDown
<p className="text-red-400 text-xs mt-1">{errors.education_level}</p> className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#F2DFF0]/72"
)} />
</div>
{errors.education_level && <p className="mt-1 text-xs text-red-300">{errors.education_level}</p>}
</div> </div>
{/* Base (Grade) */}
<div> <div>
<label className="block text-white mb-2 text-sm font-bold">پایه تحصیلی</label> <label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">پایه تحصیلی</label>
<div className="relative">
<select <select
value={formData.base} value={formData.base}
onChange={(e) => handleChange("base", e.target.value)} onChange={(e) => handleChange("base", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold" className="w-full appearance-none rounded-xl px-3 py-2.5 pl-10 text-sm font-bold text-white outline-none"
style={{ style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)", background: "rgba(255,255,255,0.08)",
border: errors.base ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)", border: errors.base ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
outline: "none",
}} }}
> >
<option value="">انتخاب کنید</option> <option value="">انتخاب کنید</option>
@ -347,33 +314,39 @@ export function EditProfilePage() {
</> </>
)} )}
</select> </select>
{errors.base && ( <ChevronDown
<p className="text-red-400 text-xs mt-1">{errors.base}</p> className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#F2DFF0]/72"
)} />
</div>
{errors.base && <p className="mt-1 text-xs text-red-300">{errors.base}</p>}
</div>
</div>
</div> </div>
{/* Submit Button */}
<motion.button <motion.button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
whileHover={{ scale: isSaving ? 1 : 1.02 }}
whileTap={{ scale: isSaving ? 1 : 0.98 }} whileTap={{ scale: isSaving ? 1 : 0.98 }}
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-2xl font-bold text-lg" className="flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-bold text-white"
style={{ style={{
background: isSaving backgroundImage: isSaving
? "linear-gradient(135deg, rgba(60, 140, 150, 0.7) 0%, rgba(40, 100, 110, 0.7) 100%)" ? "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)"
: "linear-gradient(135deg, rgba(76, 175, 80, 0.9) 0%, rgba(56, 142, 60, 0.9) 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%)",
boxShadow: "0 12px 32px rgba(76, 175, 80, 0.4), 0 4px 12px rgba(0, 0, 0, 0.4)", backgroundOrigin: "border-box",
color: "#FFFFFF", backgroundClip: "padding-box, border-box",
textShadow: "0 2px 4px rgba(0, 0, 0, 0.5)", border: "1px solid transparent",
boxShadow: isSaving
? "inset 0 1px 0 rgba(255,255,255,0.12)"
: "0 10px 24px rgba(196, 87, 255, 0.32), inset 0 1px 0 rgba(255,255,255,0.3)",
opacity: isSaving ? 0.7 : 1, opacity: isSaving ? 0.7 : 1,
cursor: isSaving ? "not-allowed" : "pointer", cursor: isSaving ? "not-allowed" : "pointer",
}} }}
> >
<Save size={24} /> <Save size={16} />
<span>{isSaving ? "در حال ذخیره..." : "ذخیره اطلاعات"}</span> <span>{isSaving ? "در حال ذخیره..." : "ذخیره اطلاعات"}</span>
</motion.button> </motion.button>
</form> </form>
</div> </div>
</main>
</div>
); );
} }

View File

@ -22,15 +22,22 @@ const headerActionStyle = {
WebkitBackdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)",
} as const; } as const;
export function Header({ showBack = false, onBack, action, onActionClick }: HeaderProps) { export function Header({ title, showBack = false, onBack, action, onActionClick }: HeaderProps) {
const shouldShowCenterTitle =
title === "اعلان‌ها" || title === "پروفایل" || title === "کیف جادویی";
return ( return (
<header className="app-header relative"> <header className="app-header relative">
<AppHeader showBack={showBack} onBack={onBack} /> <AppHeader
showBack={showBack}
onBack={onBack}
centerTitle={shouldShowCenterTitle ? title : undefined}
/>
{action === "history" && onActionClick && ( {action === "history" && onActionClick && (
<button <button
type="button" type="button"
onClick={onActionClick} 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" className="absolute right-[60px] top-1/2 z-30 flex h-10 -translate-y-1/2 items-center rounded-full border-[0.5px] border-transparent px-3 text-[11px] font-bold text-white"
style={headerActionStyle} style={headerActionStyle}
aria-label="تاریخچه چت" aria-label="تاریخچه چت"
> >

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { ShoppingBag, Package, Lock, X, Download } from "lucide-react"; import { ShoppingBag, Package, Lock, X, Download } from "lucide-react";
import coinImage from "figma:asset/f7664d355c12b1003ad460ff44c8f22cfb1bbf5a.png"; import coinImage from "../../assets/coin-star.png";
import itemImage from "figma:asset/0469c3ac6223dede16e9f8943a3cac9943835707.png"; import itemImage from "figma:asset/0469c3ac6223dede16e9f8943a3cac9943835707.png";
import { import {
loadMagicBagMissions, loadMagicBagMissions,
@ -105,7 +105,7 @@ const purchasedItems: Item[] = [
export function MagicBagPage() { export function MagicBagPage() {
usePageTracking("کیف جادویی"); usePageTracking("کیف جادویی");
const [activeTab, setActiveTab] = useState<"shop" | "owned">("shop"); const [activeTab, setActiveTab] = useState<"shop" | "owned">("owned");
const [missionItems, setMissionItems] = useState<MagicBagMissionItem[]>([]); const [missionItems, setMissionItems] = useState<MagicBagMissionItem[]>([]);
const [loadingMissions, setLoadingMissions] = useState(false); const [loadingMissions, setLoadingMissions] = useState(false);
const [selectedImage, setSelectedImage] = useState<{ url: string; title: string } | null>(null); const [selectedImage, setSelectedImage] = useState<{ url: string; title: string } | null>(null);
@ -134,54 +134,51 @@ export function MagicBagPage() {
return ( return (
<div className="pt-6 pb-2" dir="rtl"> <div className="pt-6 pb-2" dir="rtl">
{/* Tab Switcher */} {/* Tab Switcher */}
<div className="mb-4 flex gap-2"> <div
<motion.button className="mb-4 flex rounded-2xl p-1"
whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("shop")}
className="flex-1 py-2.5 rounded-2xl font-bold text-sm flex items-center justify-center gap-1.5"
style={{ style={{
background: activeTab === "shop" backgroundImage:
? "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)" "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%)",
: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)", backgroundOrigin: "border-box",
boxShadow: activeTab === "shop" backgroundClip: "padding-box, border-box",
? "0 6px 18px rgba(255, 165, 0, 0.5)" border: "0.5px solid transparent",
: "0 3px 10px rgba(0, 0, 0, 0.3)",
border: activeTab === "shop"
? "1.5px solid rgba(255, 200, 50, 0.5)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
color: activeTab === "shop" ? "#5A3800" : "#FFFFFF",
textShadow: activeTab === "shop"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none",
}} }}
> >
<ShoppingBag className="w-4 h-4" />
فروشگاه
</motion.button>
<motion.button <motion.button
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("owned")} onClick={() => setActiveTab("owned")}
className="flex-1 py-2.5 rounded-2xl font-bold text-sm flex items-center justify-center gap-1.5" className="flex-1 flex items-center justify-center gap-1.5 rounded-xl py-2.5 text-sm font-bold transition-all duration-300"
style={{ style={{
background: activeTab === "owned" background: activeTab === "owned"
? "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)" ? "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(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)", : "transparent",
boxShadow: activeTab === "owned" boxShadow: activeTab === "owned"
? "0 6px 18px rgba(255, 165, 0, 0.5)" ? "0 0 18px rgba(155,108,241,0.4)"
: "0 3px 10px rgba(0, 0, 0, 0.3)",
border: activeTab === "owned"
? "1.5px solid rgba(255, 200, 50, 0.5)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
color: activeTab === "owned" ? "#5A3800" : "#FFFFFF",
textShadow: activeTab === "owned"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none", : "none",
color: activeTab === "owned" ? "#FFFFFF" : "rgba(251,231,245,0.72)",
}} }}
> >
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
ایتمهای من ایتمهای من
</motion.button> </motion.button>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("shop")}
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl py-2.5 text-sm font-bold transition-all duration-300"
style={{
background: activeTab === "shop"
? "linear-gradient(135deg, rgba(174, 117, 255, 0.96) 0%, rgba(138, 82, 238, 0.95) 46%, rgba(102, 55, 204, 0.94) 100%)"
: "transparent",
boxShadow: activeTab === "shop"
? "0 0 18px rgba(155,108,241,0.4)"
: "none",
color: activeTab === "shop" ? "#FFFFFF" : "rgba(251,231,245,0.72)",
}}
>
<ShoppingBag className="w-4 h-4" />
فروشگاه
</motion.button>
</div> </div>
{/* Shop Tab */} {/* Shop Tab */}
@ -235,20 +232,20 @@ export function MagicBagPage() {
className="absolute top-0 left-0 flex items-center gap-1 px-2 py-1 rounded-full" className="absolute top-0 left-0 flex items-center gap-1 px-2 py-1 rounded-full"
style={{ style={{
background: canAfford background: canAfford
? "linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 152, 0, 0.95) 100%)" ? "linear-gradient(135deg, rgba(44, 34, 66, 0.96) 0%, rgba(30, 22, 50, 0.98) 100%)"
: "linear-gradient(135deg, rgba(107, 114, 128, 0.95) 0%, rgba(75, 85, 99, 0.95) 100%)", : "linear-gradient(135deg, rgba(68, 74, 88, 0.96) 0%, rgba(53, 58, 71, 0.98) 100%)",
border: `1.5px solid ${canAfford ? "rgba(255, 200, 50, 0.8)" : "rgba(107, 114, 128, 0.8)"}`, border: `1.5px solid ${canAfford ? "rgba(255, 214, 240, 0.45)" : "rgba(148, 163, 184, 0.45)"}`,
boxShadow: canAfford boxShadow: canAfford
? "0 2px 8px rgba(255, 193, 7, 0.6)" ? "0 2px 8px rgba(15, 10, 25, 0.6)"
: "0 2px 8px rgba(0, 0, 0, 0.4)", : "0 2px 8px rgba(0, 0, 0, 0.4)",
}} }}
> >
<img src={coinImage} alt="سکه" className="w-3.5 h-3.5" /> <img src={coinImage} alt="سکه" className="w-4 h-4" />
<span <span
className="text-[10px] font-bold" className="text-[10px] font-bold"
style={{ style={{
color: canAfford ? "#5A3800" : "rgba(255, 255, 255, 0.7)", color: canAfford ? "#FFF3CC" : "rgba(255, 255, 255, 0.74)",
textShadow: canAfford ? "0 1px 0 rgba(255, 255, 255, 0.3)" : "none", textShadow: "none",
}} }}
> >
{item.price} {item.price}
@ -419,14 +416,7 @@ export function MagicBagPage() {
{/* Divider */} {/* Divider */}
{missionItems.length > 0 && purchasedItems.length > 0 && ( {missionItems.length > 0 && purchasedItems.length > 0 && (
<div className="mb-6 flex items-center gap-3"> <div className="my-5 h-px w-full bg-white/20" />
<div
className="flex-1 h-[1px]"
style={{
background: "linear-gradient(90deg, transparent 0%, rgba(138, 206, 224, 0.3) 50%, transparent 100%)",
}}
/>
</div>
)} )}
{/* Section: خریداری شده */} {/* Section: خریداری شده */}

View File

@ -1,25 +1,23 @@
import { useNavigate } from "react-router-dom";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useCallback, useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { usePageTracking } from "../../hooks/usePageTracking"; import { usePageTracking } from "../../hooks/usePageTracking";
import { useInbox } from "../context/InboxContext"; import { useInbox } from "../context/InboxContext";
import { FeedHeader } from "./feed/FeedHeader";
import { getMessageStyle } from "../../utils/messageUtils"; const cardStyle = {
import { AppBackground } from "./shared/AppBackground"; backgroundImage:
import { backgroundImages } from "../../config/backgroundConfig"; "linear-gradient(180deg, rgba(46, 27, 61, 0.9) 0%, rgba(35, 24, 62, 0.94) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
import { BottomNav } from "./BottomNav"; backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
border: "0.5px solid transparent",
boxShadow:
"0 -5px 16px rgba(7, 0, 18, 0.35), 0 8px 14px rgba(5, 2, 12, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
} as const;
export function MessagesPage() { export function MessagesPage() {
const navigate = useNavigate();
usePageTracking("پیام‌ها"); usePageTracking("پیام‌ها");
const { messages, loading, markMessagesAsRead } = useInbox(); const { messages, loading, markMessagesAsRead } = useInbox();
const hasMarkedAsReadRef = useRef(false); const hasMarkedAsReadRef = useRef(false);
const handleBack = useCallback(() => {
navigate("/");
}, [navigate]);
// Mark all unread messages as read when page is opened (only once)
useEffect(() => { useEffect(() => {
if (!loading && messages.length > 0 && !hasMarkedAsReadRef.current) { if (!loading && messages.length > 0 && !hasMarkedAsReadRef.current) {
const unreadStageIds = messages const unreadStageIds = messages
@ -34,123 +32,49 @@ export function MessagesPage() {
}, [loading, messages, markMessagesAsRead]); }, [loading, messages, markMessagesAsRead]);
return ( return (
<div className="min-h-screen w-full relative overflow-hidden"> <div className="flex h-full min-h-0 flex-col pb-3 pt-4">
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.messages} /> <div className="min-h-0 flex-1 overflow-y-auto px-4">
{/* Content */}
<div className="relative z-10 max-w-md mx-auto">
{/* Header */}
<FeedHeader topicTitle="پیام‌ها" onBack={handleBack} />
{/* Messages List - Scrollable */}
<div
className="fixed top-0 left-0 right-0 bottom-0 max-w-md mx-auto overflow-hidden"
style={{
paddingTop: "110px",
zIndex: 1,
}}
>
<div
className="h-full overflow-y-auto relative px-[20px] pt-[48px] pb-[128px]"
style={{
scrollbarWidth: "none",
msOverflowStyle: "none",
maskImage: "linear-gradient(to bottom, transparent 0%, black 60px)",
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 60px)",
}}
>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-white text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div> <div className="mx-auto mb-3 h-10 w-10 animate-spin rounded-full border-4 border-[#ffd6f0]/30 border-t-[#ff79cf]" />
<p>در حال بارگذاری پیامها...</p> <p className="text-sm font-bold text-[#FBE7F5]">در حال بارگذاری پیامها...</p>
</div> </div>
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-white text-center"> <p className="text-sm font-bold text-[#FBE7F5]/88">پیامی وجود ندارد</p>
<p className="text-lg">پیامی وجود ندارد</p>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-4" dir="rtl"> <div className="space-y-3" dir="rtl">
{messages.map((message, index) => { {messages.map((message, index) => {
const messageStyle = getMessageStyle(message.kind);
const isUnread = message.status === "خوانده نشده"; const isUnread = message.status === "خوانده نشده";
return ( return (
<motion.div <motion.article
key={`${message.user_id}-${index}`} key={`${message.user_id}-${index}`}
initial={{ opacity: 0, x: -50 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.4 }} transition={{ delay: Math.min(index * 0.05, 0.25), duration: 0.26 }}
className="relative" className="relative overflow-hidden rounded-2xl px-4 py-3"
style={cardStyle}
> >
<div <span
className="rounded-3xl p-5 backdrop-blur-sm" className="absolute inset-y-0 right-0 w-1.5"
style={{ style={{
background: messageStyle.gradient, background: isUnread
border: `1.5px solid ${messageStyle.border}`, ? "linear-gradient(180deg, rgba(255,97,152,0.95) 0%, rgba(234,71,170,0.88) 100%)"
boxShadow: messageStyle.shadow, : "linear-gradient(180deg, rgba(148,163,184,0.35) 0%, rgba(100,116,139,0.28) 100%)",
opacity: isUnread ? 1 : 0.85,
}}
>
{/* Unread Badge */}
{isUnread && (
<div
className="absolute top-3 left-3 w-3 h-3 rounded-full"
style={{
background: "linear-gradient(135deg, #FF4444 0%, #CC0000 100%)",
boxShadow: "0 0 8px rgba(255, 68, 68, 0.8)",
}} }}
/> />
)}
<div className="flex items-start gap-4"> <h3 className="pr-3 text-sm font-extrabold text-[#FBE7F5]">{message.title}</h3>
{/* Icon */} <p className="mt-1.5 pr-3 text-xs leading-6 text-[#F2DFF0]/88">{message.Message}</p>
<div </motion.article>
className="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center text-white"
style={{
background: messageStyle.gradient,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
}}
>
{messageStyle.icon}
</div>
{/* Content */}
<div className="flex-1">
<h3 className="text-white font-bold text-base mb-2">{message.title}</h3>
<p className="text-white/80 text-sm leading-relaxed">{message.Message}</p>
</div>
</div>
</div>
</motion.div>
); );
})} })}
</div> </div>
)} )}
</div> </div>
{/* Bottom fade gradient */}
<div
className="absolute bottom-0 left-0 right-0 h-16 pointer-events-none"
style={{
background:
"linear-gradient(180deg, transparent 0%, rgba(46, 27, 61, 0.82) 50%, rgba(35, 24, 62, 1) 100%)",
}}
/>
</div>
</div>
<style>{`
/* Hide scrollbar for Chrome, Safari and Opera */
.h-full.overflow-y-auto::-webkit-scrollbar {
display: none;
}
`}</style>
<BottomNav />
</div> </div>
); );
} }

View File

@ -1,14 +1,14 @@
import { LogOut, Edit2, CheckCircle2, Clock, XCircle, TrendingUp, Play, Camera } from "lucide-react"; import { LogOut, Edit2, CheckCircle2, ShieldCheck, Clock, XCircle, Play, Camera } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { motion } from "motion/react"; import { motion } from "motion/react";
import profileIcon from "../../assets/image 5.png"; import profileIcon from "../../assets/image 5.png";
import coinImage from "figma:asset/f7664d355c12b1003ad460ff44c8f22cfb1bbf5a.png"; import coinImage from "../../assets/coin-star.png";
import { logout, getUserInfo } from "../../utils/auth"; import { logout, getUserInfo } from "../../utils/auth";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getUserProfile, getCachedProfile, saveUserProfile, getUserProfileData, type UserProfile, type ProfileChallenge, type ProfileCoinTransaction, type ProfilePost } from "../../services/profileService"; import { getUserProfile, getCachedProfile, saveUserProfile, getUserProfileData, type UserProfile, type ProfileChallenge, type ProfileCoinTransaction, type ProfilePost } from "../../services/profileService";
import { PostCard } from "./PostCard"; import { PostCard } from "./PostCard";
import { AvatarSelectionModal } from "./AvatarSelectionModal"; import { AvatarSelectionModal } from "./AvatarSelectionModal";
import { getProfileImageUrl, getFeedImageUrl, getVideoUrl, getAudioUrl, isImageFile, getMagicBagFileUrl, getAvatarUrl, bumpAvatarCacheBust } from "../../services/feedService"; import { getProfileImageUrl, getFeedImageUrl, getVideoUrl, getAudioUrl, isImageFile, getMagicBagFileUrl, getAvatarUrl } from "../../services/feedService";
import { ImageWithFallback } from "./figma/ImageWithFallback"; import { ImageWithFallback } from "./figma/ImageWithFallback";
import { usePageTracking } from "../../hooks/usePageTracking"; import { usePageTracking } from "../../hooks/usePageTracking";
import { getMissionTypeToTopicId } from "../../utils/topicMapper"; import { getMissionTypeToTopicId } from "../../utils/topicMapper";
@ -19,21 +19,21 @@ const getStatusBadge = (status: string) => {
return { return {
icon: <CheckCircle2 className="w-4 h-4" />, icon: <CheckCircle2 className="w-4 h-4" />,
text: "انجام شده", text: "انجام شده",
gradient: "linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%)", gradient: "linear-gradient(135deg, rgba(32, 201, 151, 0.95) 0%, rgba(16, 185, 129, 0.95) 100%)",
border: "rgba(34, 197, 94, 0.5)", border: "rgba(110, 231, 183, 0.6)",
shadow: "0 2px 8px rgba(34, 197, 94, 0.4)", shadow: "0 2px 8px rgba(16, 185, 129, 0.35)",
}; };
} else if (status === "تایید شده") { } else if (status === "تایید شده") {
return { return {
icon: <CheckCircle2 className="w-4 h-4" />, icon: <ShieldCheck className="w-4 h-4" />,
text: "تایید شده", text: "تایید شده",
gradient: "linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%)", gradient: "linear-gradient(135deg, rgba(59, 130, 246, 0.95) 0%, rgba(37, 99, 235, 0.95) 100%)",
border: "rgba(59, 130, 246, 0.5)", border: "rgba(147, 197, 253, 0.62)",
shadow: "0 2px 8px rgba(59, 130, 246, 0.4)", shadow: "0 2px 8px rgba(37, 99, 235, 0.35)",
}; };
} else if (status === "در حال انجام") { } else if (status === "در حال انجام") {
return { return {
icon: <Play className="w-4 h-4" />, icon: <Play className="w-4 h-4 rotate-180" />,
text: "در حال انجام", text: "در حال انجام",
gradient: "linear-gradient(135deg, rgba(255, 193, 7, 0.9) 0%, rgba(255, 160, 0, 0.9) 100%)", gradient: "linear-gradient(135deg, rgba(255, 193, 7, 0.9) 0%, rgba(255, 160, 0, 0.9) 100%)",
border: "rgba(255, 193, 7, 0.5)", border: "rgba(255, 193, 7, 0.5)",
@ -61,7 +61,7 @@ const getStatusBadge = (status: string) => {
export function ProfilePage() { export function ProfilePage() {
const navigate = useNavigate(); const navigate = useNavigate();
usePageTracking("پروفایل"); usePageTracking("پروفایل");
const { refreshProfile } = useProfile(); const { profile, refreshProfile } = useProfile();
const [userInfo, setUserInfo] = useState<{ Name: string; Family: string; Username: string } | null>(null); const [userInfo, setUserInfo] = useState<{ Name: string; Family: string; Username: string } | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null); const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
@ -81,6 +81,12 @@ export function ProfilePage() {
loadProfile(); loadProfile();
}, []); }, []);
useEffect(() => {
if (profile) {
setUserProfile(profile);
}
}, [profile]);
const loadProfile = async () => { const loadProfile = async () => {
setIsLoadingProfile(true); setIsLoadingProfile(true);
try { try {
@ -138,6 +144,28 @@ export function ProfilePage() {
return String(num).replace(/\d/g, (digit) => persianDigits[parseInt(digit)]); return String(num).replace(/\d/g, (digit) => persianDigits[parseInt(digit)]);
}; };
const toPersianDigitsInText = (value: string | null | undefined): string => {
if (!value) return "";
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
return value.replace(/\d/g, (digit) => persianDigits[parseInt(digit, 10)]);
};
const getChallengeAccent = (status: string): string => {
if (status === "انجام شده") {
return "linear-gradient(180deg, #7EF7C5 0%, #4BCF9F 100%)";
}
if (status === "تایید شده") {
return "linear-gradient(180deg, #87D6FF 0%, #5DAEFF 100%)";
}
if (status === "در حال انجام") {
return "linear-gradient(180deg, #FFD994 0%, #F5B14A 100%)";
}
if (status === "رد شده") {
return "linear-gradient(180deg, #FF8A94 0%, #EF4444 100%)";
}
return "linear-gradient(180deg, #F6A6DA 0%, #D777BE 100%)";
};
const handleAvatarSelect = async (imageFilename: string) => { const handleAvatarSelect = async (imageFilename: string) => {
// imageFilename از AvatarSelectionModal نام فایل آپلود شده به سرور است // imageFilename از AvatarSelectionModal نام فایل آپلود شده به سرور است
console.log("handleAvatarSelect called with:", imageFilename); console.log("handleAvatarSelect called with:", imageFilename);
@ -165,9 +193,6 @@ export function ProfilePage() {
console.log("Saving profile with data:", JSON.stringify(saveData)); console.log("Saving profile with data:", JSON.stringify(saveData));
const result = await saveUserProfile(saveData); const result = await saveUserProfile(saveData);
console.log("Save profile result:", result); console.log("Save profile result:", result);
if (result) {
bumpAvatarCacheBust();
}
// بارگذاری مجدد پروفایل از سرور // بارگذاری مجدد پروفایل از سرور
console.log("Reloading profile from server..."); console.log("Reloading profile from server...");
@ -200,6 +225,14 @@ export function ProfilePage() {
WebkitBackdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)",
} as const; } as const;
const segmentedControlStyle = {
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",
} as const;
return ( return (
<div <div
className="pt-6 pb-2" className="pt-6 pb-2"
@ -212,20 +245,45 @@ export function ProfilePage() {
className="flex flex-col items-center mb-4" className="flex flex-col items-center mb-4"
> >
{/* Avatar with Edit Button */} {/* Avatar with Edit Button */}
<div className="relative mb-3"> <div className="mb-3 grid w-full grid-cols-3 items-center">
<div className="flex justify-center">
<motion.button
whileTap={{ scale: isLoggingOut ? 1 : 0.92 }}
onClick={handleLogout}
disabled={isLoggingOut}
aria-label={isLoggingOut ? "در حال خروج" : "خروج"}
className="flex h-12 min-w-[92px] items-center justify-center gap-1.5 rounded-full px-3"
style={{
background: isLoggingOut
? "linear-gradient(145deg, rgba(226, 84, 142, 0.72) 0%, rgba(196, 57, 109, 0.72) 100%)"
: "linear-gradient(145deg, rgba(255, 93, 143, 0.95) 0%, rgba(236, 63, 122, 0.95) 52%, rgba(207, 40, 98, 0.95) 100%)",
boxShadow: isLoggingOut
? "0 6px 12px rgba(170, 34, 79, 0.3), inset 0 1px 0 rgba(255,255,255,0.18)"
: "0 10px 18px rgba(207, 40, 98, 0.36), inset 0 1px 0 rgba(255,255,255,0.26)",
border: "1px solid rgba(255, 188, 214, 0.5)",
opacity: isLoggingOut ? 0.7 : 1,
cursor: isLoggingOut ? "not-allowed" : "pointer",
}}
>
<LogOut size={16} color="#fff" />
<span className="text-xs font-bold text-white">{isLoggingOut ? "خروج..." : "خروج"}</span>
</motion.button>
</div>
<div className="relative mx-auto">
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.5 }} transition={{ type: "spring", duration: 0.5 }}
className="w-24 h-24 rounded-full p-[2px]" className="h-24 w-24 rounded-full p-[2px]"
style={navLikePanelStyle} style={navLikePanelStyle}
> >
<div className="w-full h-full rounded-full overflow-hidden flex items-center justify-center" style={navLikePanelStyle}> <div className="flex h-full w-full items-center justify-center overflow-hidden rounded-full" style={navLikePanelStyle}>
{userProfile?.image ? ( {userProfile?.image ? (
<ImageWithFallback <ImageWithFallback
src={getProfileImageUrl(userProfile.image, userProfile.user_stage_id)} src={getProfileImageUrl(userProfile.image, userProfile.user_stage_id)}
alt="پروفایل" alt="پروفایل"
className="w-full h-full rounded-full object-cover" className="h-full w-full rounded-full object-cover"
fallbackSrc={profileIcon} fallbackSrc={profileIcon}
style={{ style={{
filter: "drop-shadow(0 3px 6px rgba(138, 206, 224, 0.6))", filter: "drop-shadow(0 3px 6px rgba(138, 206, 224, 0.6))",
@ -235,7 +293,7 @@ export function ProfilePage() {
<img <img
src={profileIcon} src={profileIcon}
alt="پروفایل" alt="پروفایل"
className="w-[84px] h-[84px] object-cover rounded-full" className="h-[84px] w-[84px] rounded-full object-cover"
/> />
)} )}
</div> </div>
@ -246,17 +304,40 @@ export function ProfilePage() {
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
onClick={() => setShowAvatarModal(true)} onClick={() => setShowAvatarModal(true)}
className="absolute bottom-0 right-0 w-8 h-8 rounded-full flex items-center justify-center" className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-full"
style={{ style={{
background: "linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 152, 0, 0.95) 100%)", background: "linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 152, 0, 0.95) 100%)",
boxShadow: "0 3px 10px rgba(255, 193, 7, 0.6), 0 0 16px rgba(255, 193, 7, 0.4)", boxShadow: "0 3px 10px rgba(255, 193, 7, 0.6), 0 0 16px rgba(255, 193, 7, 0.4)",
border: "2px solid rgba(255, 255, 255, 0.9)", border: "2px solid rgba(255, 255, 255, 0.9)",
}} }}
> >
<Camera className="w-3.5 h-3.5" style={{ color: "#5A3800" }} /> <Camera className="h-3.5 w-3.5" style={{ color: "#5A3800" }} />
</motion.button> </motion.button>
</div> </div>
<div className="flex justify-center">
{(userProfile?.user_workflowID || !isLoadingProfile) && (
<motion.button
whileTap={{ scale: 0.92 }}
onClick={() => navigate("/edit-profile")}
aria-label={userProfile?.user_workflowID ? "ویرایش پروفایل" : "تکمیل پروفایل"}
className="flex h-12 min-w-[92px] items-center justify-center gap-1.5 rounded-full px-3"
style={{
background:
"linear-gradient(145deg, rgba(255, 211, 92, 0.98) 0%, rgba(255, 180, 64, 0.97) 55%, rgba(244, 148, 34, 0.96) 100%)",
backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
boxShadow: "0 10px 20px rgba(245, 158, 11, 0.32), inset 0 1px 0 rgba(255,255,255,0.4)",
border: "1px solid rgba(255, 236, 167, 0.55)",
}}
>
<Edit2 size={16} color="#fff" />
<span className="text-xs font-bold text-white">ویرایش</span>
</motion.button>
)}
</div>
</div>
{/* Name */} {/* Name */}
<h2 <h2
className="text-white text-lg font-bold mb-0.5" className="text-white text-lg font-bold mb-0.5"
@ -282,161 +363,36 @@ export function ProfilePage() {
</div> </div>
)} )}
{/* Stats */}
<div className="flex gap-2.5 mb-3">
{[
{ label: "چالش‌ها", value: toPersianNumber(challenges.filter(c => c.status === "انجام شده").length) },
{ label: "سکه‌ها", value: toPersianNumber(userProfile?.coin_count) },
{ label: "پست‌ها", value: toPersianNumber(posts.length) },
].map((stat, index) => (
<div
key={index}
className="px-4 py-2 rounded-2xl"
style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.6) 0%, rgba(20, 40, 60, 0.6) 100%)",
border: "1.5px solid rgba(138, 206, 224, 0.3)",
boxShadow: "inset 0 1.5px 6px rgba(0, 0, 0, 0.4), 0 3px 8px rgba(0, 0, 0, 0.3)",
}}
>
<div className="text-white font-bold text-sm mb-0.5">{stat.value}</div>
<div style={{ color: "#8ACEE0" }} className="text-[9px]">
{stat.label}
</div>
</div>
))}
</div>
{/* Action Buttons */}
<div className="flex gap-2 w-full">
{/* Edit Profile Button */}
{userProfile?.user_workflowID ? (
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => navigate("/edit-profile")}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-2xl font-bold text-xs"
style={{
background: "linear-gradient(135deg, rgba(76, 175, 80, 0.9) 0%, rgba(56, 142, 60, 0.9) 100%)",
boxShadow: "0 3px 12px rgba(76, 175, 80, 0.4)",
color: "#FFFFFF",
textShadow: "0 1px 3px rgba(0, 0, 0, 0.5)",
}}
>
<Edit2 size={14} />
<span>ویرایش</span>
</motion.button>
) : !isLoadingProfile ? (
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => navigate("/edit-profile")}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-2xl font-bold text-xs"
style={{
background: "linear-gradient(135deg, rgba(255, 193, 7, 0.9) 0%, rgba(255, 160, 0, 0.9) 100%)",
boxShadow: "0 3px 12px rgba(255, 193, 7, 0.4)",
color: "#FFFFFF",
textShadow: "0 1px 3px rgba(0, 0, 0, 0.5)",
}}
>
<Edit2 size={14} />
<span>تکمیل پروفایل</span>
</motion.button>
) : null}
{/* Logout Button */}
<motion.button
whileHover={{ scale: isLoggingOut ? 1 : 1.03 }}
whileTap={{ scale: isLoggingOut ? 1 : 0.97 }}
onClick={handleLogout}
disabled={isLoggingOut}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-2xl font-bold text-xs"
style={{
background: isLoggingOut
? "linear-gradient(135deg, rgba(120, 30, 40, 0.7) 0%, rgba(96, 24, 32, 0.7) 100%)"
: "linear-gradient(135deg, rgba(220, 53, 69, 0.9) 0%, rgba(176, 42, 55, 0.9) 100%)",
boxShadow: "0 3px 12px rgba(220, 53, 69, 0.4)",
color: "#FFFFFF",
textShadow: "0 1px 3px rgba(0, 0, 0, 0.5)",
opacity: isLoggingOut ? 0.7 : 1,
cursor: isLoggingOut ? "not-allowed" : "pointer",
}}
>
<LogOut size={14} />
<span>{isLoggingOut ? "خروج..." : "خروج"}</span>
</motion.button>
</div>
</motion.div> </motion.div>
{/* Tab Switcher */} {/* Tab Switcher */}
<div className="mb-3 flex gap-2"> <div className="mb-3 flex rounded-2xl p-1" style={segmentedControlStyle}>
{(
[
{ id: "challenges", label: "سابقه چالش‌ها" },
{ id: "coins", label: "سابقه سکه‌ها" },
{ id: "posts", label: "پست‌ها" },
] as const
).map((tab) => (
<motion.button <motion.button
whileTap={{ scale: 0.97 }} key={tab.id}
onClick={() => setActiveTab("challenges")} whileTap={{ scale: 0.96 }}
className="flex-1 py-2 rounded-2xl font-bold text-[11px] flex items-center justify-center gap-1" onClick={() => setActiveTab(tab.id)}
style={{ className="flex flex-1 items-center justify-center gap-1 rounded-xl py-2.5 text-[11px] font-bold transition-all duration-300"
background: activeTab === "challenges" style={
? "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)" activeTab === tab.id
: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)", ? {
boxShadow: activeTab === "challenges" background:
? "0 3px 12px rgba(255, 165, 0, 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%)",
: "0 2px 6px rgba(0, 0, 0, 0.3)", color: "#FFFFFF",
border: activeTab === "challenges" boxShadow: "0 0 18px rgba(155,108,241,0.4)",
? "1.5px solid rgba(255, 200, 50, 0.5)" }
: "1.5px solid rgba(138, 206, 224, 0.3)", : { color: "rgba(251,231,245,0.72)" }
color: activeTab === "challenges" ? "#5A3800" : "#FFFFFF", }
textShadow: activeTab === "challenges"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none",
}}
> >
سابقه چالشها {tab.label}
</motion.button>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("coins")}
className="flex-1 py-2 rounded-2xl font-bold text-[11px] flex items-center justify-center gap-1"
style={{
background: activeTab === "coins"
? "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)"
: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
boxShadow: activeTab === "coins"
? "0 3px 12px rgba(255, 165, 0, 0.5)"
: "0 2px 6px rgba(0, 0, 0, 0.3)",
border: activeTab === "coins"
? "1.5px solid rgba(255, 200, 50, 0.5)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
color: activeTab === "coins" ? "#5A3800" : "#FFFFFF",
textShadow: activeTab === "coins"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none",
}}
>
سابقه سکهها
</motion.button>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("posts")}
className="flex-1 py-2 rounded-2xl font-bold text-[11px] flex items-center justify-center gap-1"
style={{
background: activeTab === "posts"
? "linear-gradient(135deg, rgba(255, 183, 0, 0.95) 0%, rgba(255, 140, 0, 0.95) 100%)"
: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
boxShadow: activeTab === "posts"
? "0 3px 12px rgba(255, 165, 0, 0.5)"
: "0 2px 6px rgba(0, 0, 0, 0.3)",
border: activeTab === "posts"
? "1.5px solid rgba(255, 200, 50, 0.5)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
color: activeTab === "posts" ? "#5A3800" : "#FFFFFF",
textShadow: activeTab === "posts"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none",
}}
>
پستها
</motion.button> </motion.button>
))}
</div> </div>
{/* Challenges Tab */} {/* Challenges Tab */}
@ -448,9 +404,11 @@ export function ProfilePage() {
<div className="text-center text-white/60 py-8">هنوز چالشی ثبت نشده است</div> <div className="text-center text-white/60 py-8">هنوز چالشی ثبت نشده است</div>
) : ( ) : (
challenges.map((challenge, index) => { challenges.map((challenge, index) => {
const statusBadge = getStatusBadge(challenge.status);
const coins = parseInt(challenge.coin_count || "0"); const coins = parseInt(challenge.coin_count || "0");
const isInProgress = challenge.status === "در حال انجام"; const isInProgress = challenge.status === "در حال انجام";
const statusBadge = getStatusBadge(challenge.status);
const showEarnedCoins = !isInProgress &&
(challenge.status === "انجام شده" || challenge.status === "تایید شده") && coins > 0;
return ( return (
<motion.div <motion.div
@ -464,78 +422,60 @@ export function ProfilePage() {
navigate(`/chatbot/${topicId}?missionId=${challenge.mission_id}&missionType=${encodeURIComponent(challenge.mission_type)}&continueMode=true`); navigate(`/chatbot/${topicId}?missionId=${challenge.mission_id}&missionType=${encodeURIComponent(challenge.mission_type)}&continueMode=true`);
} }
}} }}
className={`rounded-2xl p-4 ${isInProgress ? 'cursor-pointer' : ''}`} className={`relative overflow-hidden rounded-2xl px-4 py-3 ${isInProgress ? "cursor-pointer" : ""}`}
style={{ style={{
background: isInProgress backgroundImage:
? "linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 152, 0, 0.15) 100%)" "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%)",
: "linear-gradient(135deg, rgba(32, 76, 106, 0.5) 0%, rgba(20, 40, 60, 0.5) 100%)", backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
border: isInProgress border: isInProgress
? "1.5px solid rgba(255, 193, 7, 0.4)" ? "1px solid rgba(255, 205, 122, 0.58)"
: "1.5px solid rgba(138, 206, 224, 0.3)", : "0.5px solid rgba(255, 255, 255, 0.16)",
boxShadow: isInProgress boxShadow: isInProgress
? "0 4px 16px rgba(255, 193, 7, 0.3)" ? "0 10px 22px rgba(255, 183, 77, 0.24), inset 0 1px 0 rgba(255,255,255,0.18)"
: "0 4px 12px rgba(0, 0, 0, 0.3)", : "0 10px 22px rgba(10, 6, 22, 0.28), inset 0 1px 0 rgba(255,255,255,0.14)",
opacity: 0.9,
}} }}
whileHover={isInProgress ? { scale: 1.02, y: -2 } : {}} whileHover={isInProgress ? { scale: 1.02, y: -2 } : {}}
whileTap={isInProgress ? { scale: 0.98 } : {}} whileTap={isInProgress ? { scale: 0.98 } : {}}
> >
<div className="flex items-start justify-between mb-2"> <span
<div className="flex items-center gap-2 flex-1"> className="absolute bottom-0 right-0 top-0 w-1.5"
<h3 className={`font-bold text-sm ${isInProgress ? 'text-yellow-300' : 'text-white'}`}> style={{
background: getChallengeAccent(challenge.status),
opacity: 0.92,
}}
/>
<div className="mb-1.5 flex items-start justify-between gap-2">
<h3 className="flex-1 text-sm font-extrabold text-[#FBE7F5]">
{challenge.mission_title} {challenge.mission_title}
</h3> </h3>
{isInProgress && (
<motion.div
animate={{ x: [0, 4, 0] }}
transition={{ repeat: Infinity, duration: 1.5 }}
>
<Play className="w-3.5 h-3.5 text-yellow-300" />
</motion.div>
)}
</div>
<div <div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-white text-[10px] font-bold" className="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-bold text-white"
style={{ style={{
background: statusBadge.gradient, background: statusBadge.gradient,
border: `1px solid ${statusBadge.border}`, border: `1px solid ${statusBadge.border}`,
boxShadow: statusBadge.shadow, boxShadow: statusBadge.shadow,
}} }}
> >
{statusBadge.icon}
<span>{statusBadge.text}</span> <span>{statusBadge.text}</span>
{statusBadge.icon}
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="mb-0 text-[11px] font-bold text-[#F2DFF0]/72">
<div className="flex items-center gap-2">
<span className="text-white/60 text-xs">{challenge.datetime1}</span>
{isInProgress && (
<span className="text-white/50 text-[10px] px-2 py-0.5 rounded-full" style={{
background: "rgba(138, 206, 224, 0.2)",
border: "1px solid rgba(138, 206, 224, 0.3)"
}}>
{challenge.mission_type} {challenge.mission_type}
</span>
)}
</div> </div>
<div className="flex items-center gap-3">
{(challenge.status === "انجام شده" || challenge.status === "تایید شده") && coins > 0 && ( <div className="mt0 flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5"> <span className="text-[#F2DFF0]/72">
<img src={coinImage} alt="سکه" className="w-4 h-4" /> {toPersianDigitsInText(challenge.datetime1)}
<span className="text-yellow-400 font-bold text-xs">
+{toPersianNumber(coins)}
</span> </span>
</div> <div className="flex items-center gap-2 rounded-full px-2 py-1">
)} <img src={coinImage} alt="سکه" className="h-5 w-5" />
{isInProgress ? ( <span className="text-base font-extrabold leading-none text-[#FFD873]">
<span className="text-yellow-300 text-[10px] font-bold"> {showEarnedCoins ? toPersianNumber(coins) : "۰"}
برای ادامه کلیک کنید
</span> </span>
) : (
<span className="text-white/50 text-[10px]">
{challenge.mission_type}
</span>
)}
</div> </div>
</div> </div>
</motion.div> </motion.div>
@ -552,24 +492,26 @@ export function ProfilePage() {
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="rounded-2xl p-4 mb-4" className="mb-4 rounded-2xl p-4"
style={{ style={{
background: "linear-gradient(135deg, rgba(255, 193, 7, 0.2) 0%, rgba(255, 152, 0, 0.2) 100%)", backgroundImage:
border: "2px solid rgba(255, 193, 7, 0.4)", "linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.96) 100%), linear-gradient(120deg, rgba(250, 204, 21, 0.28) 0%, rgba(245, 158, 11, 0.24) 50%, rgba(251, 113, 133, 0.26) 100%)",
boxShadow: "0 4px 16px rgba(255, 193, 7, 0.3)", backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
border: "1px solid transparent",
boxShadow:
"0 12px 24px rgba(25, 12, 46, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.18)",
}} }}
> >
<div className="flex items-center justify-between"> <div className="text-center">
<div> <div className="flex items-center justify-center gap-2">
<p className="text-white/70 text-xs mb-1">مجموع سکههای دریافتی</p> <img src={coinImage} alt="سکه" className="h-9 w-9" />
<div className="flex items-center gap-2"> <span className="text-[32px] font-black leading-none text-[#FFD166]">
<img src={coinImage} alt="سکه" className="w-8 h-8" />
<span className="text-yellow-300 font-bold text-2xl">
{toPersianNumber(userProfile?.coin_count)} {toPersianNumber(userProfile?.coin_count)}
</span> </span>
</div> </div>
</div> <p className="mt-2 text-xs font-bold text-[#FFF4D6]/92">مجموع کل سکهها</p>
<TrendingUp className="w-8 h-8 text-yellow-300" /> <div className="mx-auto mt-3 h-px w-[72%] rounded-full bg-gradient-to-r from-transparent via-[#FFD166] to-transparent opacity-85" />
</div> </div>
</motion.div> </motion.div>
@ -590,36 +532,57 @@ export function ProfilePage() {
initial={{ opacity: 0, x: -30 }} initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.08, duration: 0.3 }} transition={{ delay: index * 0.08, duration: 0.3 }}
className="rounded-2xl p-4" className="relative overflow-hidden rounded-2xl px-4 py-3"
style={{ style={{
background: isNegative backgroundImage: isNegative
? "linear-gradient(135deg, rgba(76, 29, 29, 0.5) 0%, rgba(60, 20, 20, 0.5) 100%)" ? "linear-gradient(180deg, rgba(46, 24, 41, 0.9) 0%, rgba(34, 18, 34, 0.94) 100%), linear-gradient(120deg, #fb7185 0%, #ef4444 100%)"
: "linear-gradient(135deg, rgba(32, 76, 106, 0.5) 0%, rgba(20, 40, 60, 0.5) 100%)", : "linear-gradient(180deg, rgba(46, 27, 61, 0.92) 0%, rgba(35, 24, 62, 0.96) 100%), linear-gradient(120deg, rgba(250, 204, 21, 0.22) 0%, rgba(245, 158, 11, 0.18) 42%, rgba(251, 113, 133, 0.24) 100%)",
border: isNegative backgroundOrigin: "border-box",
? "1.5px solid rgba(239, 68, 68, 0.4)" backgroundClip: "padding-box, border-box",
: "1.5px solid rgba(138, 206, 224, 0.3)", border: "1px solid transparent",
boxShadow: isNegative boxShadow: isNegative
? "0 4px 12px rgba(239, 68, 68, 0.2)" ? "0 8px 16px rgba(239, 68, 68, 0.18), inset 0 1px 0 rgba(255, 221, 226, 0.2)"
: "0 4px 12px rgba(0, 0, 0, 0.3)", : "0 8px 16px rgba(25, 12, 46, 0.22), inset 0 1px 0 rgba(255, 244, 200, 0.12)",
opacity: 0.92,
}} }}
> >
<div className="flex items-start justify-between mb-2"> <span
<h3 className="text-white font-bold text-sm flex-1"> className="absolute bottom-0 right-0 top-0 w-1.5"
style={{
background: isNegative
? "linear-gradient(180deg, #FB7185 0%, #EF4444 100%)"
: "linear-gradient(180deg, #FFD76A 0%, #F59E0B 100%)",
opacity: 0.95,
}}
/>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-xs font-semibold leading-5 text-[#FFF4D6]/92">
{item.description} {item.description}
</h3> </p>
<div className="flex items-center gap-1.5"> <p className="mt-1 text-[10px] text-[#FFE9A8]/60">تراکنش سکه</p>
</div>
<div
className="flex items-center gap-1.5 rounded-full px-2.5 py-1"
style={{
background: isNegative ? "rgba(239, 68, 68, 0.18)" : "rgba(255, 209, 102, 0.12)",
border: isNegative
? "1px solid rgba(251, 113, 133, 0.42)"
: "1px solid rgba(255, 224, 140, 0.34)",
}}
>
<img <img
src={coinImage} src={coinImage}
alt="سکه" alt="سکه"
className="w-5 h-5" className="h-4 w-4"
style={{ style={{
filter: isNegative ? "grayscale(100%) brightness(0.8)" : "none" filter: isNegative ? "grayscale(100%) brightness(0.8)" : "none"
}} }}
/> />
<span <span
className="font-bold text-sm" className="text-sm font-extrabold leading-none"
style={{ style={{
color: isNegative ? "#ef4444" : "#fcd34d" color: isNegative ? "#FF9CA8" : "#FFD166"
}} }}
> >
{isNegative ? "-" : "+"}{toPersianNumber(absCoins)} {isNegative ? "-" : "+"}{toPersianNumber(absCoins)}

View File

@ -28,6 +28,7 @@ export function PublicChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [showChatHistory, setShowChatHistory] = useState(false); const [showChatHistory, setShowChatHistory] = useState(false);
const [historyItems, setHistoryItems] = useState<ChatListItem[]>([]); const [historyItems, setHistoryItems] = useState<ChatListItem[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [currentChatWorkflowID, setCurrentChatWorkflowID] = useState<string>(""); const [currentChatWorkflowID, setCurrentChatWorkflowID] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
@ -124,13 +125,15 @@ export function PublicChatPage() {
}; };
const handleSendMessage = async (message: string) => { const handleSendMessage = async (message: string) => {
const trimmedText = message.trim(); const displayText = message.trim();
if (!trimmedText || isSending) return; const serverText = displayText.replace(/\r?\n+/g, " ").trim();
if (!serverText || isSending) return;
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: createMessageId(), id: createMessageId(),
type: "user", type: "user",
content: trimmedText, content: displayText,
timestamp: new Date().toLocaleTimeString("fa-IR", { timestamp: new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@ -151,7 +154,7 @@ export function PublicChatPage() {
setIsSending(true); setIsSending(true);
try { try {
const result = await sendPublicChatMessage(trimmedText, currentChatWorkflowID); const result = await sendPublicChatMessage(serverText, currentChatWorkflowID);
setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId)); setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessageId));
@ -186,15 +189,34 @@ export function PublicChatPage() {
const handleHistoryClick = useCallback(async () => { const handleHistoryClick = useCallback(async () => {
setShowChatHistory(true); setShowChatHistory(true);
setIsLoadingHistory(true);
try {
const result = await loadChatList(); const result = await loadChatList();
if (result.success) { if (result.success) {
setHistoryItems(result.data); const sortedItems = [...result.data].sort((a, b) => {
const timeA = Date.parse(a.datetime1 || "");
const timeB = Date.parse(b.datetime1 || "");
if (!Number.isNaN(timeA) && !Number.isNaN(timeB)) {
return timeB - timeA;
}
return 0;
});
const allDatesInvalid = sortedItems.every((item) =>
Number.isNaN(Date.parse(item.datetime1 || "")),
);
setHistoryItems(allDatesInvalid ? [...result.data].reverse() : sortedItems);
} else { } else {
console.error("Failed to load chat list:", result.message); console.error("Failed to load chat list:", result.message);
alert(result.message || "خطا در بارگذاری تاریخچه"); alert(result.message || "خطا در بارگذاری تاریخچه");
} }
} finally {
setIsLoadingHistory(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -202,9 +224,24 @@ export function PublicChatPage() {
void handleHistoryClick(); void handleHistoryClick();
}; };
const onNewChatRequest = () => {
if (isSending || isLoading) {
return;
}
setShowChatHistory(false);
setCurrentChatWorkflowID("");
setMessages([]);
setShouldAutoScroll(true);
};
window.addEventListener("public-chat:history", onHistoryRequest); window.addEventListener("public-chat:history", onHistoryRequest);
return () => window.removeEventListener("public-chat:history", onHistoryRequest); window.addEventListener("public-chat:new-chat", onNewChatRequest);
}, [handleHistoryClick]); return () => {
window.removeEventListener("public-chat:history", onHistoryRequest);
window.removeEventListener("public-chat:new-chat", onNewChatRequest);
};
}, [handleHistoryClick, isLoading, isSending]);
return ( return (
<div className="relative h-full min-h-0 overflow-hidden"> <div className="relative h-full min-h-0 overflow-hidden">
@ -285,6 +322,7 @@ export function PublicChatPage() {
<ChatHistoryModal <ChatHistoryModal
isOpen={showChatHistory} isOpen={showChatHistory}
onClose={() => setShowChatHistory(false)} onClose={() => setShowChatHistory(false)}
isLoading={isLoadingHistory}
historyItems={historyItems.map((item) => ({ historyItems={historyItems.map((item) => ({
id: item.chatlist_workflowID, id: item.chatlist_workflowID,
title: item.title || "چت عمومی", title: item.title || "چت عمومی",

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type KeyboardEvent } from "react"; import { useState, useRef, useEffect, type KeyboardEvent } from "react";
import { Send } from "lucide-react"; import { Send, Square } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
interface ChatInputBarProps { interface ChatInputBarProps {
@ -21,19 +21,18 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
// Reset height after send and refocus // Reset height after send and refocus
setTimeout(() => { setTimeout(() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.style.height = "auto"; inputRef.current.style.height = "24px";
inputRef.current.focus(); inputRef.current.focus();
} }
}, 0); }, 0);
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) { if (e.key !== "Enter") {
return; return;
} }
// Keep Enter behavior as newline (same as Shift+Enter).
e.preventDefault(); // Sending is intentionally limited to the send button.
handleSend();
}; };
// Auto-resize textarea based on content // Auto-resize textarea based on content
@ -73,14 +72,15 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
dir="rtl" dir="rtl"
disabled={disabled} disabled={disabled}
aria-label="پیام خود را بنویسید" aria-label="پیام خود را بنویسید"
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" className="chat-input-textarea block flex-1 resize-none bg-transparent py-0 text-right text-white outline-none placeholder:text-[rgba(207,168,212,0.7)] disabled:opacity-50"
style={{ style={{
fontFamily: "Alibaba, sans-serif", fontFamily: "Alibaba, sans-serif",
textAlign: "right", textAlign: "right",
minHeight: "36px", minHeight: "24px",
maxHeight: "96px", maxHeight: "96px",
lineHeight: "1.4", lineHeight: "24px",
overflow: "hidden", overflow: "hidden",
padding: 0,
fontSize: "16px", // حداقل 16px برای جلوگیری از zoom در iOS Safari fontSize: "16px", // حداقل 16px برای جلوگیری از zoom در iOS Safari
}} }}
/> />
@ -103,7 +103,11 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
border: canSend ? "1px solid rgba(255, 189, 228, 0.5)" : "1px solid rgba(198, 111, 177, 0.22)", border: canSend ? "1px solid rgba(255, 189, 228, 0.5)" : "1px solid rgba(198, 111, 177, 0.22)",
}} }}
> >
{disabled ? (
<Square className="h-4 w-4 text-[#CFA8D4]/80" />
) : (
<Send className={`h-5 w-5 ${canSend ? "text-white" : "text-[#CFA8D4]/70"}`} /> <Send className={`h-5 w-5 ${canSend ? "text-white" : "text-[#CFA8D4]/70"}`} />
)}
</motion.button> </motion.button>
</div> </div>

View File

@ -1,5 +1,4 @@
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { MessageCircle } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
interface ChatHistoryItem { interface ChatHistoryItem {
@ -12,38 +11,43 @@ interface ChatHistoryItem {
interface ChatHistoryModalProps { interface ChatHistoryModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
isLoading?: boolean;
historyItems: ChatHistoryItem[]; historyItems: ChatHistoryItem[];
onSelectChat: (chatId: string) => void; onSelectChat: (chatId: string) => void;
} }
const modalStyles = { const modalStyles = {
overlay: { overlay: {
backgroundColor: "rgba(0, 0, 0, 0.8)", backgroundColor: "rgba(0, 0, 0, 0.72)",
}, },
container: { container: {
background: "linear-gradient(180deg, rgba(32, 76, 106, 0.98) 0%, rgba(20, 40, 60, 0.98) 100%)", backgroundImage:
border: "2px solid rgba(138, 206, 224, 0.3)", "linear-gradient(180deg, rgba(46, 27, 61, 0.95) 0%, rgba(35, 24, 62, 0.98) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.3)", backgroundOrigin: "border-box",
maxHeight: "85vh", backgroundClip: "padding-box, border-box",
border: "0.5px solid transparent",
boxShadow:
"0 -7px 20px rgba(7, 0, 18, 0.4), 0 10px 22px rgba(5, 2, 12, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -2px 0 rgba(12, 7, 27, 0.62)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}, },
chatItem: { chatItem: {
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)", background: "rgba(255, 255, 255, 0.07)",
border: "1.5px solid rgba(138, 206, 224, 0.3)", border: "1px solid rgba(255, 255, 255, 0.12)",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", boxShadow: "0 8px 18px rgba(8, 4, 20, 0.24), inset 0 1px 0 rgba(255,255,255,0.1)",
},
iconContainer: {
background: "linear-gradient(135deg, rgba(138, 206, 224, 0.3) 0%, rgba(76, 127, 137, 0.3) 100%)",
}, },
closeButton: { closeButton: {
background: "linear-gradient(135deg, rgba(96, 147, 157, 0.6) 0%, rgba(76, 127, 137, 0.6) 100%)", background:
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", "linear-gradient(145deg, rgba(240, 110, 168, 0.95) 0%, rgba(201, 87, 156, 0.94) 52%, rgba(138, 79, 207, 0.93) 100%)",
border: "1.5px solid rgba(138, 206, 224, 0.3)", boxShadow: "0 0 16px rgba(240, 110, 168, 0.32), inset 0 1px 0 rgba(255,255,255,0.22)",
border: "1px solid rgba(255, 189, 228, 0.5)",
}, },
}; };
export function ChatHistoryModal({ export function ChatHistoryModal({
isOpen, isOpen,
onClose, onClose,
isLoading = false,
historyItems, historyItems,
onSelectChat, onSelectChat,
}: ChatHistoryModalProps) { }: ChatHistoryModalProps) {
@ -65,69 +69,93 @@ export function ChatHistoryModal({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4" className="fixed inset-0 z-[100] flex items-center justify-center p-3"
style={modalStyles.overlay} style={modalStyles.overlay}
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
initial={{ scale: 0.9, y: 20 }} initial={{ opacity: 0, y: 14 }}
animate={{ scale: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }} exit={{ opacity: 0, y: 10 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-3xl overflow-hidden" className="flex w-full max-w-md min-h-0 flex-col overflow-hidden rounded-3xl"
style={modalStyles.container} style={{ ...modalStyles.container, maxHeight: "min(78dvh, 620px)" }}
> >
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b" style={{ borderColor: "rgba(138, 206, 224, 0.2)" }}> <div className="border-b px-5 py-4" style={{ borderColor: "rgba(255, 255, 255, 0.12)" }}>
<h3 <h3
className="text-lg font-bold text-center text-white" className="text-center text-base font-extrabold text-[#FBE7F5]"
style={{ textShadow: "0 2px 4px rgba(0, 0, 0, 0.3)" }} style={{ textShadow: "0 2px 8px rgba(255, 119, 202, 0.3)" }}
> >
تاریخچه گفتگوها تاریخچه گفتگوها
</h3> </h3>
</div> </div>
{/* Content */} {/* Content */}
<div className="overflow-y-auto p-6" style={{ maxHeight: "calc(85vh - 120px)" }}> <div
className="min-h-0 flex-1 overflow-y-auto px-4 py-4"
style={{
maxHeight:
"calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 180px)",
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
{isLoading ? (
<div className="flex h-40 items-center justify-center">
<div className="text-center">
<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-xs font-bold text-[#FBE7F5]">در حال بارگذاری...</p>
</div>
</div>
) : historyItems.length === 0 ? (
<div className="flex h-40 items-center justify-center text-center">
<p className="text-sm font-bold text-[#F2DFF0]/72">تاریخچهای پیدا نشد</p>
</div>
) : (
<div className="space-y-3"> <div className="space-y-3">
{historyItems.map((chat, index) => ( {historyItems.map((chat, index) => (
<motion.button <motion.button
key={chat.id} key={chat.id}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.05, duration: 0.2 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => { onClick={() => {
onClose(); onClose();
onSelectChat(chat.id); onSelectChat(chat.id);
}} }}
className="w-full p-4 rounded-2xl text-right" className="w-full rounded-2xl p-3.5 text-right"
style={modalStyles.chatItem} style={modalStyles.chatItem}
> >
<div className="flex items-start gap-3"> <div className="flex items-start justify-between gap-2">
<div <h4 className="line-clamp-1 text-sm font-bold text-[#FBE7F5]">
className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" {chat.title}
style={modalStyles.iconContainer} </h4>
> <span className="h-2 w-2 shrink-0 rounded-full bg-[#ff78c6]" />
<MessageCircle className="w-5 h-5" style={{ color: "#8ACEE0" }} />
</div>
<div className="flex-1">
<h4 className="text-white font-bold text-sm mb-1">{chat.title}</h4>
<p className="text-white/60 text-xs mb-1">{chat.lastMessage}</p>
<p className="text-white/40 text-xs">{chat.date}</p>
</div>
</div> </div>
{!!chat.lastMessage && (
<p className="mt-1 line-clamp-1 text-xs text-[#F2DFF0]/74">
{chat.lastMessage}
</p>
)}
<p className="mt-1 text-[11px] text-[#F2DFF0]/62">{chat.date}</p>
<div className="mt-2 h-px w-full bg-white/8" />
</motion.button> </motion.button>
))} ))}
</div> </div>
)}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-6 py-4 border-t" style={{ borderColor: "rgba(138, 206, 224, 0.2)" }}> <div
className="border-t px-5 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] pt-3"
style={{ borderColor: "rgba(255, 255, 255, 0.12)" }}
>
<motion.button <motion.button
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.96 }}
onClick={onClose} onClick={onClose}
className="w-full py-3 rounded-full font-bold text-white" className="w-full rounded-full py-3 text-sm font-bold text-white"
style={modalStyles.closeButton} style={modalStyles.closeButton}
> >
بستن بستن
@ -136,6 +164,11 @@ export function ChatHistoryModal({
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
<style>{`
.min-h-0.flex-1.overflow-y-auto::-webkit-scrollbar {
display: none;
}
`}</style>
</AnimatePresence> </AnimatePresence>
); );
} }

View File

@ -65,7 +65,7 @@ body,
z-index: 20; z-index: 20;
height: 68px; height: 68px;
min-height: 68px; min-height: 68px;
overflow: hidden; overflow: visible;
} }
.page-frame { .page-frame {
@ -94,6 +94,6 @@ body,
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 30; z-index: 30;
height: calc(94px + env(safe-area-inset-bottom)); height: auto;
min-height: calc(94px + env(safe-area-inset-bottom)); min-height: 0;
} }