Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/common/constant/store.key.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { CurrencyColorMode } from '@/context/currency.context'
import type { SelectedCity } from '@/context/general-setting.context'
import type { Theme } from '@/context/theme.context'
import type { TodoOptions } from '@/context/todo.context'
import type { WidgetItem } from '@/context/widget-visibility.context'
Expand Down Expand Up @@ -31,7 +30,6 @@ export interface StorageKV {
currencies: string[]
currencyColorMode: CurrencyColorMode
hasShownPwaModal: boolean
selectedCity: SelectedCity | null
currentWeather: FetchedWeather
todos: Todo[]
wallpaper: StoredWallpaper
Expand Down
2 changes: 1 addition & 1 deletion src/common/utils/call-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { FontFamily } from '@/context/appearance.context'

export interface EventName {
startSync: SyncTarget
openSettings: 'account' | 'wallpapers' | null
openSettings: 'account' | 'wallpapers' | 'general' | null
todosChanged: Todo[]
wallpaperChanged: StoredWallpaper
openWidgetsSettings: { tab: WidgetTabKeys | null }
Expand Down
7 changes: 6 additions & 1 deletion src/components/auth/require-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ export const RequireAuth = ({ children, fallback, mode = 'block' }: RequireAuthP
<p className={'text-xs mb-4 text-content text-center'}>
برای دسترسی به این بخش، لطفاً وارد حساب کاربری خود شوید.
</p>
<Button onClick={handleAuthClick} size="sm">
<Button
onClick={handleAuthClick}
size="sm"
isPrimary={true}
className="btn mt-2 !w-fit px-6 border-none shadow-none text-white rounded-3xl transition-colors duration-300 ease-in-out"
>
ورود به حساب
</Button>
</motion.div>
Expand Down
35 changes: 4 additions & 31 deletions src/context/general-setting.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,12 @@ export interface GeneralData {
selected_timezone: FetchedTimezone
browserBookmarksEnabled: boolean
browserTabsEnabled: boolean
selectedCity: SelectedCity
}

export interface SelectedCity {
name: string
lat: number
lon: number
state?: string | null
}

interface GeneralSettingContextType extends GeneralData {
updateSetting: <K extends keyof GeneralData>(key: K, value: GeneralData[K]) => void
setAnalyticsEnabled: (value: boolean) => void
setTimezone: (value: FetchedTimezone) => void
setSelectedCity: (value: any) => void
setBrowserBookmarksEnabled: (value: boolean) => void
setBrowserTabsEnabled: (value: boolean, event?: React.MouseEvent) => void
}
Expand All @@ -44,7 +35,6 @@ const DEFAULT_SETTINGS: GeneralData = {
},
browserBookmarksEnabled: false,
browserTabsEnabled: false,
selectedCity: { name: 'Tehran', lat: 35.6892523, lon: 51.3896004, state: null },
}

export const GeneralSettingContext = createContext<GeneralSettingContextType | null>(null)
Expand All @@ -59,12 +49,10 @@ export function GeneralSettingProvider({ children }: { children: React.ReactNode
async function loadGeneralSettings() {
try {
const storedSettings = await getFromStorage('generalSettings')
const [browserBookmarksEnabled, browserTabsEnabled, selectedCity] =
await Promise.all([
browserHasPermission(['bookmarks']),
browserHasPermission(['tabs', 'tabGroups']),
getFromStorage('selectedCity'),
])
const [browserBookmarksEnabled, browserTabsEnabled] = await Promise.all([
browserHasPermission(['bookmarks']),
browserHasPermission(['tabs', 'tabGroups']),
])

if (storedSettings) {
setSettings({
Expand All @@ -76,15 +64,6 @@ export function GeneralSettingProvider({ children }: { children: React.ReactNode
: storedSettings.selected_timezone,
browserBookmarksEnabled: browserBookmarksEnabled,
browserTabsEnabled: browserTabsEnabled,
selectedCity: {
lat: selectedCity?.lat || DEFAULT_SETTINGS.selectedCity.lat,
lon: selectedCity?.lon || DEFAULT_SETTINGS.selectedCity.lon,
name:
selectedCity?.name || DEFAULT_SETTINGS.selectedCity.name,
state:
selectedCity?.state ||
DEFAULT_SETTINGS.selectedCity.state,
},
})
}
} finally {
Expand Down Expand Up @@ -229,10 +208,6 @@ export function GeneralSettingProvider({ children }: { children: React.ReactNode
'browser_tabs_disabled'
)

const setSelectedCity = (val: SelectedCity) => {
updateSetting('selectedCity', val)
}

if (!isInitialized) {
return null
}
Expand All @@ -248,8 +223,6 @@ export function GeneralSettingProvider({ children }: { children: React.ReactNode
setBrowserBookmarksEnabled,
browserTabsEnabled: settings.browserTabsEnabled,
setBrowserTabsEnabled,
selectedCity: settings.selectedCity,
setSelectedCity,
}

return (
Expand Down
11 changes: 7 additions & 4 deletions src/layouts/navbar/navbar.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ export function NavbarLayout(): JSX.Element {
const [logoData, setLogoData] = useState<LogoData>(DEFAULT_LOGO_DATA)
const { isAuthenticated } = useAuth()

const handleOpenSettings = useCallback((tabName: 'account' | 'wallpapers' | null) => {
if (tabName) setTab(tabName)
setShowSettings(true)
}, [])
const handleOpenSettings = useCallback(
(tabName: 'account' | 'wallpapers' | 'general' | null) => {
if (tabName) setTab(tabName)
setShowSettings(true)
},
[]
)

const settingsModalCloseHandler = () => setShowSettings(false)

Expand Down
187 changes: 127 additions & 60 deletions src/layouts/setting/tabs/general/components/select-city.tsx
Original file line number Diff line number Diff line change
@@ -1,126 +1,193 @@
import { useRef, useState } from 'react'
import { useState, useMemo } from 'react'
import { CiLocationOn } from 'react-icons/ci'
import Analytics from '@/analytics'
import { setToStorage } from '@/common/storage'
import { IconLoading } from '@/components/loading/icon-loading'
import Modal from '@/components/modal'
import { SectionPanel } from '@/components/section-panel'
import { type SelectedCity, useGeneralSetting } from '@/context/general-setting.context'
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
import { useGetRelatedCities } from '@/services/hooks/weather/getRelatedCities'
import { CitySearchInput } from '../../weather/CitySearchInput'
import { SelectedCityDisplay } from '../../weather/SelectedCityDisplay'
import { useGetCitiesList } from '@/services/hooks/cities/getCitiesList'
import { useAuth } from '@/context/auth.context'
import { AuthRequiredModal } from '@/components/auth/AuthRequiredModal'
import { useSetCity } from '@/services/hooks/user/userService.hook'
import { TextInput } from '@/components/text-input'
import { showToast } from '@/common/toast'
import { translateError } from '@/utils/translate-error'

interface SelectedCity {
city: string
cityId: string
}

export function SelectCity() {
const [inputValue, setInputValue] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [showAuthModal, setShowAuthModal] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const { isAuthenticated, user, isLoadingUser } = useAuth()
const { data: cities, isLoading, error } = useGetCitiesList(isAuthenticated)
const { mutateAsync: setCityToServer, isPending: isSettingCity } = useSetCity()

const debouncedValue = useDebouncedValue(
inputValue.length >= 2 ? inputValue : '',
500
)
const { setSelectedCity, selectedCity } = useGeneralSetting()
const filteredCities = useMemo(() => {
if (!cities || !searchTerm) return cities || []

const { data: relatedCities, isLoading, error } = useGetRelatedCities(debouncedValue)
const lowerSearchTerm = searchTerm.toLowerCase()

const handleSelectCity = (city: SelectedCity) => {
setSelectedCity(city)
setIsModalOpen(false)
setInputValue('')
const prefixMatches = cities.filter((city) =>
city.city.toLowerCase().startsWith(lowerSearchTerm)
)

Analytics.event('city_selected')
const remainingCities = cities.filter(
(city) =>
!city.city.toLowerCase().startsWith(lowerSearchTerm) &&
city.city.toLowerCase().includes(lowerSearchTerm)
)

setToStorage('selectedCity', {
lat: city.lat,
lon: city.lon,
name: city.name,
state: city.state,
})
}
return [...prefixMatches, ...remainingCities]
}, [cities, searchTerm])

const handleInputChange = (value: string) => {
setInputValue(value)
const handleSelectCity = async (city: SelectedCity) => {
if (!city.cityId) return
try {
setIsModalOpen(false)
setSearchTerm('')

Analytics.event('city_selected')

await setCityToServer(city.cityId)
} catch (error) {
showToast(translateError(error) as any, 'error')
}
}

const onModalOpen = () => {
if (!isAuthenticated) {
Analytics.event('open_city_selection_modal_unauthenticated')
setShowAuthModal(true)
return
}

setIsModalOpen(true)
Analytics.event('open_city_selection_modal')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
searchInputRef.current?.focus()
}, 300)
}

const selected = user?.city
? filteredCities.find((c) => c.cityId === user.city?.id)
: null

return (
<SectionPanel title="انتخاب شهر" size="sm">
<div className="space-y-2">
<button
onClick={() => onModalOpen()}
className="flex items-center justify-between w-full p-3 text-right transition-colors border cursor-pointer rounded-2xl bg-base-100 border-base-300 hover:bg-base-200"
onClick={onModalOpen}
disabled={isSettingCity}
className="flex items-center justify-between w-full p-3 text-right transition-colors border cursor-pointer rounded-2xl bg-base-100 border-base-300 hover:bg-base-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>
{selectedCity
? `${selectedCity.name} ${selectedCity.state ? `, ${selectedCity.state}` : ''}`
: 'انتخاب شهر'}
</span>
<CiLocationOn className="w-5 h-5 text-primary" />
{isLoadingUser ? (
<IconLoading className="mx-auto text-center" />
) : selected ? (
selected.city
) : (
'انتخاب شهر...'
)}
{isSettingCity ? (
<IconLoading />
) : (
<CiLocationOn className="w-5 h-5 text-primary" />
)}
</button>
<SelectedCityDisplay city={selectedCity} />

{error && (
<div className="p-3 text-sm text-right duration-300 border rounded-lg border-red-500/20 bg-red-500/10 backdrop-blur-sm animate-in fade-in-0">
<div className="font-medium text-red-400">
<div className="p-3 text-sm text-right duration-300 border rounded-lg border-error/20 bg-error/10 backdrop-blur-sm animate-in fade-in-0">
<div className="font-medium text-error">
خطا در دریافت اطلاعات
</div>
<div className="mt-1 text-red-300 opacity-80">
<div className="mt-1 text-error/80">
لطفا اتصال اینترنت خود را بررسی کرده و مجدداً تلاش کنید.
</div>
</div>
)}
</div>
<AuthRequiredModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(!showAuthModal)}
message="برای انتخاب شهر، لطفاً وارد حساب کاربری خود شوید."
/>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={() => {
setIsModalOpen(false)
setSearchTerm('')
}}
title="انتخاب شهر"
size="lg"
direction="rtl"
>
<div className="space-y-4">
<CitySearchInput
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onFocus={() => {}}
isLoading={isLoading}
/>
<div className="overflow-y-auto max-h-96 custom-scrollbar">
<div className="space-y-2 overflow-hidden">
<div className="relative">
<TextInput
type="text"
placeholder="جستجوی شهر..."
value={searchTerm}
ref={searchInputRef}
onChange={(value) => setSearchTerm(value)}
/>
<CiLocationOn className="absolute w-5 h-5 transform -translate-y-1/2 left-3 top-1/2 text-base-content/40" />
</div>

<div className="overflow-y-auto min-h-52 max-h-52 custom-scrollbar">
{isLoading ? (
<div className="flex items-center justify-center p-4 text-center text-primary">
<IconLoading />
در حال بارگذاری...
</div>
) : relatedCities && relatedCities.length > 0 ? (
relatedCities.map((city) => (
) : filteredCities?.length > 0 ? (
filteredCities.map((city) => (
<div
key={`${city.lat}-${city.lon}`}
key={city.cityId}
onClick={() => handleSelectCity(city)}
className="flex items-center w-full p-3 text-right transition-all duration-200 border-b cursor-pointer hover:bg-gradient-to-l hover:from-primary/10 hover:to-transparent border-base-200/30 last:border-b-0 group rounded-2xl"
className="flex items-center w-full p-3 text-right transition-all duration-200 border-b cursor-pointer border-base-200/30 last:border-b-0 group rounded-2xl hover:bg-primary/20 hover:text-primary"
>
<CiLocationOn className="flex-shrink-0 w-5 h-5 ml-3 transition-transform text-primary group-hover:scale-110" />
<span className="flex-1 font-medium">
{city.name}
{city.state ? `, ${city.state}` : ''}
{city.city}
</span>
</div>
))
) : inputValue.length >= 2 ? (
) : searchTerm ? (
<div className="p-4 text-center text-base-content/60">
نتیجه‌ای یافت نشد
</div>
) : cities && cities.length === 0 ? (
<div className="p-4 text-center text-base-content/60">
هیچ شهری موجود نیست
</div>
) : (
<div className="p-4 text-center text-base-content/60">
شهر مورد نظر خود را جستجو کنید
</div>
)}
</div>

<div className="pt-2 border-t border-base-300">
<p className="text-sm text-center text-base-content/60">
شهر شما در لیست نیست یا مشکلی مشاهده می‌کنید؟{' '}
<a
href="https://feedback.widgetify.ir"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline"
onClick={() =>
Analytics.event('feedback_link_clicked', {
source: 'city_selection',
})
}
>
اطلاع دهید
</a>
</p>
</div>
</div>
</Modal>
</SectionPanel>
Expand Down
Loading