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;
}
</style>
<script type="module" crossorigin src="/assets/index-Dvo_8Nz_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5jzgFDg.css">
<script type="module" crossorigin src="/assets/index-zxxbrZom.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BINX1_Nm.css">
</head>
<body>

View File

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

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
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 { getCachedProfile } from "../../services/profileService";
import { getProfileImageUrl } from "../../services/feedService";
@ -13,6 +13,8 @@ interface AppHeaderProps {
onBack?: () => void;
centerTitle?: string;
centerSubtitle?: string;
useNewChatAction?: boolean;
onNewChatClick?: () => void;
}
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)]);
};
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 { profile } = useProfile();
@ -44,6 +53,9 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
return profileFallbackImage;
}, [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 = {
backgroundImage: `
linear-gradient(180deg, #2E1B3D 0%, #23183E 100%),
@ -58,11 +70,43 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
} as const;
return (
<div className="relative z-20 flex items-center justify-between px-4 pt-3 pb-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 ? (
<button
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="بازگشت"
style={navLikePanelStyle}
>
@ -71,39 +115,56 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
) : (
<button
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="پروفایل"
style={navLikePanelStyle}
>
{hasCustomAvatar ? (
<img
src={avatarUrl}
alt="پروفایل"
className="w-full h-full rounded-full object-cover"
onError={(event) => {
event.currentTarget.src = profileFallbackImage;
}}
/>
) : (
<div
className="w-full h-full rounded-full flex items-center justify-center"
style={navLikePanelStyle}
>
<span
className="h-12 w-12 shrink-0 overflow-hidden rounded-full border-[0.5px] border-transparent p-[2px]"
style={navLikePanelStyle}
>
{hasCustomAvatar ? (
<img
src={profileFallbackImage}
alt="پروفایل پیش‌فرض"
className=" object-cover rounded-full"
src={avatarUrl}
alt="پروفایل"
className="h-full w-full rounded-full object-cover"
onError={(event) => {
event.currentTarget.src = profileFallbackImage;
}}
/>
</div>
)}
) : (
<span
className="flex h-full w-full items-center justify-center rounded-full"
style={navLikePanelStyle}
>
<img
src={profileFallbackImage}
alt="پروفایل پیش‌فرض"
className="rounded-full object-cover"
/>
</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>
)}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center text-center translate-y-1">
<div
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="leading-none">
<div className="w-full">
<div
className="font-extrabold text-[22px]"
className="mx-auto max-w-full truncate font-extrabold text-[18px] leading-6"
style={{
display: "inline-block",
background:
@ -119,7 +180,7 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
</div>
{centerSubtitle && (
<div
className="mt-1 text-[11px] font-medium"
className="-mt-2 text-[11px] font-medium"
style={{
color: "#ffb7dd",
textShadow: "0 1px 6px rgba(255, 119, 202, 0.35)",
@ -138,25 +199,6 @@ export function AppHeader({ showBack = false, onBack, centerTitle, centerSubtitl
/>
)}
</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>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
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 { getUsername } from "../../utils/auth";
import { usePageTracking } from "../../hooks/usePageTracking";
@ -164,216 +164,189 @@ export function EditProfilePage() {
// Loading state
if (isLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{
background:
"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%)",
}}
>
<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 className="flex h-full min-h-0 items-center justify-center" dir="rtl">
<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-sm font-bold text-[#FBE7F5]">در حال بارگذاری پروفایل...</p>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto px-6 py-8" dir="rtl">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<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={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.6) 0%, rgba(20, 40, 60, 0.6) 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)",
}}
>
{userProfile?.user_workflowID ? "ویرایش پروفایل" : "تکمیل پروفایل"}
</h1>
</div>
<div className="relative h-full min-h-0 overflow-hidden" dir="rtl">
<main
className="h-full overflow-y-auto px-4 pb-4 pt-4"
style={{
maxHeight: "100%",
}}
>
<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 ? "ویرایش پروفایل" : "تکمیل پروفایل"}
</h1>
<p className="mt-1 text-xs text-[#EED3EC]/82">اطلاعات پایه حساب کاربری را ثبت یا بهروزرسانی کنید</p>
</div>
{/* Success/Info Message */}
{successMessage && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 rounded-xl text-center font-bold"
style={{
background: "linear-gradient(135deg, rgba(76, 175, 80, 0.2) 0%, rgba(56, 142, 60, 0.2) 100%)",
border: "2px solid rgba(76, 175, 80, 0.4)",
color: "#A7F3D0",
textShadow: "0 2px 4px rgba(0, 0, 0, 0.6)",
}}
>
{successMessage}
</motion.div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div>
<label className="block text-white mb-2 text-sm font-bold">نام</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold"
style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)",
border: errors.name ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)",
outline: "none",
}}
placeholder="نام خود را وارد کنید"
/>
{errors.name && (
<p className="text-red-400 text-xs mt-1">{errors.name}</p>
{successMessage && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 rounded-2xl px-4 py-3 text-center text-sm font-bold"
style={{
backgroundImage:
"linear-gradient(180deg, rgba(24, 67, 46, 0.88) 0%, rgba(16, 56, 39, 0.92) 100%), linear-gradient(120deg, #34d399 0%, #10b981 100%)",
backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
border: "1px solid rgba(167, 243, 208, 0.4)",
color: "#D1FAE5",
}}
>
{successMessage}
</motion.div>
)}
</div>
{/* Family */}
<div>
<label className="block text-white mb-2 text-sm font-bold">نام خانوادگی</label>
<input
type="text"
value={formData.family}
onChange={(e) => handleChange("family", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold"
style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)",
border: errors.family ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)",
outline: "none",
}}
placeholder="نام خانوادگی خود را وارد کنید"
/>
{errors.family && (
<p className="text-red-400 text-xs mt-1">{errors.family}</p>
)}
</div>
<form id="edit-profile-form" onSubmit={handleSubmit} className="space-y-4">
<div
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>
<label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">نام</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
className="w-full rounded-xl px-3 py-2.5 text-sm font-bold text-white outline-none"
style={{
background: "rgba(255,255,255,0.08)",
border: errors.name ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
}}
placeholder="نام خود را وارد کنید"
/>
{errors.name && <p className="mt-1 text-xs text-red-300">{errors.name}</p>}
</div>
{/* Education Level */}
<div>
<label className="block text-white mb-2 text-sm font-bold">مقطع تحصیلی</label>
<select
value={formData.education_level}
onChange={(e) => handleChange("education_level", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold"
style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)",
border: errors.education_level ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)",
outline: "none",
}}
>
<option value="">انتخاب کنید</option>
<option value="ابتدایی">ابتدایی</option>
<option value="متوسطه اول">متوسطه اول</option>
<option value="متوسطه دوم">متوسطه دوم</option>
</select>
{errors.education_level && (
<p className="text-red-400 text-xs mt-1">{errors.education_level}</p>
)}
</div>
<div>
<label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">نام خانوادگی</label>
<input
type="text"
value={formData.family}
onChange={(e) => handleChange("family", e.target.value)}
className="w-full rounded-xl px-3 py-2.5 text-sm font-bold text-white outline-none"
style={{
background: "rgba(255,255,255,0.08)",
border: errors.family ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
}}
placeholder="نام خانوادگی خود را وارد کنید"
/>
{errors.family && <p className="mt-1 text-xs text-red-300">{errors.family}</p>}
</div>
{/* Base (Grade) */}
<div>
<label className="block text-white mb-2 text-sm font-bold">پایه تحصیلی</label>
<select
value={formData.base}
onChange={(e) => handleChange("base", e.target.value)}
className="w-full px-4 py-3 rounded-xl text-white font-bold"
style={{
background: "linear-gradient(135deg, rgba(32, 76, 106, 0.4) 0%, rgba(20, 40, 60, 0.4) 100%)",
border: errors.base ? "2px solid rgba(220, 53, 69, 0.6)" : "2px solid rgba(138, 206, 224, 0.3)",
outline: "none",
}}
>
<option value="">انتخاب کنید</option>
{formData.education_level === "ابتدایی" && (
<>
<option value="اول">اول</option>
<option value="دوم">دوم</option>
<option value="سوم">سوم</option>
<option value="چهارم">چهارم</option>
<option value="پنجم">پنجم</option>
<option value="ششم">ششم</option>
</>
)}
{formData.education_level === "متوسطه اول" && (
<>
<option value="هفتم">هفتم</option>
<option value="هشتم">هشتم</option>
<option value="نهم">نهم</option>
</>
)}
{formData.education_level === "متوسطه دوم" && (
<>
<option value="دهم">دهم</option>
<option value="یازدهم">یازدهم</option>
<option value="دوازدهم">دوازدهم</option>
</>
)}
</select>
{errors.base && (
<p className="text-red-400 text-xs mt-1">{errors.base}</p>
)}
</div>
<div>
<label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">مقطع تحصیلی</label>
<div className="relative">
<select
value={formData.education_level}
onChange={(e) => handleChange("education_level", e.target.value)}
className="w-full appearance-none rounded-xl px-3 py-2.5 pl-10 text-sm font-bold text-white outline-none"
style={{
background: "rgba(255,255,255,0.08)",
border: errors.education_level ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
}}
>
<option value="">انتخاب کنید</option>
<option value="ابتدایی">ابتدایی</option>
<option value="متوسطه اول">متوسطه اول</option>
<option value="متوسطه دوم">متوسطه دوم</option>
</select>
<ChevronDown
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>
{/* Submit Button */}
<motion.button
type="submit"
disabled={isSaving}
whileHover={{ scale: isSaving ? 1 : 1.02 }}
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"
style={{
background: isSaving
? "linear-gradient(135deg, rgba(60, 140, 150, 0.7) 0%, rgba(40, 100, 110, 0.7) 100%)"
: "linear-gradient(135deg, rgba(76, 175, 80, 0.9) 0%, rgba(56, 142, 60, 0.9) 100%)",
boxShadow: "0 12px 32px rgba(76, 175, 80, 0.4), 0 4px 12px rgba(0, 0, 0, 0.4)",
color: "#FFFFFF",
textShadow: "0 2px 4px rgba(0, 0, 0, 0.5)",
opacity: isSaving ? 0.7 : 1,
cursor: isSaving ? "not-allowed" : "pointer",
}}
>
<Save size={24} />
<span>{isSaving ? "در حال ذخیره..." : "ذخیره اطلاعات"}</span>
</motion.button>
</form>
<div>
<label className="mb-2 block text-xs font-semibold text-[#FBE7F5]/92">پایه تحصیلی</label>
<div className="relative">
<select
value={formData.base}
onChange={(e) => handleChange("base", e.target.value)}
className="w-full appearance-none rounded-xl px-3 py-2.5 pl-10 text-sm font-bold text-white outline-none"
style={{
background: "rgba(255,255,255,0.08)",
border: errors.base ? "1px solid rgba(248, 113, 113, 0.62)" : "1px solid rgba(255,255,255,0.14)",
}}
>
<option value="">انتخاب کنید</option>
{formData.education_level === "ابتدایی" && (
<>
<option value="اول">اول</option>
<option value="دوم">دوم</option>
<option value="سوم">سوم</option>
<option value="چهارم">چهارم</option>
<option value="پنجم">پنجم</option>
<option value="ششم">ششم</option>
</>
)}
{formData.education_level === "متوسطه اول" && (
<>
<option value="هفتم">هفتم</option>
<option value="هشتم">هشتم</option>
<option value="نهم">نهم</option>
</>
)}
{formData.education_level === "متوسطه دوم" && (
<>
<option value="دهم">دهم</option>
<option value="یازدهم">یازدهم</option>
<option value="دوازدهم">دوازدهم</option>
</>
)}
</select>
<ChevronDown
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>
<motion.button
type="submit"
disabled={isSaving}
whileTap={{ scale: isSaving ? 1 : 0.98 }}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-bold text-white"
style={{
backgroundImage: isSaving
? "linear-gradient(145deg, rgba(72, 58, 105, 0.72) 0%, rgba(42, 35, 77, 0.76) 100%)"
: "linear-gradient(90deg, rgba(255,139,91,1) 0%, rgba(238,91,166,1) 45%, rgba(147,78,255,1) 100%), linear-gradient(120deg, #7c3aed 0%, #f97316 58%, #facc15 100%)",
backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
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,
cursor: isSaving ? "not-allowed" : "pointer",
}}
>
<Save size={16} />
<span>{isSaving ? "در حال ذخیره..." : "ذخیره اطلاعات"}</span>
</motion.button>
</form>
</div>
</main>
</div>
);
}

View File

@ -22,15 +22,22 @@ const headerActionStyle = {
WebkitBackdropFilter: "blur(14px)",
} 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 (
<header className="app-header relative">
<AppHeader showBack={showBack} onBack={onBack} />
<AppHeader
showBack={showBack}
onBack={onBack}
centerTitle={shouldShowCenterTitle ? title : undefined}
/>
{action === "history" && onActionClick && (
<button
type="button"
onClick={onActionClick}
className="absolute right-[64px] top-3 h-10 px-3 rounded-full text-white text-xs font-bold border-[0.5px] border-transparent z-30"
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}
aria-label="تاریخچه چت"
>

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/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 {
loadMagicBagMissions,
@ -105,7 +105,7 @@ const purchasedItems: Item[] = [
export function MagicBagPage() {
usePageTracking("کیف جادویی");
const [activeTab, setActiveTab] = useState<"shop" | "owned">("shop");
const [activeTab, setActiveTab] = useState<"shop" | "owned">("owned");
const [missionItems, setMissionItems] = useState<MagicBagMissionItem[]>([]);
const [loadingMissions, setLoadingMissions] = useState(false);
const [selectedImage, setSelectedImage] = useState<{ url: string; title: string } | null>(null);
@ -134,54 +134,51 @@ export function MagicBagPage() {
return (
<div className="pt-6 pb-2" dir="rtl">
{/* Tab Switcher */}
<div className="mb-4 flex gap-2">
<motion.button
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={{
background: activeTab === "shop"
? "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 === "shop"
? "0 6px 18px rgba(255, 165, 0, 0.5)"
: "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>
<div
className="mb-4 flex rounded-2xl p-1"
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",
border: "0.5px solid transparent",
}}
>
<motion.button
whileTap={{ scale: 0.97 }}
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={{
background: activeTab === "owned"
? "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%)",
? "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 === "owned"
? "0 6px 18px rgba(255, 165, 0, 0.5)"
: "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)"
? "0 0 18px rgba(155,108,241,0.4)"
: "none",
color: activeTab === "owned" ? "#FFFFFF" : "rgba(251,231,245,0.72)",
}}
>
<Package className="w-4 h-4" />
ایتمهای من
</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>
{/* 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"
style={{
background: canAfford
? "linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 152, 0, 0.95) 100%)"
: "linear-gradient(135deg, rgba(107, 114, 128, 0.95) 0%, rgba(75, 85, 99, 0.95) 100%)",
border: `1.5px solid ${canAfford ? "rgba(255, 200, 50, 0.8)" : "rgba(107, 114, 128, 0.8)"}`,
? "linear-gradient(135deg, rgba(44, 34, 66, 0.96) 0%, rgba(30, 22, 50, 0.98) 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, 214, 240, 0.45)" : "rgba(148, 163, 184, 0.45)"}`,
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)",
}}
>
<img src={coinImage} alt="سکه" className="w-3.5 h-3.5" />
<img src={coinImage} alt="سکه" className="w-4 h-4" />
<span
className="text-[10px] font-bold"
style={{
color: canAfford ? "#5A3800" : "rgba(255, 255, 255, 0.7)",
textShadow: canAfford ? "0 1px 0 rgba(255, 255, 255, 0.3)" : "none",
color: canAfford ? "#FFF3CC" : "rgba(255, 255, 255, 0.74)",
textShadow: "none",
}}
>
{item.price}
@ -419,14 +416,7 @@ export function MagicBagPage() {
{/* Divider */}
{missionItems.length > 0 && purchasedItems.length > 0 && (
<div className="mb-6 flex items-center gap-3">
<div
className="flex-1 h-[1px]"
style={{
background: "linear-gradient(90deg, transparent 0%, rgba(138, 206, 224, 0.3) 50%, transparent 100%)",
}}
/>
</div>
<div className="my-5 h-px w-full bg-white/20" />
)}
{/* Section: خریداری شده */}

View File

@ -1,25 +1,23 @@
import { useNavigate } from "react-router-dom";
import { motion } from "motion/react";
import { useCallback, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { usePageTracking } from "../../hooks/usePageTracking";
import { useInbox } from "../context/InboxContext";
import { FeedHeader } from "./feed/FeedHeader";
import { getMessageStyle } from "../../utils/messageUtils";
import { AppBackground } from "./shared/AppBackground";
import { backgroundImages } from "../../config/backgroundConfig";
import { BottomNav } from "./BottomNav";
const cardStyle = {
backgroundImage:
"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%)",
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() {
const navigate = useNavigate();
usePageTracking("پیام‌ها");
const { messages, loading, markMessagesAsRead } = useInbox();
const hasMarkedAsReadRef = useRef(false);
const handleBack = useCallback(() => {
navigate("/");
}, [navigate]);
// Mark all unread messages as read when page is opened (only once)
useEffect(() => {
if (!loading && messages.length > 0 && !hasMarkedAsReadRef.current) {
const unreadStageIds = messages
@ -34,123 +32,49 @@ export function MessagesPage() {
}, [loading, messages, markMessagesAsRead]);
return (
<div className="min-h-screen w-full relative overflow-hidden">
<AppBackground position="fixed" zIndex={0} imageUrl={backgroundImages.messages} />
{/* 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 ? (
<div className="flex items-center justify-center h-full">
<div className="text-white text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
<p>در حال بارگذاری پیامها...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-white text-center">
<p className="text-lg">پیامی وجود ندارد</p>
</div>
</div>
) : (
<div className="space-y-4" dir="rtl">
{messages.map((message, index) => {
const messageStyle = getMessageStyle(message.kind);
const isUnread = message.status === "خوانده نشده";
return (
<motion.div
key={`${message.user_id}-${index}`}
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1, duration: 0.4 }}
className="relative"
>
<div
className="rounded-3xl p-5 backdrop-blur-sm"
style={{
background: messageStyle.gradient,
border: `1.5px solid ${messageStyle.border}`,
boxShadow: messageStyle.shadow,
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">
{/* Icon */}
<div
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 className="flex h-full min-h-0 flex-col pb-3 pt-4">
<div className="min-h-0 flex-1 overflow-y-auto px-4">
{loading ? (
<div className="flex h-full 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-sm font-bold text-[#FBE7F5]">در حال بارگذاری پیامها...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm font-bold text-[#FBE7F5]/88">پیامی وجود ندارد</p>
</div>
) : (
<div className="space-y-3" dir="rtl">
{messages.map((message, index) => {
const isUnread = message.status === "خوانده نشده";
return (
<motion.article
key={`${message.user_id}-${index}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.05, 0.25), duration: 0.26 }}
className="relative overflow-hidden rounded-2xl px-4 py-3"
style={cardStyle}
>
<span
className="absolute inset-y-0 right-0 w-1.5"
style={{
background: isUnread
? "linear-gradient(180deg, rgba(255,97,152,0.95) 0%, rgba(234,71,170,0.88) 100%)"
: "linear-gradient(180deg, rgba(148,163,184,0.35) 0%, rgba(100,116,139,0.28) 100%)",
}}
/>
{/* 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>
<h3 className="pr-3 text-sm font-extrabold text-[#FBE7F5]">{message.title}</h3>
<p className="mt-1.5 pr-3 text-xs leading-6 text-[#F2DFF0]/88">{message.Message}</p>
</motion.article>
);
})}
</div>
)}
</div>
<style>{`
/* Hide scrollbar for Chrome, Safari and Opera */
.h-full.overflow-y-auto::-webkit-scrollbar {
display: none;
}
`}</style>
<BottomNav />
</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 { motion } from "motion/react";
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 { useEffect, useState } from "react";
import { getUserProfile, getCachedProfile, saveUserProfile, getUserProfileData, type UserProfile, type ProfileChallenge, type ProfileCoinTransaction, type ProfilePost } from "../../services/profileService";
import { PostCard } from "./PostCard";
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 { usePageTracking } from "../../hooks/usePageTracking";
import { getMissionTypeToTopicId } from "../../utils/topicMapper";
@ -19,21 +19,21 @@ const getStatusBadge = (status: string) => {
return {
icon: <CheckCircle2 className="w-4 h-4" />,
text: "انجام شده",
gradient: "linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%)",
border: "rgba(34, 197, 94, 0.5)",
shadow: "0 2px 8px rgba(34, 197, 94, 0.4)",
gradient: "linear-gradient(135deg, rgba(32, 201, 151, 0.95) 0%, rgba(16, 185, 129, 0.95) 100%)",
border: "rgba(110, 231, 183, 0.6)",
shadow: "0 2px 8px rgba(16, 185, 129, 0.35)",
};
} else if (status === "تایید شده") {
return {
icon: <CheckCircle2 className="w-4 h-4" />,
icon: <ShieldCheck className="w-4 h-4" />,
text: "تایید شده",
gradient: "linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%)",
border: "rgba(59, 130, 246, 0.5)",
shadow: "0 2px 8px rgba(59, 130, 246, 0.4)",
gradient: "linear-gradient(135deg, rgba(59, 130, 246, 0.95) 0%, rgba(37, 99, 235, 0.95) 100%)",
border: "rgba(147, 197, 253, 0.62)",
shadow: "0 2px 8px rgba(37, 99, 235, 0.35)",
};
} else if (status === "در حال انجام") {
return {
icon: <Play className="w-4 h-4" />,
icon: <Play className="w-4 h-4 rotate-180" />,
text: "در حال انجام",
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)",
@ -61,7 +61,7 @@ const getStatusBadge = (status: string) => {
export function ProfilePage() {
const navigate = useNavigate();
usePageTracking("پروفایل");
const { refreshProfile } = useProfile();
const { profile, refreshProfile } = useProfile();
const [userInfo, setUserInfo] = useState<{ Name: string; Family: string; Username: string } | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
@ -81,6 +81,12 @@ export function ProfilePage() {
loadProfile();
}, []);
useEffect(() => {
if (profile) {
setUserProfile(profile);
}
}, [profile]);
const loadProfile = async () => {
setIsLoadingProfile(true);
try {
@ -132,11 +138,33 @@ export function ProfilePage() {
}
};
const toPersianNumber = (num: number | null | undefined): string => {
if (num === null || num === undefined) return "۰";
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
return String(num).replace(/\d/g, (digit) => persianDigits[parseInt(digit)]);
};
const toPersianNumber = (num: number | null | undefined): string => {
if (num === null || num === undefined) return "۰";
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
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) => {
// imageFilename از AvatarSelectionModal نام فایل آپلود شده به سرور است
@ -165,9 +193,6 @@ export function ProfilePage() {
console.log("Saving profile with data:", JSON.stringify(saveData));
const result = await saveUserProfile(saveData);
console.log("Save profile result:", result);
if (result) {
bumpAvatarCacheBust();
}
// بارگذاری مجدد پروفایل از سرور
console.log("Reloading profile from server...");
@ -200,6 +225,14 @@ export function ProfilePage() {
WebkitBackdropFilter: "blur(14px)",
} 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 (
<div
className="pt-6 pb-2"
@ -212,49 +245,97 @@ export function ProfilePage() {
className="flex flex-col items-center mb-4"
>
{/* Avatar with Edit Button */}
<div className="relative mb-3">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.5 }}
className="w-24 h-24 rounded-full p-[2px]"
style={navLikePanelStyle}
>
<div className="w-full h-full rounded-full overflow-hidden flex items-center justify-center" style={navLikePanelStyle}>
{userProfile?.image ? (
<ImageWithFallback
src={getProfileImageUrl(userProfile.image, userProfile.user_stage_id)}
alt="پروفایل"
className="w-full h-full rounded-full object-cover"
fallbackSrc={profileIcon}
style={{
filter: "drop-shadow(0 3px 6px rgba(138, 206, 224, 0.6))",
}}
/>
) : (
<img
src={profileIcon}
alt="پروفایل"
className="w-[84px] h-[84px] object-cover rounded-full"
/>
)}
</div>
</motion.div>
<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>
{/* دکمه تغییر عکس */}
<motion.button
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.1 }}
onClick={() => setShowAvatarModal(true)}
className="absolute bottom-0 right-0 w-8 h-8 rounded-full flex items-center justify-center"
style={{
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)",
border: "2px solid rgba(255, 255, 255, 0.9)",
}}
>
<Camera className="w-3.5 h-3.5" style={{ color: "#5A3800" }} />
</motion.button>
<div className="relative mx-auto">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.5 }}
className="h-24 w-24 rounded-full p-[2px]"
style={navLikePanelStyle}
>
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-full" style={navLikePanelStyle}>
{userProfile?.image ? (
<ImageWithFallback
src={getProfileImageUrl(userProfile.image, userProfile.user_stage_id)}
alt="پروفایل"
className="h-full w-full rounded-full object-cover"
fallbackSrc={profileIcon}
style={{
filter: "drop-shadow(0 3px 6px rgba(138, 206, 224, 0.6))",
}}
/>
) : (
<img
src={profileIcon}
alt="پروفایل"
className="h-[84px] w-[84px] rounded-full object-cover"
/>
)}
</div>
</motion.div>
{/* دکمه تغییر عکس */}
<motion.button
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.1 }}
onClick={() => setShowAvatarModal(true)}
className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-full"
style={{
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)",
border: "2px solid rgba(255, 255, 255, 0.9)",
}}
>
<Camera className="h-3.5 w-3.5" style={{ color: "#5A3800" }} />
</motion.button>
</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 */}
@ -282,161 +363,36 @@ export function ProfilePage() {
</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>
{/* Tab Switcher */}
<div className="mb-3 flex gap-2">
<motion.button
whileTap={{ scale: 0.97 }}
onClick={() => setActiveTab("challenges")}
className="flex-1 py-2 rounded-2xl font-bold text-[11px] flex items-center justify-center gap-1"
style={{
background: activeTab === "challenges"
? "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 === "challenges"
? "0 3px 12px rgba(255, 165, 0, 0.5)"
: "0 2px 6px rgba(0, 0, 0, 0.3)",
border: activeTab === "challenges"
? "1.5px solid rgba(255, 200, 50, 0.5)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
color: activeTab === "challenges" ? "#5A3800" : "#FFFFFF",
textShadow: activeTab === "challenges"
? "0 1px 0 rgba(255, 255, 255, 0.2)"
: "none",
}}
>
سابقه چالشها
</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>
<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
key={tab.id}
whileTap={{ scale: 0.96 }}
onClick={() => setActiveTab(tab.id)}
className="flex flex-1 items-center justify-center gap-1 rounded-xl py-2.5 text-[11px] font-bold transition-all duration-300"
style={
activeTab === tab.id
? {
background:
"linear-gradient(135deg, rgba(174, 117, 255, 0.96) 0%, rgba(138, 82, 238, 0.95) 46%, rgba(102, 55, 204, 0.94) 100%)",
color: "#FFFFFF",
boxShadow: "0 0 18px rgba(155,108,241,0.4)",
}
: { color: "rgba(251,231,245,0.72)" }
}
>
{tab.label}
</motion.button>
))}
</div>
{/* Challenges Tab */}
@ -448,9 +404,11 @@ export function ProfilePage() {
<div className="text-center text-white/60 py-8">هنوز چالشی ثبت نشده است</div>
) : (
challenges.map((challenge, index) => {
const statusBadge = getStatusBadge(challenge.status);
const coins = parseInt(challenge.coin_count || "0");
const isInProgress = challenge.status === "در حال انجام";
const statusBadge = getStatusBadge(challenge.status);
const showEarnedCoins = !isInProgress &&
(challenge.status === "انجام شده" || challenge.status === "تایید شده") && coins > 0;
return (
<motion.div
@ -464,78 +422,60 @@ export function ProfilePage() {
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={{
background: isInProgress
? "linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 152, 0, 0.15) 100%)"
: "linear-gradient(135deg, rgba(32, 76, 106, 0.5) 0%, rgba(20, 40, 60, 0.5) 100%)",
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: isInProgress
? "1.5px solid rgba(255, 193, 7, 0.4)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
? "1px solid rgba(255, 205, 122, 0.58)"
: "0.5px solid rgba(255, 255, 255, 0.16)",
boxShadow: isInProgress
? "0 4px 16px rgba(255, 193, 7, 0.3)"
: "0 4px 12px rgba(0, 0, 0, 0.3)",
? "0 10px 22px rgba(255, 183, 77, 0.24), inset 0 1px 0 rgba(255,255,255,0.18)"
: "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 } : {}}
whileTap={isInProgress ? { scale: 0.98 } : {}}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-1">
<h3 className={`font-bold text-sm ${isInProgress ? 'text-yellow-300' : 'text-white'}`}>
{challenge.mission_title}
</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>
<span
className="absolute bottom-0 right-0 top-0 w-1.5"
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}
</h3>
<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={{
background: statusBadge.gradient,
border: `1px solid ${statusBadge.border}`,
boxShadow: statusBadge.shadow,
}}
>
{statusBadge.icon}
<span>{statusBadge.text}</span>
{statusBadge.icon}
</div>
</div>
<div className="flex items-center justify-between">
<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}
</span>
)}
</div>
<div className="flex items-center gap-3">
{(challenge.status === "انجام شده" || challenge.status === "تایید شده") && coins > 0 && (
<div className="flex items-center gap-1.5">
<img src={coinImage} alt="سکه" className="w-4 h-4" />
<span className="text-yellow-400 font-bold text-xs">
+{toPersianNumber(coins)}
</span>
</div>
)}
{isInProgress ? (
<span className="text-yellow-300 text-[10px] font-bold">
برای ادامه کلیک کنید
</span>
) : (
<span className="text-white/50 text-[10px]">
{challenge.mission_type}
</span>
)}
<div className="mb-0 text-[11px] font-bold text-[#F2DFF0]/72">
{challenge.mission_type}
</div>
<div className="mt0 flex items-center justify-between text-[11px]">
<span className="text-[#F2DFF0]/72">
{toPersianDigitsInText(challenge.datetime1)}
</span>
<div className="flex items-center gap-2 rounded-full px-2 py-1">
<img src={coinImage} alt="سکه" className="h-5 w-5" />
<span className="text-base font-extrabold leading-none text-[#FFD873]">
{showEarnedCoins ? toPersianNumber(coins) : "۰"}
</span>
</div>
</div>
</motion.div>
@ -552,24 +492,26 @@ export function ProfilePage() {
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="rounded-2xl p-4 mb-4"
className="mb-4 rounded-2xl p-4"
style={{
background: "linear-gradient(135deg, rgba(255, 193, 7, 0.2) 0%, rgba(255, 152, 0, 0.2) 100%)",
border: "2px solid rgba(255, 193, 7, 0.4)",
boxShadow: "0 4px 16px rgba(255, 193, 7, 0.3)",
backgroundImage:
"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%)",
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>
<p className="text-white/70 text-xs mb-1">مجموع سکههای دریافتی</p>
<div className="flex items-center gap-2">
<img src={coinImage} alt="سکه" className="w-8 h-8" />
<span className="text-yellow-300 font-bold text-2xl">
{toPersianNumber(userProfile?.coin_count)}
</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2">
<img src={coinImage} alt="سکه" className="h-9 w-9" />
<span className="text-[32px] font-black leading-none text-[#FFD166]">
{toPersianNumber(userProfile?.coin_count)}
</span>
</div>
<TrendingUp className="w-8 h-8 text-yellow-300" />
<p className="mt-2 text-xs font-bold text-[#FFF4D6]/92">مجموع کل سکهها</p>
<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>
</motion.div>
@ -590,36 +532,57 @@ export function ProfilePage() {
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.08, duration: 0.3 }}
className="rounded-2xl p-4"
className="relative overflow-hidden rounded-2xl px-4 py-3"
style={{
background: isNegative
? "linear-gradient(135deg, rgba(76, 29, 29, 0.5) 0%, rgba(60, 20, 20, 0.5) 100%)"
: "linear-gradient(135deg, rgba(32, 76, 106, 0.5) 0%, rgba(20, 40, 60, 0.5) 100%)",
border: isNegative
? "1.5px solid rgba(239, 68, 68, 0.4)"
: "1.5px solid rgba(138, 206, 224, 0.3)",
backgroundImage: isNegative
? "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(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%)",
backgroundOrigin: "border-box",
backgroundClip: "padding-box, border-box",
border: "1px solid transparent",
boxShadow: isNegative
? "0 4px 12px rgba(239, 68, 68, 0.2)"
: "0 4px 12px rgba(0, 0, 0, 0.3)",
? "0 8px 16px rgba(239, 68, 68, 0.18), inset 0 1px 0 rgba(255, 221, 226, 0.2)"
: "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">
<h3 className="text-white font-bold text-sm flex-1">
{item.description}
</h3>
<div className="flex items-center gap-1.5">
<span
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}
</p>
<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
src={coinImage}
alt="سکه"
className="w-5 h-5"
className="h-4 w-4"
style={{
filter: isNegative ? "grayscale(100%) brightness(0.8)" : "none"
}}
/>
<span
className="font-bold text-sm"
className="text-sm font-extrabold leading-none"
style={{
color: isNegative ? "#ef4444" : "#fcd34d"
color: isNegative ? "#FF9CA8" : "#FFD166"
}}
>
{isNegative ? "-" : "+"}{toPersianNumber(absCoins)}

View File

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

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
import { Send } from "lucide-react";
import { Send, Square } from "lucide-react";
import { motion } from "motion/react";
interface ChatInputBarProps {
@ -21,19 +21,18 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
// Reset height after send and refocus
setTimeout(() => {
if (inputRef.current) {
inputRef.current.style.height = "auto";
inputRef.current.style.height = "24px";
inputRef.current.focus();
}
}, 0);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) {
if (e.key !== "Enter") {
return;
}
e.preventDefault();
handleSend();
// Keep Enter behavior as newline (same as Shift+Enter).
// Sending is intentionally limited to the send button.
};
// Auto-resize textarea based on content
@ -73,14 +72,15 @@ export function ChatInputBar({ onSendMessage, disabled = false }: ChatInputBarPr
dir="rtl"
disabled={disabled}
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={{
fontFamily: "Alibaba, sans-serif",
textAlign: "right",
minHeight: "36px",
minHeight: "24px",
maxHeight: "96px",
lineHeight: "1.4",
lineHeight: "24px",
overflow: "hidden",
padding: 0,
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)",
}}
>
<Send className={`h-5 w-5 ${canSend ? "text-white" : "text-[#CFA8D4]/70"}`} />
{disabled ? (
<Square className="h-4 w-4 text-[#CFA8D4]/80" />
) : (
<Send className={`h-5 w-5 ${canSend ? "text-white" : "text-[#CFA8D4]/70"}`} />
)}
</motion.button>
</div>

View File

@ -1,5 +1,4 @@
import { motion, AnimatePresence } from "motion/react";
import { MessageCircle } from "lucide-react";
import { useEffect } from "react";
interface ChatHistoryItem {
@ -12,38 +11,43 @@ interface ChatHistoryItem {
interface ChatHistoryModalProps {
isOpen: boolean;
onClose: () => void;
isLoading?: boolean;
historyItems: ChatHistoryItem[];
onSelectChat: (chatId: string) => void;
}
const modalStyles = {
overlay: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
backgroundColor: "rgba(0, 0, 0, 0.72)",
},
container: {
background: "linear-gradient(180deg, rgba(32, 76, 106, 0.98) 0%, rgba(20, 40, 60, 0.98) 100%)",
border: "2px solid rgba(138, 206, 224, 0.3)",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.3)",
maxHeight: "85vh",
backgroundImage:
"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%)",
backgroundOrigin: "border-box",
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: {
background: "linear-gradient(135deg, rgba(50, 107, 118, 0.6) 0%, rgba(32, 76, 106, 0.6) 100%)",
border: "1.5px solid rgba(138, 206, 224, 0.3)",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
},
iconContainer: {
background: "linear-gradient(135deg, rgba(138, 206, 224, 0.3) 0%, rgba(76, 127, 137, 0.3) 100%)",
background: "rgba(255, 255, 255, 0.07)",
border: "1px solid rgba(255, 255, 255, 0.12)",
boxShadow: "0 8px 18px rgba(8, 4, 20, 0.24), inset 0 1px 0 rgba(255,255,255,0.1)",
},
closeButton: {
background: "linear-gradient(135deg, rgba(96, 147, 157, 0.6) 0%, rgba(76, 127, 137, 0.6) 100%)",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
border: "1.5px solid rgba(138, 206, 224, 0.3)",
background:
"linear-gradient(145deg, rgba(240, 110, 168, 0.95) 0%, rgba(201, 87, 156, 0.94) 52%, rgba(138, 79, 207, 0.93) 100%)",
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({
isOpen,
onClose,
isLoading = false,
historyItems,
onSelectChat,
}: ChatHistoryModalProps) {
@ -65,69 +69,93 @@ export function ChatHistoryModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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}
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-3xl overflow-hidden"
style={modalStyles.container}
className="flex w-full max-w-md min-h-0 flex-col overflow-hidden rounded-3xl"
style={{ ...modalStyles.container, maxHeight: "min(78dvh, 620px)" }}
>
{/* 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
className="text-lg font-bold text-center text-white"
style={{ textShadow: "0 2px 4px rgba(0, 0, 0, 0.3)" }}
className="text-center text-base font-extrabold text-[#FBE7F5]"
style={{ textShadow: "0 2px 8px rgba(255, 119, 202, 0.3)" }}
>
تاریخچه گفتگوها
</h3>
</div>
{/* Content */}
<div className="overflow-y-auto p-6" style={{ maxHeight: "calc(85vh - 120px)" }}>
<div className="space-y-3">
{historyItems.map((chat, index) => (
<motion.button
key={chat.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
onClose();
onSelectChat(chat.id);
}}
className="w-full p-4 rounded-2xl text-right"
style={modalStyles.chatItem}
>
<div className="flex items-start gap-3">
<div
className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
style={modalStyles.iconContainer}
>
<MessageCircle className="w-5 h-5" style={{ color: "#8ACEE0" }} />
<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">
{historyItems.map((chat, index) => (
<motion.button
key={chat.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.2 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
onClose();
onSelectChat(chat.id);
}}
className="w-full rounded-2xl p-3.5 text-right"
style={modalStyles.chatItem}
>
<div className="flex items-start justify-between gap-2">
<h4 className="line-clamp-1 text-sm font-bold text-[#FBE7F5]">
{chat.title}
</h4>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#ff78c6]" />
</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>
</motion.button>
))}
</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>
))}
</div>
)}
</div>
{/* 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
whileTap={{ scale: 0.95 }}
whileTap={{ scale: 0.96 }}
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}
>
بستن
@ -136,6 +164,11 @@ export function ChatHistoryModal({
</motion.div>
</motion.div>
)}
<style>{`
.min-h-0.flex-1.overflow-y-auto::-webkit-scrollbar {
display: none;
}
`}</style>
</AnimatePresence>
);
}

View File

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