diff --git a/background/cache.ts b/background/cache.ts index 2bc9171c..69ef8f12 100644 --- a/background/cache.ts +++ b/background/cache.ts @@ -51,6 +51,24 @@ export function setupCaching() { const isDev = import.meta.env.DEV if (!isDev) { + registerRoute( + ({ url }) => + url.href === 'https://cdn.widgetify.ir/fonts/remote-fonts.css', + new StaleWhileRevalidate({ + cacheName: 'remote-fonts-css-cache', + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxEntries: 1, + maxAgeSeconds: 2 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ) + registerRoute( ({ request }) => request.destination === 'script' || request.destination === 'style', diff --git a/src/common/constant/config.key.ts b/src/common/constant/config.key.ts index 394eb453..1c044de4 100644 --- a/src/common/constant/config.key.ts +++ b/src/common/constant/config.key.ts @@ -1,4 +1,4 @@ export enum ConfigKey { - VERSION_NAME = 'ساحل', + VERSION_NAME = 'مورچه', WIG_COIN_ICON = 'https://cdn.widgetify.ir/extension/wig-icon.png', } diff --git a/src/common/constant/habit-options.ts b/src/common/constant/habit-options.ts new file mode 100644 index 00000000..eec29617 --- /dev/null +++ b/src/common/constant/habit-options.ts @@ -0,0 +1,53 @@ +export const HABIT_UNIT_OPTIONS = [ + { value: 'TIMES', label: 'دفعه' }, + { value: 'MINUTES', label: 'دقیقه' }, + { value: 'HOURS', label: 'ساعت' }, + { value: 'PAGES', label: 'صفحه' }, + { value: 'GLASSES', label: 'لیوان' }, + { value: 'CUSTOM', label: 'دلخواه' }, +] + +export const HABIT_COMPARISON_OPTIONS = [ + { value: 'AT_LEAST', label: 'حداقل' }, + { value: 'AT_MOST', label: 'حداکثر' }, + { value: 'EXACT', label: 'دقیقا' }, +] + +export const HABIT_FREQUENCY_OPTIONS = [ + { value: 'DAILY', label: 'روزانه' }, + { value: 'WEEKLY', label: 'هفتگی' }, + { value: 'MONTHLY', label: 'ماهانه' }, +] + +export const HABIT_COLOR_PRESETS = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#22c55e', + '#06b6d4', + '#3b82f6', + '#8b5cf6', + '#ec4899', +] + +export const HABIT_EMOJI_PRESETS = [ + '🎯', + '💧', + '📖', + '🏃', + '🧘', + '🥗', + '😴', + '✍️', + '💪', + '🚭', +] + +export const HABIT_UNIT_STEP: Record = { + TIMES: 1, + GLASSES: 1, + PAGES: 1, + MINUTES: 5, + HOURS: 1, + CUSTOM: 1, +} \ No newline at end of file diff --git a/src/common/constant/store.key.ts b/src/common/constant/store.key.ts index da0803b9..6ed0af69 100644 --- a/src/common/constant/store.key.ts +++ b/src/common/constant/store.key.ts @@ -86,4 +86,5 @@ export interface StorageKV { [key: `removed_notification_${string}`]: string selected_engine: string | null widget_tab: string + yadkar_tab: string } diff --git a/src/components/UpdateReleaseNotesModal.tsx b/src/components/UpdateReleaseNotesModal.tsx index 01ff8abd..f1bc59ce 100644 --- a/src/components/UpdateReleaseNotesModal.tsx +++ b/src/components/UpdateReleaseNotesModal.tsx @@ -1,67 +1,7 @@ import { useEffect, useState } from 'react' -import { - RiBug2Line, - RiCheckboxCircleLine, - RiThumbUpLine, - RiSparklingLine, -} from 'react-icons/ri' +import { RiThumbUpLine } from 'react-icons/ri' import { Button } from './button/button' import Modal from './modal' -import { ConfigKey } from '@/common/constant/config.key' - -type MediaContent = { - type: 'image' | 'video' - url: string - caption?: string -} - -type ReleaseNote = { - type: 'feature' | 'bugfix' | 'improvement' | 'info' - title: string - description: string - media?: MediaContent[] -} - -const VERSION_NAME = ConfigKey.VERSION_NAME - -const releaseNotes: ReleaseNote[] = [ - { - type: 'feature', - title: 'امکان پیشنمایش در فروشگاه', - description: - 'الان دیگه قبل اینکه از فروشگاه والپیپر یا هرچیز دیگه ای بخری، میتونی پیشنمایش داشته باشی🐳', - }, - { - type: 'feature', - title: 'مدیریت اعلانات', - description: '', - }, - { - type: 'improvement', - title: 'بهبود ظاهری فروشگاه', - description: '', - }, - { - type: 'improvement', - title: 'بهبود قسمت سرچ های پیشنهادی', - description: '', - }, - { - type: 'improvement', - title: 'بهبود برنامک ها', - description: '', - }, - { - type: 'improvement', - description: '', - title: 'بهبود عملکرد کلی', - }, - { - type: 'bugfix', - description: '', - title: 'رفع مشکلات گزارش شده', - }, -] type UpdateReleaseNotesModalProps = { isOpen: boolean @@ -75,10 +15,10 @@ export const UpdateReleaseNotesModal = ({ counterValue, }: UpdateReleaseNotesModalProps) => { const [counter, setCounter] = useState(0) - + const videoRef = useRef(null) useEffect(() => { if (isOpen && counterValue !== null) { - setCounter(counterValue === null ? 2 : counterValue) + setCounter(counterValue === null ? 10 : counterValue) const interval = setInterval(() => { setCounter((prev) => { if (prev <= 1) { @@ -94,92 +34,80 @@ export const UpdateReleaseNotesModal = ({ } }, [isOpen]) - const getTypeIcon = (note: ReleaseNote) => { - switch (note.type) { - case 'feature': - return - case 'bugfix': - return - case 'improvement': - return - default: - return + useEffect(() => { + if (isOpen && videoRef.current) { + videoRef.current.play().catch(() => {}) } - } + }, [isOpen]) return ( -
-
-
+
+
+
+

+ آپدیت جدید، با ویجت جدید رسید! (نسخه آزمایشی) +

+
-
-
- {releaseNotes.map((note, index) => ( -
-
-
-
-

- {note.title} -

-
- {getTypeIcon(note)} -
-

- {note.description} -

-
- ))} +
+
+ +
+ +
+
+

+ ویجت عادت‌ها (Habit Tracker) اضافه شد! +

+
+ +

+ از امروز می‌تونی{' '} + + عادت‌های روزانه‌ات رو ثبت و دنبال کنی + + . هر روز که به هدفت پایبند باشی، زنجیره عادتت کامل‌تر میشه و + پیشرفتت همیشه جلوی چشمت خواهد بود. +

+ +

+ از قسمت{' '} + مدیریت ویجت‌ها{' '} + می‌تونی این ویجت رو فعال کنی یا از ویجت{' '} + یادکار استفاده + کنی. +

-
- +
+ دمت گرم که همراه مایی
-
-
- - کانال و گروه ما در بله - - + +
+ +
) diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index fa529681..3267b534 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -19,6 +19,7 @@ import { useAuth } from './auth.context' import { CurrencyProvider } from './currency.context' import { showToast } from '@/common/toast' import { YadkarWidget } from '@/layouts/widgets/yadkar/yadkar' +import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' export enum WidgetKeys { comboWidget = 'comboWidget', @@ -33,6 +34,7 @@ export enum WidgetKeys { wigiPad = 'wigiPad', network = 'network', yadKar = 'yadKar', + HabitTracker = 'HabitTracker', } export interface WidgetItem { id: WidgetKeys @@ -45,6 +47,7 @@ export interface WidgetItem { disabled?: boolean soon?: boolean popular?: boolean + isBeta?: boolean } export const widgetItems: WidgetItem[] = [ @@ -60,11 +63,11 @@ export const widgetItems: WidgetItem[] = [ { id: WidgetKeys.yadKar, emoji: '📒', - label: 'یادکار (وظایف و یادداشت)', + label: 'یادکار (وظایف/یادداشت/عادت‌ها)', order: 0, node: , canToggle: true, - isNew: true, + isNew: false, }, { id: WidgetKeys.tools, @@ -126,6 +129,16 @@ export const widgetItems: WidgetItem[] = [ canToggle: true, isNew: false, }, + { + id: WidgetKeys.HabitTracker, + emoji: '🎯', + label: 'عادات', + order: 10, + node: , + canToggle: true, + isNew: true, + isBeta: true, + }, ] interface WidgetVisibilityContextType { diff --git a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx index 68ea808d..2f72312c 100644 --- a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx +++ b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-apply.tsx @@ -60,7 +60,6 @@ function applyWallpaper(wallpaper: StoredWallpaper) { }) video.onerror = () => { - console.error('Video error:', video.error) document.body.style.backgroundColor = '#000' } video.onloadeddata = () => { diff --git a/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx b/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx index 95357d7a..7ea3586e 100644 --- a/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx +++ b/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx @@ -60,6 +60,7 @@ function WidgetItemComponent({ const isDisabled = widget.disabled || false const isSoon = widget.soon || false + const isBeta = widget.isBeta || false const finalCanToggle = canToggle && !isDisabled @@ -68,7 +69,7 @@ function WidgetItemComponent({ extraClasses += ' border-red-500/50 bg-red-500/10' } if (isSoon) { - extraClasses += ' border-warning/50 bg-warning/10' + extraClasses += 'border border-warning/50' } return ( @@ -86,7 +87,7 @@ function WidgetItemComponent({
{widget.isNew && ( - + جدید )} @@ -96,10 +97,13 @@ function WidgetItemComponent({ )} {isDisabled && ( - غیرفعال + غیرفعال )} {isSoon && ( - به زودی + به زودی + )} + {isBeta && ( + آزمایشی )}
diff --git a/src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx b/src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx new file mode 100644 index 00000000..ab139e2b --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react' +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6' +import { TfiBackRight } from 'react-icons/tfi' +import jalaliMoment from 'jalali-moment' +import moment from 'moment' +import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' +import { safeAwait } from '@/services/api' +import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { HABIT_UNIT_STEP } from '@/common/constant/habit-options' +import { useQueryClient } from '@tanstack/react-query' +import { IconLoading } from '@/components/loading/icon-loading' + +interface HabitCalendarProps { + habit: Habit + color: string +} + +const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + +const isToday = (today: jalaliMoment.Moment, date: jalaliMoment.Moment) => { + return ( + date.jDate() === today.jDate() && + date.jMonth() === today.jMonth() && + date.jYear() === today.jYear() + ) +} + +const isPast = (todayGregorian: any, date: jalaliMoment.Moment) => { + const greg = toGregorian(date) + return greg <= todayGregorian +} + +const toGregorian = (date: jalaliMoment.Moment): string => { + const d = date.toDate() + return moment(d).format('YYYY-MM-DD') +} + +export function HabitCalendar({ habit, color }: HabitCalendarProps) { + const today = jalaliMoment() + const queryClient = useQueryClient() + + const { mutateAsync: logProgress, isPending: isUpdating } = useLogHabitProgress() + + const [currentDate, setCurrentDate] = useState( + today.clone().locale('fa') + ) + + const toGregorian = (date: jalaliMoment.Moment): string => { + const d = date.toDate() + return moment(d).format('YYYY-MM-DD') + } + + const todayGregorian = toGregorian(today) + + const firstDayOfMonth = currentDate.clone().startOf('jMonth').day() + const daysInMonth = currentDate.clone().endOf('jMonth').jDate() + const emptyDays = (firstDayOfMonth + 1) % 7 + + const prevMonth = currentDate.clone().subtract(1, 'jMonth') + const daysInPrevMonth = prevMonth.clone().endOf('jMonth').jDate() + const prevMonthStartDay = daysInPrevMonth - emptyDays + 1 + + const totalCells = 42 + + 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 changeMonth = (delta: number) => { + setCurrentDate((prev) => prev.clone().add(delta, 'jMonth')) + } + + const goToToday = () => { + const todayClone = today.clone().locale('fa') + setCurrentDate(todayClone) + } + + const handleDateClick = async ( + day: number, + isCurrentMonth: boolean = true, + isPrevMonth: boolean = false + ) => { + if (isUpdating) return + + let targetDate: jalaliMoment.Moment + if (isCurrentMonth) { + targetDate = currentDate.clone().jDate(day) + } else if (isPrevMonth) { + targetDate = currentDate.clone().subtract(1, 'jMonth').jDate(day) + } else { + targetDate = currentDate.clone().add(1, 'jMonth').jDate(day) + } + const gregorianDate = toGregorian(targetDate) + + let step = HABIT_UNIT_STEP[habit.unit] || 1 + const habitData = getHabitData(gregorianDate) + if ( + habit.comparison === HabitComparison.EXACT && + habitData.value + step > habit.target + ) { + step = 0 + } + + if ( + habit.comparison === HabitComparison.AT_MOST && + habitData.value + step > habit.target + ) { + return showToast( + `مقدار فعلی به حداکثرِ هدفِ شما (یعنی ${habit.target}) رسیده.`, + 'error' + ) + } + + const [error] = await safeAwait( + logProgress({ + id: habit.id, + input: { date: gregorianDate, amount: step }, + }) + ) + if (error) { + autoFormatErrorToast(error) + return + } + + queryClient.invalidateQueries({ queryKey: ['get-habit-detail', habit.id] }) + } + + const renderDay = ( + day: number, + isCurrentMonth: boolean = true, + isPrevMonth: boolean = false + ) => { + let cellDate: jalaliMoment.Moment + if (isCurrentMonth) { + cellDate = currentDate.clone().jDate(day) + } else if (isPrevMonth) { + cellDate = currentDate.clone().subtract(1, 'jMonth').jDate(day) + } else { + cellDate = currentDate.clone().add(1, 'jMonth').jDate(day) + } + + const gregorianDate = toGregorian(cellDate) + const habitData = getHabitData(gregorianDate) + const value = habitData.value + const isDayToday = isToday(today, cellDate) + const isDayPast = isPast(todayGregorian, cellDate) + const clickable = isDayPast && isCurrentMonth + + const bgColor = value > 0 ? `${color}22` : '' + let Opacity = isCurrentMonth ? 'opacity-100' : '' + + if (cellDate.isAfter() && !value) { + Opacity = 'opacity-50' + } + + if (cellDate.isBefore() && !isCurrentMonth && !value) { + Opacity = 'opacity-50' + } + + let indicator = null + if (value > 0 && value < 4) { + indicator = ( +
+ {Array.from({ length: value }).map((i) => { + return ( +
+ ) + })} +
+ ) + } else if (value >= 4) { + indicator = ( +
+

{value}

+
+
+ ) + } + + return ( +
handleDateClick(day, isCurrentMonth, isPrevMonth)} + className={`relative rounded-xl transition-all h-10 w-10 mx-auto flex flex-col items-center justify-center ${Opacity} ${clickable ? 'hover:scale-110 cursor-pointer' : 'cursor-not-allowed'} ${isDayToday ? 'border-b scale-110 font-extrabold' : ''}`} + style={{ + backgroundColor: bgColor, + borderColor: color + '50', + }} + > + {day} + {indicator ? indicator : null} +
+ ) + } + + const renderCalendarGrid = () => { + const cells = [] + for (let i = 0; i < emptyDays; i++) { + cells.push(renderDay(prevMonthStartDay + i, false, true)) + } + for (let day = 1; day <= daysInMonth; day++) { + cells.push(renderDay(day, true)) + } + const remainingCells = totalCells - cells.length + for (let day = 1; day <= remainingCells; day++) { + cells.push(renderDay(day, false, false)) + } + return cells + } + + const showTodayButton = !( + currentDate.jMonth() === today.jMonth() && currentDate.jYear() === today.jYear() + ) + + return ( +
+
+

+ {currentDate.format('dddd، jD jMMMM jYYYY')}{' '} +

+
+ {isUpdating ? : null} + {showTodayButton && ( + + )} + + +
+
+ +
+ {WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} +
+ +
{renderCalendarGrid()}
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit-detail.modal.tsx b/src/layouts/widgets/habit/components/habit-detail.modal.tsx new file mode 100644 index 00000000..e27f63ce --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-detail.modal.tsx @@ -0,0 +1,86 @@ +import Modal from '@/components/modal' +import { IconLoading } from '@/components/loading/icon-loading' +import { useGetHabitDetail } from '@/services/hooks/habit/get-habit-detail.hook' +import { HabitCalendar } from './habit-calendar-heatmap' +import { useAuth } from '@/context/auth.context' +import { formatHabitGoal } from '../utils' + +interface ModalProps { + isOpen: boolean + habitId: string | null + onClose: () => void +} + +export function HabitDetailModal({ isOpen, habitId, onClose }: ModalProps) { + const { isAuthenticated } = useAuth() + const { data: habit, isLoading } = useGetHabitDetail( + habitId || '', + isOpen && isAuthenticated + ) + + if (!habit) return null + + const color = habit.color || '#3b82f6' + const weekDone = habit.history.filter((h) => h.isDone).length + + return ( + +
+ {habit.emoji || '🎯'} +
+
+

+ {habit.title} +

+

+ {formatHabitGoal(habit)} +

+
+
+ } + > + {isLoading ? ( +
+ +
+ ) : ( +
+
+
+

امروز

+

+ {habit.today.value}/{habit.target} +

+
+
+

7 روز

+

{weekDone}/7

+
+
+

این دوره

+

+ {habit.progressThisPeriod.done}/ + {habit.progressThisPeriod.required} +

+
+
+ +
+ +
+
+ )} + + ) +} diff --git a/src/layouts/widgets/habit/components/habit-form.modal.tsx b/src/layouts/widgets/habit/components/habit-form.modal.tsx new file mode 100644 index 00000000..6ad0dc65 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form.modal.tsx @@ -0,0 +1,376 @@ +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 } from '@/components/button/button' +import Modal from '@/components/modal' +import { SelectBox } from '@/components/selectbox/selectbox' +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 '@/utils/translate-error' +import Tooltip from '@/components/toolTip' +import { BiChevronDown, BiInfoCircle } from 'react-icons/bi' +import type { HabitIcon } from '@/services/hooks/habit/get-habits.hook' +import { AnimatePresence, motion } from 'framer-motion' + +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) 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/components/habit-item.skeleton.tsx b/src/layouts/widgets/habit/components/habit-item.skeleton.tsx new file mode 100644 index 00000000..780947e7 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-item.skeleton.tsx @@ -0,0 +1,22 @@ +export function HabitItemSkeleton() { + return ( +
+
+
+ +
+
+
+
+ +
+
+ +
+ {Array.from({ length: 7 }).map((_, index) => ( +
+ ))} +
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit.item.tsx b/src/layouts/widgets/habit/components/habit.item.tsx new file mode 100644 index 00000000..395a85a0 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit.item.tsx @@ -0,0 +1,158 @@ +import { FiArchive, FiCheck, FiEdit3 } from 'react-icons/fi' +import Analytics from '@/analytics' +import { getContrastingTextColor } from '@/common/color' +import { playAlarm } from '@/common/playAlarm' +import { showToast } from '@/common/toast' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils' +import { safeAwait } from '@/services/api' +import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' +import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' +import { translateError } from '@/utils/translate-error' +import { HABIT_UNIT_STEP } from '@/common/constant/habit-options' +import { formatHabitGoal } from '../utils' + +interface HabitItemProps { + habit: Habit + today: WidgetifyDate + onChanged: () => void + onEdit: () => void + onArchive: () => void + onViewDetails: () => void +} + +export function HabitItem({ + habit, + today, + onChanged, + onEdit, + onArchive, + onViewDetails, +}: HabitItemProps) { + const { mutateAsync: logProgress, isPending } = useLogHabitProgress() + const isDone = habit.today.isDone + const color = habit.color || '#3b82f6' + const target = habit.target || 1 + + const handleQuickLog = async (e?: React.MouseEvent) => { + if (e) e.stopPropagation() + if (isPending) return + const date = today.clone().doAsGregorian().format('YYYY-MM-DD') + let step = HABIT_UNIT_STEP[habit.unit] || 1 + + if ( + habit.comparison === HabitComparison.EXACT && + habit.today.value + 1 > habit.target + ) { + step = 0 + } + + if ( + habit.comparison === HabitComparison.AT_MOST && + habit.today.value + 1 > habit.target + ) { + return showToast( + `مقدار فعلی به حداکثرِ هدفِ شما (یعنی ${habit.target}) رسیده.`, + 'error' + ) + } + + const [error] = await safeAwait( + logProgress({ id: habit.id, input: { date, amount: step } }) + ) + + if (error) { + showToast(translateError(error) as string, 'error') + return + } + + if (!isDone) playAlarm('success') + Analytics.event('habit_quick_log') + onChanged() + } + + return ( + + + +
+ + {habit.today.value}/{habit.target} + +
+
+ {habit.history.map((day) => { + const dayProgress = Math.min(day.value / target, 1) + return ( + + ) + })} +
+ + ) +} diff --git a/src/layouts/widgets/habit/habits.layout.tsx b/src/layouts/widgets/habit/habits.layout.tsx new file mode 100644 index 00000000..5afd9b7d --- /dev/null +++ b/src/layouts/widgets/habit/habits.layout.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react' +import { MdRefresh } from 'react-icons/md' +import Analytics from '@/analytics' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { Button } from '@/components/button/button' +import { ConfirmationModal } from '@/components/modal/confirmation-modal' +import Tooltip from '@/components/toolTip' +import { useAuth } from '@/context/auth.context' +import { useDate } from '@/context/date.context' +import { useGeneralSetting } from '@/context/general-setting.context' +import { safeAwait } from '@/services/api' +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 { HabitItem } from './components/habit.item' +import { BiPlus } from 'react-icons/bi' +import { callEvent } from '@/common/utils/call-event' +import { HabitItemSkeleton } from './components/habit-item.skeleton' + +export function HabitsContent() { + const { isAuthenticated } = useAuth() + const { today } = useDate() + const { blurMode } = useGeneralSetting() + + const [editingHabit, setEditingHabit] = useState(null) + const [detailHabitId, setDetailHabitId] = useState(null) + const [showForm, setShowForm] = useState(false) + const [archiveConfirm, setArchiveConfirm] = useState(null) + + const { data, isLoading, refetch, isRefetching } = useGetHabits(isAuthenticated) + const { mutateAsync: archiveHabit, isPending: isArchiving } = useArchiveHabit() + + const handleCloseForm = () => { + setShowForm(false) + setEditingHabit(null) + } + + const handleAddHabit = () => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + setEditingHabit(null) + setShowForm(true) + Analytics.event('habit_form_opened') + } + + const handleEditHabit = (habit: Habit) => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + + setEditingHabit(habit) + setShowForm(true) + Analytics.event('habit_edit_opened') + } + + const handleConfirmArchive = async () => { + if (!archiveConfirm || isArchiving) return + + const [error] = await safeAwait(archiveHabit(archiveConfirm)) + + if (error) { + autoFormatErrorToast(error) + return + } + + setArchiveConfirm(null) + showToast('عادت حذف شد.', 'success') + Analytics.event('habit_archived') + refetch() + } + + const onRefresh = () => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + + refetch() + Analytics.event('habit_refetch') + } + + const onCloseDetailModal = () => { + setDetailHabitId(null) + refetch() + Analytics.event('habit_close_detail_model') + } + + const isWaiting = isLoading || isRefetching + + return ( + <> +
+
+
+

عادت‌ها

+
+ +
+ + + +
+
+
+ +
+
+ {isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ) : data?.items?.length === 0 ? ( +
+
+ بدون عادت +
+

+ هیچ عادتی برای نمایش وجود ندارد +

+

+ یک عادت جدید اضافه کن تا شروع کنی +

+
+ ) : ( +
+ {data?.items?.map((habit) => ( + handleEditHabit(habit)} + onArchive={() => setArchiveConfirm(habit.id)} + onViewDetails={() => setDetailHabitId(habit.id)} + /> + ))} +
+ )} +
+
+ + + + { + handleCloseForm() + refetch() + }} + icons={data?.icons || []} + colors={data?.colors || []} + /> + + onCloseDetailModal()} + /> + + setArchiveConfirm(null)} + onConfirm={handleConfirmArchive} + variant="danger" + title="آرشیو عادت" + message="آیا از آرشیو این عادت اطمینان داری؟" + confirmText="آرشیو" + cancelText="انصراف" + isLoading={isArchiving} + /> + + ) +} + +export function HabitsLayout() { + return ( + + + + ) +} diff --git a/src/layouts/widgets/habit/utils.ts b/src/layouts/widgets/habit/utils.ts new file mode 100644 index 00000000..4d89d5ae --- /dev/null +++ b/src/layouts/widgets/habit/utils.ts @@ -0,0 +1,45 @@ +import { + HabitFrequency, + HabitUnit, + type Habit, +} from '@/services/hooks/habit/habit.interface' + +const unitLabels: Record = { + [HabitUnit.TIMES]: 'دفعه', + [HabitUnit.MINUTES]: 'دقیقه', + [HabitUnit.HOURS]: 'ساعت', + [HabitUnit.PAGES]: 'صفحه', + [HabitUnit.GLASSES]: 'لیوان', + [HabitUnit.CUSTOM]: '', +} + +const comparisonLabels: Record = { + AT_LEAST: 'حداقل', + AT_MOST: 'حداکثر', + EXACT: 'دقیقا', +} + +const frequencyLabels: Record = { + [HabitFrequency.DAILY]: 'روزانه', + [HabitFrequency.WEEKLY]: 'هفته', + [HabitFrequency.MONTHLY]: 'ماه', +} + +export function getHabitUnitLabel(habit: Habit): string { + if (habit.unit === HabitUnit.CUSTOM) { + return habit.customUnit || '' + } + return unitLabels[habit.unit] || '' +} + +export function formatHabitGoal(habit: Habit): string { + const unitLabel = getHabitUnitLabel(habit) + const base = + `${comparisonLabels[habit.comparison]} ${habit.target} ${unitLabel}`.trim() + + if (habit.frequency === HabitFrequency.DAILY) { + return `${base} در روز` + } + + return `${base} · ${habit.progressThisPeriod.done} از ${habit.progressThisPeriod.required} بار در ${frequencyLabels[habit.frequency]}` +} diff --git a/src/layouts/widgets/habit/utils/habit.utils.ts b/src/layouts/widgets/habit/utils/habit.utils.ts new file mode 100644 index 00000000..4d89d5ae --- /dev/null +++ b/src/layouts/widgets/habit/utils/habit.utils.ts @@ -0,0 +1,45 @@ +import { + HabitFrequency, + HabitUnit, + type Habit, +} from '@/services/hooks/habit/habit.interface' + +const unitLabels: Record = { + [HabitUnit.TIMES]: 'دفعه', + [HabitUnit.MINUTES]: 'دقیقه', + [HabitUnit.HOURS]: 'ساعت', + [HabitUnit.PAGES]: 'صفحه', + [HabitUnit.GLASSES]: 'لیوان', + [HabitUnit.CUSTOM]: '', +} + +const comparisonLabels: Record = { + AT_LEAST: 'حداقل', + AT_MOST: 'حداکثر', + EXACT: 'دقیقا', +} + +const frequencyLabels: Record = { + [HabitFrequency.DAILY]: 'روزانه', + [HabitFrequency.WEEKLY]: 'هفته', + [HabitFrequency.MONTHLY]: 'ماه', +} + +export function getHabitUnitLabel(habit: Habit): string { + if (habit.unit === HabitUnit.CUSTOM) { + return habit.customUnit || '' + } + return unitLabels[habit.unit] || '' +} + +export function formatHabitGoal(habit: Habit): string { + const unitLabel = getHabitUnitLabel(habit) + const base = + `${comparisonLabels[habit.comparison]} ${habit.target} ${unitLabel}`.trim() + + if (habit.frequency === HabitFrequency.DAILY) { + return `${base} در روز` + } + + return `${base} · ${habit.progressThisPeriod.done} از ${habit.progressThisPeriod.required} بار در ${frequencyLabels[habit.frequency]}` +} diff --git a/src/layouts/widgets/notes/notes.layout.tsx b/src/layouts/widgets/notes/notes.layout.tsx index bd742254..b2d9180d 100644 --- a/src/layouts/widgets/notes/notes.layout.tsx +++ b/src/layouts/widgets/notes/notes.layout.tsx @@ -1,4 +1,3 @@ -import { PiNotepad } from 'react-icons/pi' import Analytics from '@/analytics' import { useGeneralSetting } from '@/context/general-setting.context' import { NotesProvider, useNotes } from '@/context/notes.context' @@ -17,7 +16,13 @@ function NotesContent() {
- +
+ بدون عادت +

یادداشتی پیدا نشد

منتظر چی هستی؟ شروع کن به نوشتن! diff --git a/src/layouts/widgets/todos/todos.tsx b/src/layouts/widgets/todos/todos.tsx index d3485cf7..092da2e9 100644 --- a/src/layouts/widgets/todos/todos.tsx +++ b/src/layouts/widgets/todos/todos.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { FiList } from 'react-icons/fi' import { ExpandableTodoInput } from './expandable-todo-input' import { useAuth } from '@/context/auth.context' import Analytics from '@/analytics' @@ -311,12 +310,12 @@ function TodosEmpty() { 'flex-1 flex flex-col items-center justify-center gap-y-1.5 px-5 py-8' } > -
- +
+ بدون عادت

هیچ تسکی برای نمایش وجود ندارد diff --git a/src/layouts/widgets/yadkar/yadkar.tsx b/src/layouts/widgets/yadkar/yadkar.tsx index 79727b93..b397828a 100644 --- a/src/layouts/widgets/yadkar/yadkar.tsx +++ b/src/layouts/widgets/yadkar/yadkar.tsx @@ -3,17 +3,37 @@ import Analytics from '@/analytics' import { NotesLayout } from '../notes/notes.layout' import { TodosLayout } from '../todos/todos' import { TabNavigation } from '@/components/tab-navigation' -import { HiOutlineCheckCircle, HiOutlineDocumentText } from 'react-icons/hi2' +import { + HiOutlineCheckCircle, + HiOutlineDocumentText, + HiOutlineFire, +} from 'react-icons/hi2' import { WidgetContainer } from '../widget-container' +import { HabitsContent } from '../habit/habits.layout' +import { getFromStorage, setToStorage } from '@/common/storage' +import { useEffect } from 'react' +type Tab = 'todos' | 'notes' | 'rabbit' export function YadkarWidget() { - const [tab, setTab] = useState<'todos' | 'notes'>('todos') + const [tab, setTab] = useState('todos') - const onChangeTab = (newTab: 'todos' | 'notes') => { + const onChangeTab = (newTab: Tab) => { setTab(newTab) Analytics.event('yadkar_change_tab') + setToStorage('yadkar_tab', newTab) } + useEffect(() => { + const load = async () => { + const currentTab = await getFromStorage('yadkar_tab') + if (currentTab) { + setTab(currentTab as Tab) + } + } + + load() + }, []) + return (

@@ -34,6 +54,11 @@ export function YadkarWidget() { label: 'یادداشت', icon: , }, + { + id: 'rabbit', + label: 'عادت‌ها (بتا)', + icon: , + }, ]} size="small" className="w-full border-none" @@ -41,7 +66,13 @@ export function YadkarWidget() {
- {tab === 'todos' ? : } + {tab === 'todos' ? ( + + ) : tab === 'notes' ? ( + + ) : ( + + )}
) diff --git a/src/pages/home/home.page.tsx b/src/pages/home/home.page.tsx index 6c5b9987..bf636c12 100644 --- a/src/pages/home/home.page.tsx +++ b/src/pages/home/home.page.tsx @@ -81,7 +81,6 @@ export function HomePage() { } function onDoneTour(data: any) { - console.log(data) if ( data.status === 'finished' || data.status === 'skipped' || diff --git a/src/services/hooks/habit/add-habit.hook.ts b/src/services/hooks/habit/add-habit.hook.ts new file mode 100644 index 00000000..d2ea631f --- /dev/null +++ b/src/services/hooks/habit/add-habit.hook.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { CreateHabitInput } from './habit.interface' + +export const useAddHabit = () => { + return useMutation({ + mutationKey: ['addHabit'], + mutationFn: async (input: CreateHabitInput) => { + const client = await getMainClient() + const response = await client.post('/widgets/habits', input) + return response.data + }, + }) +} diff --git a/src/services/hooks/habit/archive-habit.hook.ts b/src/services/hooks/habit/archive-habit.hook.ts new file mode 100644 index 00000000..b3c11d67 --- /dev/null +++ b/src/services/hooks/habit/archive-habit.hook.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' + +export const useArchiveHabit = () => { + return useMutation({ + mutationKey: ['archiveHabit'], + mutationFn: async (id: string) => { + const client = await getMainClient() + const response = await client.delete(`/widgets/habits/${id}`) + return response.data + }, + }) +} diff --git a/src/services/hooks/habit/get-habit-detail.hook.ts b/src/services/hooks/habit/get-habit-detail.hook.ts new file mode 100644 index 00000000..816b490f --- /dev/null +++ b/src/services/hooks/habit/get-habit-detail.hook.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { Habit } from './habit.interface' + +export const useGetHabitDetail = (habitId: string, enabled: boolean) => { + return useQuery({ + queryKey: ['get-habit-detail', habitId], + queryFn: () => getHabitDetail(habitId), + retry: 0, + enabled: enabled && !!habitId, + refetchOnWindowFocus: false, + }) +} + +async function getHabitDetail(habitId: string): Promise { + const client = await getMainClient() + const { data } = await client.get<{ data: Habit }>(`/widgets/habits/${habitId}`) + return data.data +} diff --git a/src/services/hooks/habit/get-habits.hook.ts b/src/services/hooks/habit/get-habits.hook.ts new file mode 100644 index 00000000..b663f21a --- /dev/null +++ b/src/services/hooks/habit/get-habits.hook.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { Habit } from './habit.interface' + +export interface HabitIcon { + type: 'EMOJI' + content: string +} +interface GetHabitsResponse { + items: Habit[] + page: number + limit: number + total: number + icons: HabitIcon[] + colors: string[] +} + +export const useGetHabits = (enabled: boolean, archived = false) => { + return useQuery({ + queryKey: ['get-habits', archived], + queryFn: () => getHabits(archived), + retry: 0, + enabled, + refetchOnWindowFocus: false, + }) +} + +async function getHabits(archived: boolean): Promise { + const client = await getMainClient() + const { data } = await client.get<{ data: GetHabitsResponse }>('/widgets/habits', { + params: { archived, limit: 50 }, + }) + return data.data +} diff --git a/src/services/hooks/habit/habit.interface.ts b/src/services/hooks/habit/habit.interface.ts new file mode 100644 index 00000000..dfdba88d --- /dev/null +++ b/src/services/hooks/habit/habit.interface.ts @@ -0,0 +1,69 @@ +export enum HabitComparison { + AT_LEAST = 'AT_LEAST', + AT_MOST = 'AT_MOST', + EXACT = 'EXACT', +} + +export enum HabitUnit { + TIMES = 'TIMES', + MINUTES = 'MINUTES', + HOURS = 'HOURS', + PAGES = 'PAGES', + GLASSES = 'GLASSES', + CUSTOM = 'CUSTOM', +} + +export enum HabitFrequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} + +export interface HabitDayProgress { + date: string + value: number + isDone: boolean +} + +export interface Habit { + id: string + title: string + emoji: string | null + color: string | null + target: number + comparison: HabitComparison + unit: HabitUnit + customUnit: string | null + frequency: HabitFrequency + frequencyCount: number + sort: number + archivedAt: string | null + today: HabitDayProgress + history: HabitDayProgress[] + calendarData: Record> + progressThisPeriod: { + done: number + required: number + } +} + +export interface CreateHabitInput { + title: string + emoji?: string + color?: string + comparison: HabitComparison + unit: HabitUnit + customUnit?: string + target: number + frequency: HabitFrequency + frequencyCount?: number +} + +export interface UpdateHabitInput extends Partial { + sort?: number +} + +export interface LogHabitProgressInput { + date: string + amount: number +} \ No newline at end of file diff --git a/src/services/hooks/habit/log-habit-progress.hook.ts b/src/services/hooks/habit/log-habit-progress.hook.ts new file mode 100644 index 00000000..21a0ff3e --- /dev/null +++ b/src/services/hooks/habit/log-habit-progress.hook.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { LogHabitProgressInput } from './habit.interface' + +export const useLogHabitProgress = () => { + return useMutation({ + mutationKey: ['logHabitProgress'], + mutationFn: async ({ + id, + input, + }: { + id: string + input: LogHabitProgressInput + }) => { + const client = await getMainClient() + const response = await client.put(`/widgets/habits/${id}/progress`, input) + return response.data + }, + }) +} diff --git a/src/services/hooks/habit/update-habit.hook.ts b/src/services/hooks/habit/update-habit.hook.ts new file mode 100644 index 00000000..7980439a --- /dev/null +++ b/src/services/hooks/habit/update-habit.hook.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { UpdateHabitInput } from './habit.interface' + +export const useUpdateHabit = () => { + return useMutation({ + mutationKey: ['updateHabit'], + mutationFn: async ({ id, input }: { id: string; input: UpdateHabitInput }) => { + const client = await getMainClient() + const response = await client.patch(`/widgets/habits/${id}`, input) + return response.data + }, + }) +} diff --git a/src/styles/theme/light.css b/src/styles/theme/light.css index 2138db4e..658581d0 100644 --- a/src/styles/theme/light.css +++ b/src/styles/theme/light.css @@ -1,7 +1,7 @@ @plugin "daisyui/theme" { name: "light"; - default: true; --color-primary: var(--brand-primary); + --color-primary-focus: #4f46e5; --color-secondary: var(--brand-secondary); --color-primary-content: rgba(var(--brand-primary-rgb), 0.9); } \ No newline at end of file diff --git a/src/utils/translate-error.ts b/src/utils/translate-error.ts index 5f65c13e..d74d3a63 100644 --- a/src/utils/translate-error.ts +++ b/src/utils/translate-error.ts @@ -108,6 +108,8 @@ const errorTranslations: Record = { PAYMENT_NOT_FOUND: 'پرداخت مورد نظر یافت نشد', TRY_NEXT_TIME: 'چیزی اشتباه پیش رفت، لطفا بعدا دوباره تلاش کنید', + + TOO_MANY_ATTEMPTS_HABIT: 'بیش از حد نمیتونید بسازید.', } const validationTranslations: Record = { diff --git a/wxt.config.ts b/wxt.config.ts index 1dc85c7a..c877205e 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ '@wxt-dev/module-react', ], manifest: { - version: '1.0.98', + version: '1.1.0', name: 'Widgetify', description: 'Transform your new tab into a smart dashboard with Widgetify! Get currency rates, crypto prices, weather & more.',