diff --git a/src/components/gallery/gallery-picker-modal.tsx b/src/components/gallery/gallery-picker-modal.tsx index 62e59da9d..aefe02079 100644 --- a/src/components/gallery/gallery-picker-modal.tsx +++ b/src/components/gallery/gallery-picker-modal.tsx @@ -95,7 +95,7 @@ export function GalleryPickerModal({ >
{categories.length > 0 && ( -
+
setSelectedCategory('ALL')} diff --git a/src/components/ui/color-picker/color-picker.tsx b/src/components/ui/color-picker/color-picker.tsx index 998a8cb6e..a561e26c5 100644 --- a/src/components/ui/color-picker/color-picker.tsx +++ b/src/components/ui/color-picker/color-picker.tsx @@ -1,29 +1,66 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { RgbaStringColorPicker } from 'react-colorful' +import { HexColorPicker, RgbaStringColorPicker } from 'react-colorful' +import { Portal } from '../portal/portal' export interface ColorPickerProps { color: string onChange: (color: string) => void className?: string + mode?: 'hex' | 'rgba' } export const ColorPicker: React.FC = ({ color, onChange, className = '', + mode = 'hex', }) => { const [isOpen, setIsOpen] = useState(false) - const containerRef = useRef(null) + const triggerRef = useRef(null) + const popupRef = useRef(null) + const [coords, setCoords] = useState<{ top: number; left: number }>({ + top: 0, + left: 0, + }) const displayColor = color || '#000000' + const updatePosition = () => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + const popupWidth = 220 + const popupHeight = 220 + + let left = rect.left + if (left + popupWidth > window.innerWidth - 16) { + left = window.innerWidth - popupWidth - 16 + } + if (left < 16) { + left = 16 + } + + let top = rect.bottom + 8 + if (top + popupHeight > window.innerHeight - 16) { + top = rect.top - popupHeight - 8 + } + + setCoords({ top, left }) + } + useEffect(() => { if (!isOpen) return + updatePosition() + const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node - if (containerRef.current && !containerRef.current.contains(target)) { + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + popupRef.current && + !popupRef.current.contains(target) + ) { setIsOpen(false) } } @@ -34,36 +71,57 @@ export const ColorPicker: React.FC = ({ } } + const handleScroll = () => { + updatePosition() + } + const timer = setTimeout(() => { document.addEventListener('mousedown', handleClickOutside) document.addEventListener('keydown', handleKeyDown) + window.addEventListener('scroll', handleScroll, true) + window.addEventListener('resize', handleScroll) }, 0) return () => { clearTimeout(timer) document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('scroll', handleScroll, true) + window.removeEventListener('resize', handleScroll) } }, [isOpen]) return ( -
+
setIsOpen((prev) => !prev)} - className="w-8 h-8 p-1 transition-transform border-0 rounded-md shadow-xs cursor-pointer hover:scale-105 active:scale-95" + className="w-8 h-8 p-1 transition-transform border-0 shadow-xs cursor-pointer rounded-xl hover:scale-105 active:scale-95" style={{ backgroundColor: displayColor }} /> {isOpen && ( -
- -
+ +
+ {mode === 'hex' ? ( + + ) : ( + + )} +
+
)}
) diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index 2844554a6..95a35955d 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -100,7 +100,9 @@ export function Modal({ style={ { '--modal-duration': `${modalDurationMs}ms`, - zIndex: assignedZIndex ?? (customZIndex !== undefined ? customZIndex : BASE_MODAL_Z_INDEX), + zIndex: + assignedZIndex ?? + (customZIndex !== undefined ? customZIndex : BASE_MODAL_Z_INDEX), } as React.CSSProperties } > @@ -123,7 +125,7 @@ export function Modal({ - + + +
+
+
+ + +
-
- {activeView === 'contribution' ? ( - - ) : ( - - )} + {activeView === 'contribution' ? ( + + ) : ( + + )} +
)} - + +
+ + +
{habit && isShareModalOpen && ( diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx new file mode 100644 index 000000000..40a196b59 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { Button } from '@/components/ui' + +interface HabitFormActionsProps { + isEdit: boolean + isPending: boolean + onClose: () => void + onSubmit: () => void +} + +export const HabitFormActions: React.FC = React.memo( + ({ isEdit, isPending, onClose, onSubmit }) => { + return ( +
+ + + +
+ ) + } +) + +HabitFormActions.displayName = 'HabitFormActions' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx new file mode 100644 index 000000000..7f852d9d5 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +interface HabitFormHeaderProps { + isEdit: boolean + onClose: () => void +} + +export const HabitFormHeader: React.FC = React.memo( + ({ isEdit, onClose }) => { + return ( +
+
+

+ {isEdit ? 'ویرایش عادت' : 'عادت جدید'} +

+

+ از یک الگو شروع کن یا خودت بساز +

+
+ + +
+ ) + } +) + +HabitFormHeader.displayName = 'HabitFormHeader' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx new file mode 100644 index 000000000..7900b06a5 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import type { HabitPresetItem } from './habit-form.constants' +import { Chip } from '@/components/ui' + +interface HabitFormPresetsProps { + presets: HabitPresetItem[] + activePresetId: string | null + onSelectPreset: (preset: HabitPresetItem) => void +} + +export const HabitFormPresets: React.FC = React.memo( + ({ presets, activePresetId, onSelectPreset }) => { + return ( +
+ {presets.map((preset) => { + const isSelected = activePresetId === preset.id + + return ( + onSelectPreset(preset)} + className="flex items-center gap-1.5 shrink-0 px-1.5 py-1" + > + {preset.emoji} + {preset.label} + + ) + })} +
+ ) + } +) + +HabitFormPresets.displayName = 'HabitFormPresets' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form.constants.ts b/src/layouts/widgets/habit/components/habit-form-modal/habit-form.constants.ts new file mode 100644 index 000000000..d97ab9e7c --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form.constants.ts @@ -0,0 +1,111 @@ +import type { CreateHabitInput } from '@/services/hooks/habit/habit.interface' +import { + HabitComparison, + HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' + +export interface HabitPresetItem { + id: string + label: string + emoji: string + color: string + values: CreateHabitInput +} + +export const HABIT_QUICK_PRESETS: HabitPresetItem[] = [ + { + id: 'water', + label: '۸ لیوان آب', + emoji: '💧', + color: '#3b82f6', + values: { + title: 'نوشیدن آب', + emoji: '💧', + color: '#3b82f6', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.GLASSES, + target: 8, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'walk', + label: '۳ کیلومتر پیاده‌روی', + emoji: '🏃', + color: '#f97316', + values: { + title: 'پیاده‌روی', + emoji: '🏃', + color: '#f97316', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.CUSTOM, + customUnit: 'کیلومتر', + target: 3, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'book', + label: '۲۰ صفحه کتاب', + emoji: '📖', + color: '#06b6d4', + values: { + title: 'مطالعه کتاب', + emoji: '📖', + color: '#06b6d4', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.PAGES, + target: 20, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'meditation', + label: 'مدیتیشن', + emoji: '🧘', + color: '#8b5cf6', + values: { + title: 'مدیتیشن و تنفس', + emoji: '🧘', + color: '#8b5cf6', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.MINUTES, + target: 15, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, +] + +export interface EmojiCategory { + id: string + label: string + emojis: string[] +} + +export const HABIT_EMOJI_CATEGORIES: EmojiCategory[] = [ + { + id: 'health', + label: 'سلامتی', + emojis: ['💧', '🥗', '💊', '😴', '🧘', '🦷', '🚭'], + }, + { + id: 'sport', + label: 'ورزش', + emojis: ['🏃', '🚴', '🏋️', '🏊', '⚽', '🤸', '🧗'], + }, + { + id: 'study', + label: 'مطالعه', + emojis: ['📖', '✍️', '💻', '🎨', '🧠', '🎧', '🎯'], + }, + { + id: 'lifestyle', + label: 'سبک زندگی', + emojis: ['☕', '🍵', '🧹', '🪴', '🍎', '🚶', '✨'], + }, +] diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx new file mode 100644 index 000000000..4458679c3 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx @@ -0,0 +1,286 @@ +import React, { useCallback, useMemo } from 'react' +import { + type HabitComparison, + type HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' +import { + HABIT_COMPARISON_OPTIONS, + HABIT_FREQUENCY_OPTIONS, + HABIT_UNIT_OPTIONS, +} from '@/common/constant/habit-options' +import { Chip, Dropdown, type DropdownOption, TextInput } from '@/components/ui' +import { Icon } from '@/src/icons' + +interface HabitGoalFrequencySectionProps { + target: number + unit: HabitUnit + customUnit?: string + frequency: HabitFrequency + comparison: HabitComparison + onChangeTarget: (val: number) => void + onChangeUnit: (val: HabitUnit) => void + onChangeCustomUnit?: (val: string) => void + onChangeFrequency: (val: HabitFrequency) => void + onChangeComparison: (val: HabitComparison) => void +} + +const PRIMARY_UNIT_IDS: HabitUnit[] = [ + HabitUnit.TIMES, + HabitUnit.MINUTES, + HabitUnit.GLASSES, + HabitUnit.PAGES, +] + +const MORE_UNIT_IDS: HabitUnit[] = [HabitUnit.HOURS, HabitUnit.CUSTOM] + +interface StepperCounterProps { + target: number + onDecrease: () => void + onIncrease: () => void +} + +const StepperCounter = React.memo( + ({ target, onDecrease, onIncrease }) => { + return ( +
+ + + {target} + + +
+ ) + } +) +StepperCounter.displayName = 'StepperCounter' + +interface UnitSelectionRowProps { + unit: HabitUnit + onChangeUnit: (val: HabitUnit) => void +} + +const UnitSelectionRow = React.memo(({ unit, onChangeUnit }) => { + const primaryUnits = useMemo( + () => + PRIMARY_UNIT_IDS.map((id) => ({ + id, + label: HABIT_UNIT_OPTIONS.find((opt) => opt.value === id)?.label || '', + })), + [] + ) + + const moreUnits = useMemo( + () => + MORE_UNIT_IDS.map((id) => ({ + id, + label: HABIT_UNIT_OPTIONS.find((opt) => opt.value === id)?.label || '', + })), + [] + ) + + const isMoreUnitSelected = MORE_UNIT_IDS.includes(unit) + const selectedMoreUnit = moreUnits.find((u) => u.id === unit) + + const moreUnitDropdownOptions: DropdownOption[] = useMemo( + () => + moreUnits.map((opt) => ({ + id: opt.id, + label: ( +
+ {opt.label} + {unit === opt.id && ( + + )} +
+ ), + onClick: () => onChangeUnit(opt.id), + })), + [moreUnits, unit, onChangeUnit] + ) + + return ( +
+ {primaryUnits.map((opt) => { + const isSelected = unit === opt.id + + return ( + onChangeUnit(opt.id)} + className="px-2.5 py-1 rounded-full text-xs font-medium shrink-0" + > + {opt.label} + + ) + })} + + {}} + className="flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium shrink-0" + > + + {isMoreUnitSelected ? selectedMoreUnit?.label : 'بیشتر'} + + + + } + options={moreUnitDropdownOptions} + position="bottom-left" + width="8rem" + /> +
+ ) +}) +UnitSelectionRow.displayName = 'UnitSelectionRow' + +interface FrequencyComparisonRowProps { + frequency: HabitFrequency + comparison: HabitComparison + onChangeFrequency: (val: HabitFrequency) => void + onChangeComparison: (val: HabitComparison) => void +} + +const FrequencyComparisonRow = React.memo( + ({ frequency, comparison, onChangeFrequency, onChangeComparison }) => { + const comparisonDropdownOptions: DropdownOption[] = useMemo( + () => + HABIT_COMPARISON_OPTIONS.map((opt) => ({ + id: opt.value, + label: ( +
+ {opt.label} + {comparison === opt.value && ( + + )} +
+ ), + onClick: () => onChangeComparison(opt.value as HabitComparison), + })), + [comparison, onChangeComparison] + ) + + const currentComparisonLabel = + HABIT_COMPARISON_OPTIONS.find((c) => c.value === comparison)?.label || 'حداقل' + + return ( +
+
+ تکرار +
+ {HABIT_FREQUENCY_OPTIONS.map((freqOpt) => ( + + onChangeFrequency(freqOpt.value as HabitFrequency) + } + className="px-3 py-1 text-xs font-medium rounded-full" + > + {freqOpt.label} + + ))} +
+
+ + + {currentComparisonLabel} + +
+ } + options={comparisonDropdownOptions} + position="bottom-left" + width="9rem" + /> +
+ ) + } +) +FrequencyComparisonRow.displayName = 'FrequencyComparisonRow' + +export const HabitGoalFrequencySection: React.FC = + React.memo( + ({ + target, + unit, + customUnit = '', + frequency, + comparison, + onChangeTarget, + onChangeUnit, + onChangeCustomUnit, + onChangeFrequency, + onChangeComparison, + }) => { + const handleDecrease = useCallback(() => { + if (target > 1) { + onChangeTarget(target - 1) + } + }, [target, onChangeTarget]) + + const handleIncrease = useCallback(() => { + onChangeTarget(target + 1) + }, [target, onChangeTarget]) + + return ( +
+
+
+ + هدف روزانه + + +
+ + +
+ + {unit === HabitUnit.CUSTOM && onChangeCustomUnit && ( +
+ + نام واحد دلخواه: + + onChangeCustomUnit(val)} + placeholder="مثال: کیلومتر، ست، فنجان" + size="sm" + className="flex-1 text-xs" + /> +
+ )} + + +
+ ) + }, + ) + +HabitGoalFrequencySection.displayName = 'HabitGoalFrequencySection' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx new file mode 100644 index 000000000..0ec31b6ce --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useState } from 'react' +import { HABIT_EMOJI_CATEGORIES } from './habit-form.constants' +import { HABIT_COLOR_PRESETS } from '@/common/constant/habit-options' +import { Chip, ColorPicker } from '@/components/ui' +import { cn } from '@/common/utils/cn' + +interface HabitIconColorPickerProps { + selectedEmoji: string + selectedColor: string + onSelectEmoji: (emoji: string) => void + onSelectColor: (color: string) => void +} + +interface EmojiItemProps { + emoji: string + isSelected: boolean + onSelect: (emoji: string) => void +} + +const EmojiItem = React.memo(({ emoji, isSelected, onSelect }) => { + const handleClick = useCallback(() => { + onSelect(emoji) + }, [emoji, onSelect]) + + return ( + + ) +}) +EmojiItem.displayName = 'EmojiItem' + +interface ColorSwatchProps { + color: string + isSelected: boolean + onSelect: (color: string) => void +} + +const ColorSwatch = React.memo(({ color, isSelected, onSelect }) => { + const handleClick = useCallback(() => { + onSelect(color) + }, [color, onSelect]) + + return ( + + ) +}) +ColorSwatch.displayName = 'ColorSwatch' + +export const HabitIconColorPicker: React.FC = React.memo( + ({ selectedEmoji, selectedColor, onSelectEmoji, onSelectColor }) => { + const [activeCategory, setActiveCategory] = useState('health') + + const currentCategory = HABIT_EMOJI_CATEGORIES.find( + (c) => c.id === activeCategory + ) + + return ( +
+
+
+ شکلک +
+ {HABIT_EMOJI_CATEGORIES.map((cat) => ( + setActiveCategory(cat.id)} + key={cat.id} + selected={activeCategory === cat.id} + className="py-1! px-1.5! border-none text-[11px]" + > + {cat.label} + + ))} +
+
+ +
+ {currentCategory?.emojis.map((emoji) => ( + + ))} +
+
+ +
+
+ رنگ +
+ +
+
+ +
+ {HABIT_COLOR_PRESETS.map((color) => ( + + ))} +
+
+
+ ) + }, + ) + +HabitIconColorPicker.displayName = 'HabitIconColorPicker' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx new file mode 100644 index 000000000..3a75a6cb1 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { addOpacityToColor } from '@/common/color' +import { + HABIT_FREQUENCY_OPTIONS, + HABIT_UNIT_OPTIONS, +} from '@/common/constant/habit-options' +import { type HabitFrequency, HabitUnit } from '@/services/hooks/habit/habit.interface' +import { Icon } from '@/src/icons' + +interface HabitLivePreviewProps { + title: string + emoji: string + color: string + target: number + unit: HabitUnit + customUnit?: string + frequency: HabitFrequency +} + +export const HabitLivePreview: React.FC = React.memo( + ({ title, emoji, color, target, unit, customUnit, frequency }) => { + const defaultUnitLabel = + HABIT_UNIT_OPTIONS.find((opt) => opt.value === unit)?.label || '' + const unitText = unit === HabitUnit.CUSTOM ? customUnit || '' : defaultUnitLabel + const freqText = + HABIT_FREQUENCY_OPTIONS.find((opt) => opt.value === frequency)?.label || '' + const displayTitle = title.trim() || 'عنوان عادت' + + return ( +
+ پیش‌نمایش +
+
+
+ {emoji || '💧'} +
+ +
+ + {displayTitle} + + + {`۰ از ${target} ${unitText} • ${freqText}`} + +
+
+ +
+ +
+
+
+ ) + } +) + +HabitLivePreview.displayName = 'HabitLivePreview' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/index.tsx b/src/layouts/widgets/habit/components/habit-form-modal/index.tsx new file mode 100644 index 000000000..2eb35b2a9 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/index.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useState } from 'react' +import Analytics from '@/analytics' +import { HABIT_COLOR_PRESETS, HABIT_EMOJI_PRESETS } from '@/common/constant/habit-options' +import { showToast } from '@/common/toast' +import { Modal, TextInput } from '@/components/ui' +import { + type CreateHabitInput, + type Habit, + HabitComparison, + HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' +import { useAddHabit } from '@/services/hooks/habit/add-habit.hook' +import { useUpdateHabit } from '@/services/hooks/habit/update-habit.hook' +import type { HabitIcon } from '@/services/hooks/habit/get-habits.hook' +import { addOpacityToColor } from '@/common/color' +import { safeAwait } from '@/services/api' +import { translateError } from '@/common/utils/translate-error' +import { HabitFormActions } from './habit-form-actions' +import { HABIT_QUICK_PRESETS, type HabitPresetItem } from './habit-form.constants' +import { HabitFormHeader } from './habit-form-header' +import { HabitFormPresets } from './habit-form-presets' +import { HabitGoalFrequencySection } from './habit-goal-frequency-section' +import { HabitIconColorPicker } from './habit-icon-color-picker' +import { HabitLivePreview } from './habit-live-preview' + +export interface HabitFormModalProps { + isOpen: boolean + habit: Habit | null + onClose: () => void + onSaved: () => void + icons: HabitIcon[] + colors: string[] +} + +export const HabitFormModal: React.FC = ({ + isOpen, + onClose, + onSaved, + habit, +}) => { + const isEdit = Boolean(habit) + + const [title, setTitle] = useState('') + const [emoji, setEmoji] = useState(HABIT_EMOJI_PRESETS[0] || '💧') + const [color, setColor] = useState(HABIT_COLOR_PRESETS[0] || '#3b82f6') + const [comparison, setComparison] = useState( + HabitComparison.AT_LEAST + ) + const [unit, setUnit] = useState(HabitUnit.TIMES) + const [customUnit, setCustomUnit] = useState('') + const [target, setTarget] = useState(1) + const [frequency, setFrequency] = useState(HabitFrequency.DAILY) + const [frequencyCount, setFrequencyCount] = useState(1) + const [activePresetId, setActivePresetId] = useState(null) + + const { mutateAsync: addHabit, isPending: isAdding } = useAddHabit() + const { mutateAsync: updateHabit, isPending: isUpdating } = useUpdateHabit() + const isPending = isAdding || isUpdating + + useEffect(() => { + if (!isOpen) return + + if (habit) { + setTitle(habit.title) + setEmoji(habit.emoji || HABIT_EMOJI_PRESETS[0]) + setColor(habit.color || HABIT_COLOR_PRESETS[0]) + setComparison(habit.comparison || HabitComparison.AT_LEAST) + setUnit(habit.unit || HabitUnit.TIMES) + setCustomUnit(habit.customUnit || '') + setTarget(habit.target || 1) + setFrequency(habit.frequency || HabitFrequency.DAILY) + setFrequencyCount(habit.frequencyCount || 1) + setActivePresetId(null) + } else { + setTitle('') + setEmoji(HABIT_EMOJI_PRESETS[0] || '💧') + setColor(HABIT_COLOR_PRESETS[0] || '#3b82f6') + setComparison(HabitComparison.AT_LEAST) + setUnit(HabitUnit.TIMES) + setCustomUnit('') + setTarget(1) + setFrequency(HabitFrequency.DAILY) + setFrequencyCount(1) + setActivePresetId(null) + } + }, [habit, isOpen]) + + const handleSelectPreset = useCallback((preset: HabitPresetItem) => { + setActivePresetId(preset.id) + setTitle(preset.values.title) + setEmoji(preset.values.emoji || '💧') + setColor(preset.values.color || '#3b82f6') + setComparison(preset.values.comparison || HabitComparison.AT_LEAST) + setUnit(preset.values.unit || HabitUnit.GLASSES) + setCustomUnit(preset.values.customUnit || '') + setTarget(preset.values.target || 1) + setFrequency(preset.values.frequency || HabitFrequency.DAILY) + setFrequencyCount(preset.values.frequencyCount || 1) + }, []) + + const handleEmojiChange = useCallback((newEmoji: string) => { + setEmoji(newEmoji) + setActivePresetId(null) + }, []) + + const handleColorChange = useCallback((newColor: string) => { + setColor(newColor) + setActivePresetId(null) + }, []) + + const handleChangeUnit = useCallback((newUnit: HabitUnit) => { + setUnit(newUnit) + }, []) + + const handleChangeCustomUnit = useCallback((newCustomUnit: string) => { + setCustomUnit(newCustomUnit) + }, []) + + const handleChangeFrequency = useCallback((newFreq: HabitFrequency) => { + setFrequency(newFreq) + }, []) + + const handleChangeComparison = useCallback((newComp: HabitComparison) => { + setComparison(newComp) + }, []) + + const handleSubmit = async () => { + if (!title.trim()) { + showToast('عنوان عادت را وارد کنید.', 'error') + return + } + + if (unit === HabitUnit.CUSTOM && !customUnit.trim()) { + showToast('واحد دلخواه را وارد کنید.', 'error') + return + } + + if (target <= 0) { + showToast('هدف باید بزرگتر از صفر باشد', 'error') + return + } + + const payload: CreateHabitInput = { + title: title.trim(), + emoji, + color, + comparison, + unit, + customUnit: unit === HabitUnit.CUSTOM ? customUnit.trim() : undefined, + target, + frequency, + frequencyCount: frequency === HabitFrequency.DAILY ? 1 : frequencyCount || 1, + } + + const [error] = await safeAwait( + isEdit ? updateHabit({ id: habit!.id, input: payload }) : addHabit(payload) + ) + + if (error) { + showToast(translateError(error) as string, 'error') + return + } + + showToast(isEdit ? 'عادت ویرایش شد.' : 'عادت جدید اضافه شد.', 'success') + Analytics.event(isEdit ? 'habit_updated' : 'habit_created') + onSaved() + } + + return ( + +
+ + + {!isEdit && ( + + )} + +
+ +
+
+ {emoji || '💧'} +
+ + { + setTitle(val) + setActivePresetId(null) + }} + placeholder="عنوان عادت (مثلا: نوشیدن آب)" + className="flex-1 text-sm font-medium bg-transparent border-none shadow-none text-content placeholder:text-muted/60 focus:outline-none focus:ring-0" + /> +
+
+ + + + + + + + +
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit-form.modal.tsx b/src/layouts/widgets/habit/components/habit-form.modal.tsx deleted file mode 100644 index 00b1c58b6..000000000 --- a/src/layouts/widgets/habit/components/habit-form.modal.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import { useEffect, useState } from 'react' -import Analytics from '@/analytics' -import { - HABIT_COLOR_PRESETS, - HABIT_COMPARISON_OPTIONS, - HABIT_EMOJI_PRESETS, - HABIT_FREQUENCY_OPTIONS, - HABIT_UNIT_OPTIONS, -} from '@/common/constant/habit-options' -import { showToast } from '@/common/toast' -import { Button, Modal } from '@/components/ui' -import { SelectBox } from '@/components/ui' -import { TextInput } from '@/components/text-input' -import { safeAwait } from '@/services/api' -import { useAddHabit } from '@/services/hooks/habit/add-habit.hook' -import { - type CreateHabitInput, - type Habit, - HabitComparison, - HabitFrequency, - HabitUnit, -} from '@/services/hooks/habit/habit.interface' -import { useUpdateHabit } from '@/services/hooks/habit/update-habit.hook' -import { translateError } from '@/common/utils/translate-error' -import { Tooltip } from '@/components/ui' -import type { HabitIcon } from '@/services/hooks/habit/get-habits.hook' -import { Motion as motion, Presence } from '@/common/motion' - -import { Icon } from '@/src/icons' - -interface HabitFormModalProps { - isOpen: boolean - habit: Habit | null - onClose: () => void - onSaved: () => void - icons: HabitIcon[] - colors: string[] -} - -const defaultForm: CreateHabitInput = { - title: '', - color: HABIT_COLOR_PRESETS[0], - comparison: HabitComparison.AT_LEAST, - unit: HabitUnit.TIMES, - customUnit: '', - target: 1, - frequency: HabitFrequency.DAILY, - frequencyCount: 1, -} - -export function HabitFormModal({ - isOpen, - habit, - onClose, - onSaved, - icons, - colors, -}: HabitFormModalProps) { - const isEdit = !!habit - const [showAdvanced, setShowAdvanced] = useState(false) - const [form, setForm] = useState(defaultForm) - const { mutateAsync: addHabit, isPending: isAdding } = useAddHabit() - const { mutateAsync: updateHabit, isPending: isUpdating } = useUpdateHabit() - const isPending = isAdding || isUpdating - - useEffect(() => { - if (!isOpen) { - if (showAdvanced) setShowAdvanced(false) - - return - } - - if (habit) { - setForm({ - title: habit.title, - emoji: habit.emoji || HABIT_EMOJI_PRESETS[0], - color: habit.color || HABIT_COLOR_PRESETS[0], - comparison: habit.comparison, - unit: habit.unit, - customUnit: habit.customUnit || '', - target: habit.target, - frequency: habit.frequency, - frequencyCount: habit.frequencyCount, - }) - } else { - setForm(defaultForm) - } - }, [habit, isOpen]) - - const updateField = ( - key: K, - value: CreateHabitInput[K] - ) => { - setForm((prev) => ({ ...prev, [key]: value })) - } - - const handleSubmit = async () => { - if (isPending) return - - if (!form.title.trim()) { - showToast('عنوان عادت را وارد کنید.', 'error') - return - } - - if (form.unit === HabitUnit.CUSTOM && !form.customUnit?.trim()) { - showToast('واحد دلخواه را وارد کنید.', 'error') - return - } - - const payload: CreateHabitInput = { - ...form, - customUnit: form.unit === HabitUnit.CUSTOM ? form.customUnit : undefined, - frequencyCount: - form.frequency === HabitFrequency.DAILY ? 1 : form.frequencyCount || 1, - } - - const [error] = await safeAwait( - isEdit ? updateHabit({ id: habit!.id, input: payload }) : addHabit(payload) - ) - - if (error) { - showToast(translateError(error) as string, 'error') - return - } - - showToast(isEdit ? 'عادت ویرایش شد.' : 'عادت جدید اضافه شد.', 'success') - Analytics.event(isEdit ? 'habit_updated' : 'habit_created') - onSaved() - } - - return ( - -
-
- - updateField('title', value)} - placeholder="مثلا نوشیدن آب" - direction="rtl" - /> -
- -
-
- -
- {icons.map((emoji) => ( - - ))} -
-
-
- -
- {colors.map((color) => ( -
-
-
- -
- - - - {showAdvanced && ( - -
-
-
-
- - - - -
- - updateField( - 'unit', - value as HabitUnit - ) - } - className="w-full! h-8!" - /> -
-
-
- - - - -
- - updateField( - 'comparison', - value as HabitComparison - ) - } - className="w-full! h-8!" - /> -
-
- - {form.unit === HabitUnit.CUSTOM && ( -
- - updateField('customUnit', value) - } - placeholder="نام واحد (مثلا: کیلومتر)" - direction="rtl" - className="h-8!" - /> -
- )} - -
-
-
- - - - -
- - updateField( - 'target', - Number(value) || 0 - ) - } - /> -
-
-
- - - - -
- - updateField( - 'frequency', - value as HabitFrequency - ) - } - className="w-full! h-8!" - /> -
-
- - {form.frequency !== HabitFrequency.DAILY && ( -
-
- - - - -
- - updateField( - 'frequencyCount', - Number(value) || 1 - ) - } - className="h-8!" - /> -
- )} -
-
- )} -
-
- - -
-
- ) -} diff --git a/src/layouts/widgets/habit/habits.layout.tsx b/src/layouts/widgets/habit/habits.layout.tsx index 09c7ad16f..c783aa290 100644 --- a/src/layouts/widgets/habit/habits.layout.tsx +++ b/src/layouts/widgets/habit/habits.layout.tsx @@ -11,8 +11,8 @@ import { useArchiveHabit } from '@/services/hooks/habit/archive-habit.hook' import { useGetHabits } from '@/services/hooks/habit/get-habits.hook' import type { Habit } from '@/services/hooks/habit/habit.interface' import { WidgetContainer } from '../widget-container' -import { HabitDetailModal } from './components/habit-detail.modal' -import { HabitFormModal } from './components/habit-form.modal' +import { HabitDetailModal } from './components/habit-detail-modal' +import { HabitFormModal } from './components/habit-form-modal' import { HabitItem } from './components/item/habit.item' import { callEvent } from '@/common/utils/call-event' import { HabitItemSkeleton } from './components/item/habit-item.skeleton' @@ -131,7 +131,7 @@ export function HabitsContent() {
-
+
{isLoading ? (
diff --git a/src/layouts/widgets/presets/preset-layout-modal.tsx b/src/layouts/widgets/presets/preset-layout-modal.tsx index b738c6ff3..107ec5be2 100644 --- a/src/layouts/widgets/presets/preset-layout-modal.tsx +++ b/src/layouts/widgets/presets/preset-layout-modal.tsx @@ -79,7 +79,7 @@ const PresetLayoutModalComponent: React.FC = ({ title="چیدمان‌های آماده" >
-
+