diff --git a/src/layouts/widgets/habit/components/habit-contribution-chart.tsx b/src/layouts/widgets/habit/components/habit-contribution-chart.tsx new file mode 100644 index 00000000..3afb7439 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-contribution-chart.tsx @@ -0,0 +1,407 @@ +import { useMemo, useState } from 'react' +import jalaliMoment from 'jalali-moment' +import moment from 'moment' +import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' +import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' +import { safeAwait } from '@/services/api' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { HABIT_UNIT_STEP } from '@/common/constant/habit-options' +import { useQueryClient } from '@tanstack/react-query' +import { Icon } from '@/src/icons' +import { getHabitUnitLabel } from '../utils' +import { cn } from '@/common/utils/cn' + +interface HabitContributionChartProps { + habit: Habit + color: string +} + +interface DayCell { + gregorianDate: string + jalaliDate: jalaliMoment.Moment + value: number + isDone: boolean + level: number + isFuture: boolean + isToday: boolean +} + +interface WeekColumn { + weekNumber: number + days: DayCell[] + monthLabel?: string +} + +const DISPLAY_WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] +const NUM_WEEKS = 26 + +export function HabitContributionChart({ habit, color }: HabitContributionChartProps) { + const queryClient = useQueryClient() + const { mutateAsync: logProgress, isPending: isUpdating } = useLogHabitProgress() + const [hoveredDay, setHoveredDay] = useState(null) + + const today = useMemo(() => jalaliMoment().locale('fa').startOf('day'), []) + + const getHabitData = (gregorianDate: string) => { + const [year, month] = gregorianDate.split('-') + const monthKey = `${year}-${month}` + const monthData = habit.calendarData?.[monthKey] || {} + return monthData[gregorianDate] || { value: 0, isDone: false } + } + + const { weeks, stats } = useMemo(() => { + const start = today + .clone() + .subtract(NUM_WEEKS - 1, 'weeks') + .startOf('week') + const current = start.clone() + const weeksList: WeekColumn[] = [] + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let totalCompleted = 0 + let totalTrackedDays = 0 + + const allDaysChronological: DayCell[] = [] + let lastMonthName = '' + + for (let w = 0; w < NUM_WEEKS; w++) { + const weekDays: DayCell[] = [] + let weekMonthLabel = '' + + for (let d = 0; d < 7; d++) { + const dayDate = current.clone() + const gregorianDate = moment(dayDate.toDate()).format('YYYY-MM-DD') + const habitData = getHabitData(gregorianDate) + const isDayToday = dayDate.isSame(today, 'day') + const isFuture = dayDate.isAfter(today, 'day') + + const value = habitData.value + const isDone = + habitData.isDone || (habit.target > 0 && value >= habit.target) + + let level = 0 + if (value > 0) { + if (habit.target > 0) { + const ratio = value / habit.target + if (ratio >= 1.5) level = 4 + else if (ratio >= 1) level = 3 + else if (ratio >= 0.5) level = 2 + else level = 1 + } else { + level = 3 + } + } + + const cell: DayCell = { + gregorianDate, + jalaliDate: dayDate, + value, + isDone, + level, + isFuture, + isToday: isDayToday, + } + + weekDays.push(cell) + + if (!isFuture) { + allDaysChronological.push(cell) + totalTrackedDays++ + if (isDone) totalCompleted++ + } + + const monthName = dayDate.format('jMMMM') + if (d === 0 && monthName !== lastMonthName) { + weekMonthLabel = monthName + lastMonthName = monthName + } + + current.add(1, 'day') + } + + weeksList.push({ + weekNumber: w, + days: weekDays, + monthLabel: weekMonthLabel, + }) + } + + for (const day of allDaysChronological) { + if (day.isDone) { + tempStreak++ + if (tempStreak > longestStreak) longestStreak = tempStreak + } else { + tempStreak = 0 + } + } + + const lastIdx = allDaysChronological.length - 1 + if (lastIdx >= 0) { + let checkIdx = lastIdx + if ( + !allDaysChronological[checkIdx].isDone && + checkIdx > 0 && + allDaysChronological[checkIdx - 1].isDone + ) { + checkIdx-- + } + while (checkIdx >= 0 && allDaysChronological[checkIdx].isDone) { + currentStreak++ + checkIdx-- + } + } + + const completionRate = + totalTrackedDays > 0 + ? Math.round((totalCompleted / totalTrackedDays) * 100) + : 0 + + return { + weeks: weeksList, + stats: { + currentStreak, + longestStreak, + totalCompleted, + completionRate, + }, + } + }, [habit, color, today]) + + const handleDayClick = async (cell: DayCell) => { + if (cell.isFuture || isUpdating) return + + let step = HABIT_UNIT_STEP[habit.unit] || 1 + const currentVal = cell.value + + if ( + habit.comparison === HabitComparison.EXACT && + currentVal + step > habit.target + ) { + step = 0 + } + + if ( + habit.comparison === HabitComparison.AT_MOST && + currentVal + step > habit.target + ) { + showToast(`مقدار فعلی به حداکثر هدف شما (${habit.target}) رسیده`, 'error') + return + } + + const [error] = await safeAwait( + logProgress({ + id: habit.id, + input: { date: cell.gregorianDate, amount: step }, + }) + ) + + if (error) { + autoFormatErrorToast(error) + return + } + + queryClient.invalidateQueries({ queryKey: ['get-habit-detail', habit.id] }) + } + + const getCellColor = (level: number) => { + switch (level) { + case 1: + return `${color}33` + case 2: + return `${color}66` + case 3: + return `${color}aa` + case 4: + return color + default: + return undefined + } + } + + const unitLabel = getHabitUnitLabel(habit) + + return ( +
+
+
+
+ + + + استریک فعلی +
+
+ + {stats.currentStreak} + + روز +
+
+ +
+
+ + + + بهترین رکورد +
+
+ + {stats.longestStreak} + + روز +
+
+ +
+
+ + + + روزهای موفق +
+
+ + {stats.totalCompleted} + + روز +
+
+ +
+
+ + + + نرخ موفقیت +
+
+ + {stats.completionRate}٪ + +
+
+
+ +
+
+
+
+ {weeks.map((week) => ( +
+ {week.monthLabel || ''} +
+ ))} +
+ +
+
+ {DISPLAY_WEEKDAYS.map((dayName, idx) => ( +
+ {dayName} +
+ ))} +
+ +
+ {weeks.map((week) => ( +
+ {week.days.map((day) => { + const cellBg = getCellColor(day.level) + return ( +
+ ))} +
+
+
+
+ +
+
+ {hoveredDay ? ( + <> + + {hoveredDay.jalaliDate.format('dddd، jD jMMMM')} + + : + + {hoveredDay.value > 0 + ? `${hoveredDay.value} ${unitLabel}` + : 'بدون ثبت'} + + {hoveredDay.isDone && ( + + (انجام شد) + + )} + + ) : ( + برای ثبت، روی روزها کلیک کن + )} +
+ +
+ کمتر +
+
+
+
+
+ بیشتر +
+
+
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit-detail.modal.tsx b/src/layouts/widgets/habit/components/habit-detail.modal.tsx index 373b0cac..a8acf79a 100644 --- a/src/layouts/widgets/habit/components/habit-detail.modal.tsx +++ b/src/layouts/widgets/habit/components/habit-detail.modal.tsx @@ -1,12 +1,16 @@ +import { useState } from 'react' import { Button, Modal } from '@/components/ui' import { useGetHabitDetail } from '@/services/hooks/habit/get-habit-detail.hook' import { HabitCalendar } from './habit-calendar-heatmap' +import { HabitContributionChart } from './habit-contribution-chart' +import { HabitShareModal } from './habit-share-modal' import { useAuth } from '@/context/auth.context' import { formatHabitGoal } from '../utils' import { Dropdown } from '@/components/ui' import type { Habit } from '@/services/hooks/habit/habit.interface' import { callEvent } from '@/common/utils/call-event' import { Icon } from '@/src/icons' +import { cn } from '@/common/utils/cn' interface ModalProps { isOpen: boolean @@ -24,6 +28,11 @@ export function HabitDetailModal({ onEdit, }: ModalProps) { const { isAuthenticated } = useAuth() + const [activeView, setActiveView] = useState<'contribution' | 'calendar'>( + 'contribution' + ) + const [isShareModalOpen, setIsShareModalOpen] = useState(false) + const onClickEdit = () => { if (habit) { callEvent('closeAllDropdowns') @@ -111,32 +120,92 @@ export function HabitDetailModal({ ) return ( - - {isLoading ? ( -
-
-
- ) : error ? ( -
-

- خطا در دریافت اطلاعات -

-

- لطفا چند لحظه دیگر دوباره تلاش کنید. -

-
- ) : !habit ? ( -
-
📭
-

اطلاعات این عادت پیدا نشد.

-
- ) : ( -
+ <> + + {isLoading ? (
- +
-
+ ) : error ? ( +
+

+ خطا در دریافت اطلاعات +

+

+ لطفا چند لحظه دیگر دوباره تلاش کنید +

+
+ ) : !habit ? ( +
+
📭
+

اطلاعات این عادت پیدا نشد

+
+ ) : ( +
+
+
+ + +
+
+ + {activeView === 'contribution' ? ( + + ) : ( + + )} +
+ )} + +
+ + {habit && ( + setIsShareModalOpen(false)} + habit={habit} + color={color} + /> )} - + ) } diff --git a/src/layouts/widgets/habit/components/habit-share-modal.tsx b/src/layouts/widgets/habit/components/habit-share-modal.tsx new file mode 100644 index 00000000..738b3f3e --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-share-modal.tsx @@ -0,0 +1,691 @@ +import { useEffect, useRef, useState } from 'react' +import jalaliMoment from 'jalali-moment' +import moment from 'moment' +import { Button, Modal } from '@/components/ui' +import { Icon } from '@/src/icons' +import { showToast } from '@/common/toast' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { formatHabitGoal } from '../utils' + +interface HabitShareModalProps { + isOpen: boolean + onClose: () => void + habit: Habit + color: string +} + +function drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +) { + const radius = Math.min(r, w / 2, h / 2) + + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + w - radius, y) + ctx.arcTo(x + w, y, x + w, y + radius, radius) + ctx.lineTo(x + w, y + h - radius) + ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius) + ctx.lineTo(x + radius, y + h) + ctx.arcTo(x, y + h, x, y + h - radius, radius) + ctx.lineTo(x, y + radius) + ctx.arcTo(x, y, x + radius, y, radius) + ctx.closePath() +} + +function hexToRgb(hex: string) { + const normalized = hex.replace('#', '') + + if (normalized.length !== 6) { + return { r: 83, g: 109, b: 254 } + } + + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + } +} + +function rgba(color: string, alpha: number) { + const { r, g, b } = hexToRgb(color) + + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { + let result = text + + while (result.length > 0 && ctx.measureText(result).width > maxWidth) { + result = result.slice(0, -1) + } + + if (result !== text) { + return `${result.trim()}…` + } + + return result +} + +export function HabitShareModal({ isOpen, onClose, habit, color }: HabitShareModalProps) { + const canvasRef = useRef(null) + + const [isGenerating, setIsGenerating] = useState(false) + const [streakInfo, setStreakInfo] = useState<{ + current: number + longest: number + } | null>(null) + + useEffect(() => { + if (!isOpen || !habit) return + + const canvas = canvasRef.current + + if (!canvas) return + + const ctx = canvas.getContext('2d') + + if (!ctx) return + + // --------------------------------------------------------- + // Canvas + // --------------------------------------------------------- + + const dpr = 2 + const width = 800 + const height = 520 + + canvas.width = width * dpr + canvas.height = height * dpr + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + // --------------------------------------------------------- + // Palette + // --------------------------------------------------------- + + const accent = color || '#536dfe' + + const background = '#0f1014' + const primary = '#f7f7f8' + const secondary = '#a7a7b0' + const muted = '#62636d' + + // --------------------------------------------------------- + // Background + // --------------------------------------------------------- + + ctx.fillStyle = background + ctx.fillRect(0, 0, width, height) + + // Very subtle accent atmosphere. + // No gradients and no dashboard-like panels. + ctx.fillStyle = rgba(accent, 0.045) + ctx.beginPath() + ctx.arc(118, 86, 170, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = rgba(accent, 0.025) + ctx.beginPath() + ctx.arc(700, 385, 210, 0, Math.PI * 2) + ctx.fill() + + // Fine border. + ctx.strokeStyle = '#24252b' + ctx.lineWidth = 1 + + drawRoundedRect(ctx, 0.5, 0.5, width - 1, height - 1, 30) + + ctx.stroke() + + // --------------------------------------------------------- + // Data + // --------------------------------------------------------- + + const today = jalaliMoment().locale('fa').startOf('day') + + const numWeeks = 26 + + const start = today + .clone() + .subtract(numWeeks - 1, 'weeks') + .startOf('week') + + const current = start.clone() + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let totalCompleted = 0 + + const allDaysChronological: { + gregorianDate: string + isDone: boolean + level: number + jalaliDate: jalaliMoment.Moment + }[] = [] + + const weeksGrid: { + days: { + level: number + isFuture: boolean + }[] + }[] = [] + + const monthMarkers: { + name: string + weekIndex: number + }[] = [] + + let currentMonth = '' + + for (let w = 0; w < numWeeks; w++) { + const daysInWeek: { + level: number + isFuture: boolean + }[] = [] + + for (let d = 0; d < 7; d++) { + const dayDate = current.clone() + + const gregorianDate = moment(dayDate.toDate()).format('YYYY-MM-DD') + + const [year, month] = gregorianDate.split('-') + + const monthData = habit.calendarData?.[`${year}-${month}`] || {} + + const dayData = monthData[gregorianDate] || { + value: 0, + isDone: false, + } + + const isFuture = dayDate.isAfter(today, 'day') + + const value = dayData.value + + const isDone = + dayData.isDone || (habit.target > 0 && value >= habit.target) + + let level = 0 + + if (value > 0) { + if (habit.target > 0) { + const ratio = value / habit.target + + if (ratio >= 1.5) { + level = 4 + } else if (ratio >= 1) { + level = 3 + } else if (ratio >= 0.5) { + level = 2 + } else { + level = 1 + } + } else { + level = 3 + } + } + + daysInWeek.push({ + level, + isFuture, + }) + + if (!isFuture) { + allDaysChronological.push({ + gregorianDate, + isDone, + level, + jalaliDate: dayDate, + }) + + if (isDone) { + totalCompleted++ + } + } + + const monthName = dayDate.format('jMMMM') + + if (monthName !== currentMonth) { + currentMonth = monthName + + monthMarkers.push({ + name: monthName, + weekIndex: w, + }) + } + + current.add(1, 'day') + } + + weeksGrid.push({ + days: daysInWeek, + }) + } + + // --------------------------------------------------------- + // Longest streak + // --------------------------------------------------------- + + for (const day of allDaysChronological) { + if (day.isDone) { + tempStreak++ + + if (tempStreak > longestStreak) { + longestStreak = tempStreak + } + } else { + tempStreak = 0 + } + } + + // --------------------------------------------------------- + // Current streak + // --------------------------------------------------------- + + const lastIdx = allDaysChronological.length - 1 + + if (lastIdx >= 0) { + let checkIdx = lastIdx + + if ( + !allDaysChronological[checkIdx].isDone && + checkIdx > 0 && + allDaysChronological[checkIdx - 1].isDone + ) { + checkIdx-- + } + + while (checkIdx >= 0 && allDaysChronological[checkIdx].isDone) { + currentStreak++ + checkIdx-- + } + } + + setStreakInfo({ + current: currentStreak, + longest: longestStreak, + }) + + // --------------------------------------------------------- + // TOP BRAND + // --------------------------------------------------------- + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + ctx.font = '600 11px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = muted + + ctx.fillText('افزونه نیوتب مرورگر ویجتیفای', 36, 35) + + // small accent dot + ctx.fillStyle = accent + + ctx.beginPath() + ctx.arc(175, 35, 3, 0, Math.PI * 2) + ctx.fill() + + // --------------------------------------------------------- + // HERO STREAK + // --------------------------------------------------------- + + ctx.textAlign = 'right' + ctx.textBaseline = 'alphabetic' + + // Number + ctx.font = '800 96px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = primary + + ctx.fillText(String(currentStreak), width - 36, 108) + + // Accent underline + const numberWidth = ctx.measureText(String(currentStreak)).width + + ctx.fillStyle = accent + + drawRoundedRect(ctx, width - 36 - numberWidth, 119, numberWidth, 4, 2) + + ctx.fill() + + // Streak label + ctx.font = '500 13px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = secondary + + ctx.fillText('روز متوالی', width - 36, 145) + + // Fire + if (currentStreak > 0) { + ctx.font = + '28px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif' + + ctx.fillText('🔥', width - 110, 104) + } + + // --------------------------------------------------------- + // HABIT IDENTITY + // --------------------------------------------------------- + + const identityCenterX = 190 + const identityTop = 87 + + // Large emoji + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + ctx.font = + '78px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif' + + ctx.fillText(habit.emoji || '🎯', identityCenterX, identityTop + 22) + + // Accent micro-line + ctx.fillStyle = accent + + drawRoundedRect(ctx, identityCenterX - 20, identityTop + 72, 40, 4, 2) + + ctx.fill() + + // Title + ctx.textBaseline = 'top' + + ctx.font = '700 25px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = primary + + const title = fitText(ctx, habit.title || 'عادت من', 270) + + ctx.fillText(title, identityCenterX, identityTop + 88) + + // Goal + ctx.font = '400 12px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = secondary + + const goal = fitText(ctx, formatHabitGoal(habit), 270) + + ctx.fillText(goal, identityCenterX, identityTop + 124) + + // --------------------------------------------------------- + // CHART AREA + // --------------------------------------------------------- + + const chartX = 36 + const chartY = 225 + const chartWidth = width - 72 + + // Heading + ctx.textAlign = 'right' + ctx.textBaseline = 'top' + + ctx.font = '600 12px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = secondary + + ctx.fillText('فعالیت ۶ ماه اخیر', chartX + chartWidth, chartY) + + // Completed days + ctx.font = '400 10px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = muted + + ctx.fillText(`${totalCompleted} روز موفق`, chartX + chartWidth - 105, chartY + 2) + + // --------------------------------------------------------- + // Heatmap + // --------------------------------------------------------- + + const cellSize = 17 + const gap = 4 + + const gridTop = chartY + 38 + + const gridRight = chartX + chartWidth - 27 + + const dayLabels = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + + // Month labels + ctx.font = '500 9px Arad, Vazir, sans-serif' + + ctx.fillStyle = muted + ctx.textBaseline = 'middle' + + for (const marker of monthMarkers) { + const colX = gridRight - marker.weekIndex * (cellSize + gap) - cellSize + + let textX = colX + cellSize / 2 + + let align: CanvasTextAlign = 'center' + + if (marker.weekIndex === 0) { + align = 'right' + textX = colX + cellSize + } + + if (marker.weekIndex >= numWeeks - 2) { + align = 'left' + textX = colX + } + + ctx.textAlign = align + + ctx.fillText(marker.name, textX, chartY + 29) + } + + // Day labels + ctx.font = '500 9px Arad, Vazir, sans-serif' + + ctx.textAlign = 'center' + ctx.fillStyle = muted + + for (let d = 0; d < 7; d++) { + const y = gridTop + d * (cellSize + gap) + cellSize / 2 + + ctx.fillText(dayLabels[d], gridRight + 18, y) + } + + // Cells + weeksGrid.forEach((week, w) => { + const colX = gridRight - w * (cellSize + gap) - cellSize + + week.days.forEach((day, d) => { + const cellY = gridTop + d * (cellSize + gap) + + let bg = '#24252a' + + if (day.isFuture) { + bg = '#17181c' + } else { + if (day.level === 1) { + bg = rgba(accent, 0.22) + } + + if (day.level === 2) { + bg = rgba(accent, 0.43) + } + + if (day.level === 3) { + bg = rgba(accent, 0.7) + } + + if (day.level === 4) { + bg = accent + } + } + + ctx.fillStyle = bg + + drawRoundedRect(ctx, colX, cellY, cellSize, cellSize, 4) + + ctx.fill() + }) + }) + + // --------------------------------------------------------- + // BOTTOM INFORMATION + // --------------------------------------------------------- + + const bottomY = 454 + + // Longest streak + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + ctx.font = '400 10px Vazir, "Segoe UI", sans-serif' + + ctx.fillStyle = muted + + ctx.fillText(`بهترین رکورد ${longestStreak} روز`, 36, bottomY) + + // Brand + ctx.textAlign = 'right' + + ctx.fillText('یک قدم کوچک، هر روز', width - 36, bottomY) + + // --------------------------------------------------------- + // Legend + // --------------------------------------------------------- + + const legendY = 454 + let legendX = width / 2 - 38 + + const legendColors = [ + '#24252a', + rgba(accent, 0.22), + rgba(accent, 0.43), + rgba(accent, 0.7), + accent, + ] + + for (let i = 0; i < legendColors.length; i++) { + ctx.fillStyle = legendColors[i] + + drawRoundedRect(ctx, legendX, legendY - 5, 11, 11, 3) + + ctx.fill() + + legendX += 15 + } + }, [isOpen, habit, color]) + + // ----------------------------------------------------------- + // Copy + // ----------------------------------------------------------- + + const handleCopyImage = () => { + const canvas = canvasRef.current + + if (!canvas) return + + setIsGenerating(true) + + canvas.toBlob( + async (blob) => { + setIsGenerating(false) + + if (!blob) { + showToast('خطا در ایجاد تصویر', 'error') + + return + } + + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob, + }), + ]) + + showToast('تصویر در کلیپ‌بورد کپی شد', 'success') + } catch { + showToast( + 'امکان کپی خودکار در مرورگر وجود ندارد، تصویر را ذخیره کن', + 'warning' + ) + } + }, + 'image/png', + 1 + ) + } + + const handleDownloadImage = () => { + const canvas = canvasRef.current + + if (!canvas) return + + const dataUrl = canvas.toDataURL('image/png') + + const link = document.createElement('a') + + link.download = `عادت-${habit.title || 'habit'}.png` + + link.href = dataUrl + link.click() + + showToast('تصویر با موفقیت ذخیره شد', 'success') + } + + return ( + + + + + اشتراک‌گذاری پیشرفت + +
+ } + > +
+
+ +
+ +
+ + +
+ + + +
+
+
+ + ) +}