feat(web): стандартизированный справочник стран и городов в @cfdm/shared/geo
- packages/shared/src/geo: страны ISO 3166-1 alpha-2 + топ-города по странам - vps.tsx: countryOptions/cityOptions из справочника + существующих VPS, города фильтруются по выбранной стране - auto-complete-input: убран onBlur-хак с setTimeout, фильтрация по основному полю, initialFocus=false чтобы popover не закрывался - vite.config.ts + tsconfig.json: алиас @cfdm/shared/geo Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@cfdm/ui/components/command'
|
||||
@@ -43,19 +42,18 @@ export function AutoCompleteInput({
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
searchPlaceholder: _searchPlaceholder = 'Поиск…',
|
||||
emptyText = 'Ничего не найдено',
|
||||
className,
|
||||
showLeadingInInput = true,
|
||||
}: AutoCompleteInputProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [query, setQuery] = React.useState('')
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const q = value.trim().toLowerCase()
|
||||
if (!q) return options
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
||||
}, [options, query])
|
||||
}, [options, value])
|
||||
|
||||
const selected = options.find((o) => o.value.toLowerCase() === value.trim().toLowerCase())
|
||||
const leading = selected?.leading
|
||||
@@ -81,10 +79,6 @@ export function AutoCompleteInput({
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
// задержка чтобы клик по item успел сработать
|
||||
setTimeout(() => setOpen(false), 150)
|
||||
}}
|
||||
className={cn(
|
||||
showLeadingInInput && leading ? 'pl-8' : '',
|
||||
className,
|
||||
@@ -93,16 +87,17 @@ export function AutoCompleteInput({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-[--anchor-width] p-0">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[--anchor-width] p-0"
|
||||
initialFocus={false}
|
||||
>
|
||||
<Command shouldFilter={false} loop>
|
||||
<div className="flex items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="h-9"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground truncate flex-1">
|
||||
{value.trim() || 'Введите для поиска'}
|
||||
</span>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
@@ -115,7 +110,6 @@ export function AutoCompleteInput({
|
||||
value={opt.value}
|
||||
onSelect={() => {
|
||||
onChange(opt.value)
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
}}
|
||||
className="gap-2"
|
||||
|
||||
@@ -37,6 +37,12 @@ export function getCountryFlagEmoji(country?: string): string {
|
||||
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
|
||||
}
|
||||
|
||||
/** Флаг по ISO 3166-1 alpha-2 коду страны. */
|
||||
export function getCountryFlagEmojiByCode(code?: string): string {
|
||||
if (!code || code.length !== 2) return '🌐'
|
||||
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
|
||||
}
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
direct_vps_payment: 'Прямой платеж за VPS',
|
||||
provider_balance_topup: 'Пополнение баланса хостера',
|
||||
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
} from '@/components/vps-filters'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji } from '@/lib/format'
|
||||
import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo'
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -141,28 +142,40 @@ function VpsPage() {
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
// Из существующих VPS — могут быть произвольные строки
|
||||
for (const v of snapshot?.vps ?? []) {
|
||||
const c = (v.country || '').trim()
|
||||
if (c) names.add(c)
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
leading: getCountryFlagEmoji(name),
|
||||
}))
|
||||
// Из стандартизированного справочника
|
||||
for (const c of COUNTRIES) names.add(c.name)
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
|
||||
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name),
|
||||
}
|
||||
})
|
||||
}, [snapshot?.vps])
|
||||
|
||||
const cityOptions = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
// Из существующих VPS
|
||||
for (const v of snapshot?.vps ?? []) {
|
||||
const c = (v.city || '').trim()
|
||||
if (c) names.add(c)
|
||||
}
|
||||
// Из стандартизированного справочника (фильтр по выбранной стране, если есть)
|
||||
const countryCode = filters.country
|
||||
? COUNTRY_BY_NAME_RU[filters.country.toLowerCase()]?.code
|
||||
: undefined
|
||||
for (const city of listCities(countryCode)) names.add(city.name)
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))
|
||||
}, [snapshot?.vps])
|
||||
}, [snapshot?.vps, filters.country])
|
||||
|
||||
const tableSections = useMemo(() => {
|
||||
if (!filters.groupByProject) {
|
||||
|
||||
@@ -115,6 +115,10 @@ export interface ActiveTariff {
|
||||
diskType?: string
|
||||
monthlyRate?: number
|
||||
currency?: string
|
||||
datacenterKey?: string
|
||||
datacenterName?: string
|
||||
location?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface SyncLogRow {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"@cfdm/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@cfdm/shared/contracts/*": ["../../packages/shared/src/contracts/*"],
|
||||
"@cfdm/shared/types/*": ["../../packages/shared/src/types/*"],
|
||||
"@cfdm/shared/geo": ["../../packages/shared/src/geo/index.ts"],
|
||||
"@cfdm/shared/geo/*": ["../../packages/shared/src/geo/*"],
|
||||
"@cfdm/db": ["../../packages/db/src/index.ts"],
|
||||
"@cfdm/db/schema": ["../../packages/db/src/schema/index.ts"],
|
||||
"@cfdm/db/repositories/*": ["../../packages/db/src/repositories/*"]
|
||||
|
||||
@@ -11,14 +11,15 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@cfdm/ui/components': path.resolve(__dirname, '../../packages/ui/src/components'),
|
||||
'@cfdm/ui/hooks': path.resolve(__dirname, '../../packages/ui/src/hooks'),
|
||||
'@cfdm/ui/lib/utils': path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts'),
|
||||
'@cfdm/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
|
||||
'@cfdm/db': path.resolve(__dirname, '../../packages/db/src/index.ts'),
|
||||
},
|
||||
alias: [
|
||||
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||
{ find: '@cfdm/ui/components', replacement: path.resolve(__dirname, '../../packages/ui/src/components') },
|
||||
{ find: '@cfdm/ui/hooks', replacement: path.resolve(__dirname, '../../packages/ui/src/hooks') },
|
||||
{ find: '@cfdm/ui/lib/utils', replacement: path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts') },
|
||||
{ find: /^@cfdm\/shared\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/shared/src/$1') },
|
||||
{ find: '@cfdm/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') },
|
||||
{ find: '@cfdm/db', replacement: path.resolve(__dirname, '../../packages/db/src/index.ts') },
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user