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:
Denozordec
2026-06-26 22:56:54 +07:00
co-authored by Cursor
parent a51b5f3594
commit db1d26d324
10 changed files with 429 additions and 33 deletions
+11 -17
View File
@@ -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"
+6
View File
@@ -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: 'Пополнение баланса хостера',
+20 -7
View File
@@ -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) {
+4
View File
@@ -115,6 +115,10 @@ export interface ActiveTariff {
diskType?: string
monthlyRate?: number
currency?: string
datacenterKey?: string
datacenterName?: string
location?: string
country?: string
}
export interface SyncLogRow {
+2
View File
@@ -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/*"]
+9 -8
View File
@@ -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,