From 800e92e6575492973f080fa135f4a4ff00a4c0a6 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Thu, 28 May 2026 18:26:52 +0300 Subject: [PATCH 01/11] Refactor Locations to POST /query endpoint --- .github/workflows/elixir.yml | 1 + assets/data/countries_meta.json | 1 + assets/js/dashboard/router.tsx | 14 +- .../js/dashboard/stats/locations/countries.ts | 29 ++ .../js/dashboard/stats/locations/details.tsx | 104 ++++++ .../dashboard/stats/locations/flag-emoji.tsx | 13 + assets/js/dashboard/stats/locations/index.js | 315 ----------------- assets/js/dashboard/stats/locations/index.tsx | 316 ++++++++++++++++++ assets/js/dashboard/stats/locations/map.tsx | 110 +++--- .../dashboard/stats/modals/locations-modal.js | 127 ------- .../dashboard/stats/reports/reports-config.ts | 30 +- lib/mix/tasks/generate_countries_meta.ex | 39 +++ 12 files changed, 588 insertions(+), 511 deletions(-) create mode 100644 assets/data/countries_meta.json create mode 100644 assets/js/dashboard/stats/locations/countries.ts create mode 100644 assets/js/dashboard/stats/locations/details.tsx create mode 100644 assets/js/dashboard/stats/locations/flag-emoji.tsx delete mode 100644 assets/js/dashboard/stats/locations/index.js create mode 100644 assets/js/dashboard/stats/locations/index.tsx delete mode 100644 assets/js/dashboard/stats/modals/locations-modal.js create mode 100644 lib/mix/tasks/generate_countries_meta.ex diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 48af9e9f2aec..2ebb5fe0185a 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -293,5 +293,6 @@ jobs: - run: mix compile --warnings-as-errors --all-warnings - run: mix format --check-formatted - run: mix deps.unlock --check-unused + - run: mix generate_countries_meta && git diff --exit-code -- assets/data/countries_meta.json - run: mix credo diff --from-git-merge-base origin/master - run: mix dialyzer diff --git a/assets/data/countries_meta.json b/assets/data/countries_meta.json new file mode 100644 index 000000000000..29c67793d2f9 --- /dev/null +++ b/assets/data/countries_meta.json @@ -0,0 +1 @@ +{"YE":{"flag":"🇾🇪","alpha_3":"YEM"},"QA":{"flag":"🇶🇦","alpha_3":"QAT"},"MR":{"flag":"🇲🇷","alpha_3":"MRT"},"CH":{"flag":"🇨🇭","alpha_3":"CHE"},"AF":{"flag":"🇦🇫","alpha_3":"AFG"},"AO":{"flag":"🇦🇴","alpha_3":"AGO"},"BT":{"flag":"🇧🇹","alpha_3":"BTN"},"HK":{"flag":"🇭🇰","alpha_3":"HKG"},"CU":{"flag":"🇨🇺","alpha_3":"CUB"},"HU":{"flag":"🇭🇺","alpha_3":"HUN"},"AZ":{"flag":"🇦🇿","alpha_3":"AZE"},"SS":{"flag":"🇸🇸","alpha_3":"SSD"},"BY":{"flag":"🇧🇾","alpha_3":"BLR"},"MA":{"flag":"🇲🇦","alpha_3":"MAR"},"MZ":{"flag":"🇲🇿","alpha_3":"MOZ"},"TR":{"flag":"🇹🇷","alpha_3":"TUR"},"PL":{"flag":"🇵🇱","alpha_3":"POL"},"US":{"flag":"🇺🇸","alpha_3":"USA"},"SR":{"flag":"🇸🇷","alpha_3":"SUR"},"LR":{"flag":"🇱🇷","alpha_3":"LBR"},"OM":{"flag":"🇴🇲","alpha_3":"OMN"},"MH":{"flag":"🇲🇭","alpha_3":"MHL"},"TG":{"flag":"🇹🇬","alpha_3":"TGO"},"CW":{"flag":"🇨🇼","alpha_3":"CUW"},"AT":{"flag":"🇦🇹","alpha_3":"AUT"},"CO":{"flag":"🇨🇴","alpha_3":"COL"},"SV":{"flag":"🇸🇻","alpha_3":"SLV"},"RE":{"flag":"🇷🇪","alpha_3":"REU"},"LI":{"flag":"🇱🇮","alpha_3":"LIE"},"PR":{"flag":"🇵🇷","alpha_3":"PRI"},"GI":{"flag":"🇬🇮","alpha_3":"GIB"},"CV":{"flag":"🇨🇻","alpha_3":"CPV"},"KG":{"flag":"🇰🇬","alpha_3":"KGZ"},"SK":{"flag":"🇸🇰","alpha_3":"SVK"},"LT":{"flag":"🇱🇹","alpha_3":"LTU"},"AL":{"flag":"🇦🇱","alpha_3":"ALB"},"BL":{"flag":"🇧🇱","alpha_3":"BLM"},"FK":{"flag":"🇫🇰","alpha_3":"FLK"},"TV":{"flag":"🇹🇻","alpha_3":"TUV"},"SJ":{"flag":"🇸🇯","alpha_3":"SJM"},"CX":{"flag":"🇨🇽","alpha_3":"CXR"},"VG":{"flag":"🇻🇬","alpha_3":"VGB"},"VI":{"flag":"🇻🇮","alpha_3":"VIR"},"TT":{"flag":"🇹🇹","alpha_3":"TTO"},"AI":{"flag":"🇦🇮","alpha_3":"AIA"},"GE":{"flag":"🇬🇪","alpha_3":"GEO"},"GB":{"flag":"🇬🇧","alpha_3":"GBR"},"TW":{"flag":"🇹🇼","alpha_3":"TWN"},"BV":{"flag":"🇧🇻","alpha_3":"BVT"},"AU":{"flag":"🇦🇺","alpha_3":"AUS"},"CD":{"flag":"🇨🇩","alpha_3":"COD"},"IL":{"flag":"🇮🇱","alpha_3":"ISR"},"FJ":{"flag":"🇫🇯","alpha_3":"FJI"},"HM":{"flag":"🇭🇲","alpha_3":"HMD"},"MO":{"flag":"🇲🇴","alpha_3":"MAC"},"LU":{"flag":"🇱🇺","alpha_3":"LUX"},"TH":{"flag":"🇹🇭","alpha_3":"THA"},"TZ":{"flag":"🇹🇿","alpha_3":"TZA"},"MC":{"flag":"🇲🇨","alpha_3":"MCO"},"AD":{"flag":"🇦🇩","alpha_3":"AND"},"IQ":{"flag":"🇮🇶","alpha_3":"IRQ"},"NI":{"flag":"🇳🇮","alpha_3":"NIC"},"KW":{"flag":"🇰🇼","alpha_3":"KWT"},"MW":{"flag":"🇲🇼","alpha_3":"MWI"},"GH":{"flag":"🇬🇭","alpha_3":"GHA"},"DM":{"flag":"🇩🇲","alpha_3":"DMA"},"EE":{"flag":"🇪🇪","alpha_3":"EST"},"IS":{"flag":"🇮🇸","alpha_3":"ISL"},"BM":{"flag":"🇧🇲","alpha_3":"BMU"},"EC":{"flag":"🇪🇨","alpha_3":"ECU"},"KM":{"flag":"🇰🇲","alpha_3":"COM"},"SB":{"flag":"🇸🇧","alpha_3":"SLB"},"AE":{"flag":"🇦🇪","alpha_3":"ARE"},"CM":{"flag":"🇨🇲","alpha_3":"CMR"},"EH":{"flag":"🇪🇭","alpha_3":"ESH"},"CC":{"flag":"🇨🇨","alpha_3":"CCK"},"AS":{"flag":"🇦🇸","alpha_3":"ASM"},"KH":{"flag":"🇰🇭","alpha_3":"KHM"},"MD":{"flag":"🇲🇩","alpha_3":"MDA"},"NA":{"flag":"🇳🇦","alpha_3":"NAM"},"SA":{"flag":"🇸🇦","alpha_3":"SAU"},"HN":{"flag":"🇭🇳","alpha_3":"HND"},"MK":{"flag":"🇲🇰","alpha_3":"MKD"},"LC":{"flag":"🇱🇨","alpha_3":"LCA"},"PA":{"flag":"🇵🇦","alpha_3":"PAN"},"VC":{"flag":"🇻🇨","alpha_3":"VCT"},"TM":{"flag":"🇹🇲","alpha_3":"TKM"},"SI":{"flag":"🇸🇮","alpha_3":"SVN"},"GQ":{"flag":"🇬🇶","alpha_3":"GNQ"},"PH":{"flag":"🇵🇭","alpha_3":"PHL"},"CZ":{"flag":"🇨🇿","alpha_3":"CZE"},"BO":{"flag":"🇧🇴","alpha_3":"BOL"},"SY":{"flag":"🇸🇾","alpha_3":"SYR"},"NO":{"flag":"🇳🇴","alpha_3":"NOR"},"IM":{"flag":"🇮🇲","alpha_3":"IMN"},"SX":{"flag":"🇸🇽","alpha_3":"SXM"},"GG":{"flag":"🇬🇬","alpha_3":"GGY"},"GW":{"flag":"🇬🇼","alpha_3":"GNB"},"SH":{"flag":"🇸🇭","alpha_3":"SHN"},"NC":{"flag":"🇳🇨","alpha_3":"NCL"},"BE":{"flag":"🇧🇪","alpha_3":"BEL"},"JP":{"flag":"🇯🇵","alpha_3":"JPN"},"LV":{"flag":"🇱🇻","alpha_3":"LVA"},"AM":{"flag":"🇦🇲","alpha_3":"ARM"},"SD":{"flag":"🇸🇩","alpha_3":"SDN"},"GT":{"flag":"🇬🇹","alpha_3":"GTM"},"PY":{"flag":"🇵🇾","alpha_3":"PRY"},"MN":{"flag":"🇲🇳","alpha_3":"MNG"},"TK":{"flag":"🇹🇰","alpha_3":"TKL"},"DZ":{"flag":"🇩🇿","alpha_3":"DZA"},"KZ":{"flag":"🇰🇿","alpha_3":"KAZ"},"LY":{"flag":"🇱🇾","alpha_3":"LBY"},"AW":{"flag":"🇦🇼","alpha_3":"ABW"},"UY":{"flag":"🇺🇾","alpha_3":"URY"},"GL":{"flag":"🇬🇱","alpha_3":"GRL"},"SN":{"flag":"🇸🇳","alpha_3":"SEN"},"UM":{"flag":"🇺🇲","alpha_3":"UMI"},"JO":{"flag":"🇯🇴","alpha_3":"JOR"},"MT":{"flag":"🇲🇹","alpha_3":"MLT"},"BS":{"flag":"🇧🇸","alpha_3":"BHS"},"BI":{"flag":"🇧🇮","alpha_3":"BDI"},"BA":{"flag":"🇧🇦","alpha_3":"BIH"},"MQ":{"flag":"🇲🇶","alpha_3":"MTQ"},"MU":{"flag":"🇲🇺","alpha_3":"MUS"},"MS":{"flag":"🇲🇸","alpha_3":"MSR"},"BW":{"flag":"🇧🇼","alpha_3":"BWA"},"YT":{"flag":"🇾🇹","alpha_3":"MYT"},"PN":{"flag":"🇵🇳","alpha_3":"PCN"},"MP":{"flag":"🇲🇵","alpha_3":"MNP"},"ML":{"flag":"🇲🇱","alpha_3":"MLI"},"BH":{"flag":"🇧🇭","alpha_3":"BHR"},"LB":{"flag":"🇱🇧","alpha_3":"LBN"},"AR":{"flag":"🇦🇷","alpha_3":"ARG"},"PG":{"flag":"🇵🇬","alpha_3":"PNG"},"GR":{"flag":"🇬🇷","alpha_3":"GRC"},"HT":{"flag":"🇭🇹","alpha_3":"HTI"},"WS":{"flag":"🇼🇸","alpha_3":"WSM"},"SG":{"flag":"🇸🇬","alpha_3":"SGP"},"GP":{"flag":"🇬🇵","alpha_3":"GLP"},"BF":{"flag":"🇧🇫","alpha_3":"BFA"},"ME":{"flag":"🇲🇪","alpha_3":"MNE"},"AQ":{"flag":"🇦🇶","alpha_3":"ATA"},"PK":{"flag":"🇵🇰","alpha_3":"PAK"},"FM":{"flag":"🇫🇲","alpha_3":"FSM"},"MV":{"flag":"🇲🇻","alpha_3":"MDV"},"GS":{"flag":"🇬🇸","alpha_3":"SGS"},"BN":{"flag":"🇧🇳","alpha_3":"BRN"},"CK":{"flag":"🇨🇰","alpha_3":"COK"},"IO":{"flag":"🇮🇴","alpha_3":"IOT"},"SE":{"flag":"🇸🇪","alpha_3":"SWE"},"SC":{"flag":"🇸🇨","alpha_3":"SYC"},"ZW":{"flag":"🇿🇼","alpha_3":"ZWE"},"SL":{"flag":"🇸🇱","alpha_3":"SLE"},"AG":{"flag":"🇦🇬","alpha_3":"ATG"},"PF":{"flag":"🇵🇫","alpha_3":"PYF"},"CF":{"flag":"🇨🇫","alpha_3":"CAF"},"BD":{"flag":"🇧🇩","alpha_3":"BGD"},"AX":{"flag":"🇦🇽","alpha_3":"ALA"},"SZ":{"flag":"🇸🇿","alpha_3":"SWZ"},"HR":{"flag":"🇭🇷","alpha_3":"HRV"},"RS":{"flag":"🇷🇸","alpha_3":"SRB"},"NF":{"flag":"🇳🇫","alpha_3":"NFK"},"IE":{"flag":"🇮🇪","alpha_3":"IRL"},"NR":{"flag":"🇳🇷","alpha_3":"NRU"},"ZA":{"flag":"🇿🇦","alpha_3":"ZAF"},"CA":{"flag":"🇨🇦","alpha_3":"CAN"},"KR":{"flag":"🇰🇷","alpha_3":"KOR"},"A1":{"flag":"🏳️","alpha_3":null},"VA":{"flag":"🇻🇦","alpha_3":"VAT"},"NU":{"flag":"🇳🇺","alpha_3":"NIU"},"JE":{"flag":"🇯🇪","alpha_3":"JEY"},"TD":{"flag":"🇹🇩","alpha_3":"TCD"},"IR":{"flag":"🇮🇷","alpha_3":"IRN"},"NL":{"flag":"🇳🇱","alpha_3":"NLD"},"BB":{"flag":"🇧🇧","alpha_3":"BRB"},"FI":{"flag":"🇫🇮","alpha_3":"FIN"},"UA":{"flag":"🇺🇦","alpha_3":"UKR"},"ID":{"flag":"🇮🇩","alpha_3":"IDN"},"ST":{"flag":"🇸🇹","alpha_3":"STP"},"VU":{"flag":"🇻🇺","alpha_3":"VUT"},"RU":{"flag":"🇷🇺","alpha_3":"RUS"},"NE":{"flag":"🇳🇪","alpha_3":"NER"},"TO":{"flag":"🇹🇴","alpha_3":"TON"},"UZ":{"flag":"🇺🇿","alpha_3":"UZB"},"GN":{"flag":"🇬🇳","alpha_3":"GIN"},"JM":{"flag":"🇯🇲","alpha_3":"JAM"},"FR":{"flag":"🇫🇷","alpha_3":"FRA"},"TL":{"flag":"🇹🇱","alpha_3":"TLS"},"ET":{"flag":"🇪🇹","alpha_3":"ETH"},"KI":{"flag":"🇰🇮","alpha_3":"KIR"},"CG":{"flag":"🇨🇬","alpha_3":"COG"},"DE":{"flag":"🇩🇪","alpha_3":"DEU"},"RW":{"flag":"🇷🇼","alpha_3":"RWA"},"DO":{"flag":"🇩🇴","alpha_3":"DOM"},"VE":{"flag":"🇻🇪","alpha_3":"VEN"},"PW":{"flag":"🇵🇼","alpha_3":"PLW"},"TC":{"flag":"🇹🇨","alpha_3":"TCA"},"ZM":{"flag":"🇿🇲","alpha_3":"ZMB"},"NG":{"flag":"🇳🇬","alpha_3":"NGA"},"WF":{"flag":"🇼🇫","alpha_3":"WLF"},"GF":{"flag":"🇬🇫","alpha_3":"GUF"},"KN":{"flag":"🇰🇳","alpha_3":"KNA"},"ES":{"flag":"🇪🇸","alpha_3":"ESP"},"GM":{"flag":"🇬🇲","alpha_3":"GMB"},"KP":{"flag":"🇰🇵","alpha_3":"PRK"},"GY":{"flag":"🇬🇾","alpha_3":"GUY"},"MX":{"flag":"🇲🇽","alpha_3":"MEX"},"IN":{"flag":"🇮🇳","alpha_3":"IND"},"SM":{"flag":"🇸🇲","alpha_3":"SMR"},"BG":{"flag":"🇧🇬","alpha_3":"BGR"},"MF":{"flag":"🇲🇫","alpha_3":"MAF"},"CL":{"flag":"🇨🇱","alpha_3":"CHL"},"VN":{"flag":"🇻🇳","alpha_3":"VNM"},"NP":{"flag":"🇳🇵","alpha_3":"NPL"},"CR":{"flag":"🇨🇷","alpha_3":"CRI"},"ER":{"flag":"🇪🇷","alpha_3":"ERI"},"LK":{"flag":"🇱🇰","alpha_3":"LKA"},"CI":{"flag":"🇨🇮","alpha_3":"CIV"},"PT":{"flag":"🇵🇹","alpha_3":"PRT"},"TJ":{"flag":"🇹🇯","alpha_3":"TJK"},"MY":{"flag":"🇲🇾","alpha_3":"MYS"},"PS":{"flag":"🇵🇸","alpha_3":"PSE"},"PE":{"flag":"🇵🇪","alpha_3":"PER"},"LS":{"flag":"🇱🇸","alpha_3":"LSO"},"CY":{"flag":"🇨🇾","alpha_3":"CYP"},"TN":{"flag":"🇹🇳","alpha_3":"TUN"},"XK":{"flag":"🇽🇰","alpha_3":"XKX"},"KY":{"flag":"🇰🇾","alpha_3":"CYM"},"DK":{"flag":"🇩🇰","alpha_3":"DNK"},"BZ":{"flag":"🇧🇿","alpha_3":"BLZ"},"FO":{"flag":"🇫🇴","alpha_3":"FRO"},"LA":{"flag":"🇱🇦","alpha_3":"LAO"},"RO":{"flag":"🇷🇴","alpha_3":"ROU"},"DJ":{"flag":"🇩🇯","alpha_3":"DJI"},"EG":{"flag":"🇪🇬","alpha_3":"EGY"},"KE":{"flag":"🇰🇪","alpha_3":"KEN"},"UG":{"flag":"🇺🇬","alpha_3":"UGA"},"MM":{"flag":"🇲🇲","alpha_3":"MMR"},"BQ":{"flag":"🇧🇶","alpha_3":"BES"},"PM":{"flag":"🇵🇲","alpha_3":"SPM"},"GD":{"flag":"🇬🇩","alpha_3":"GRD"},"GU":{"flag":"🇬🇺","alpha_3":"GUM"},"CN":{"flag":"🇨🇳","alpha_3":"CHN"},"SO":{"flag":"🇸🇴","alpha_3":"SOM"},"BJ":{"flag":"🇧🇯","alpha_3":"BEN"},"BR":{"flag":"🇧🇷","alpha_3":"BRA"},"GA":{"flag":"🇬🇦","alpha_3":"GAB"},"NZ":{"flag":"🇳🇿","alpha_3":"NZL"},"MG":{"flag":"🇲🇬","alpha_3":"MDG"},"IT":{"flag":"🇮🇹","alpha_3":"ITA"},"TF":{"flag":"🇹🇫","alpha_3":"ATF"}} \ No newline at end of file diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index b19c9a2aff8c..1e8a1f10d0d8 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -15,7 +15,7 @@ import { BREAKDOWN_REPORTS, BreakdownReportKey } from './stats/reports/reports-config' -import LocationsModal from './stats/modals/locations-modal' +import { LocationsDetails } from './stats/locations/details' import PropsModal from './stats/modals/props' import ConversionsModal from './stats/modals/conversions' import FilterModal from './stats/modals/filter-modal' @@ -113,18 +113,18 @@ export const exitPagesRoute = { } export const countriesRoute = { - path: 'countries', - element: + path: BREAKDOWN_REPORTS.countries.detailsPath, + element: } export const regionsRoute = { - path: 'regions', - element: + path: BREAKDOWN_REPORTS.regions.detailsPath, + element: } export const citiesRoute = { - path: 'cities', - element: + path: BREAKDOWN_REPORTS.cities.detailsPath, + element: } export const browsersRoute = { diff --git a/assets/js/dashboard/stats/locations/countries.ts b/assets/js/dashboard/stats/locations/countries.ts new file mode 100644 index 000000000000..5c7f7bb6d1fa --- /dev/null +++ b/assets/js/dashboard/stats/locations/countries.ts @@ -0,0 +1,29 @@ +import worldJson from 'visionscarto-world-atlas/world/110m.json' +import countriesMeta from '../../../../data/countries_meta.json' +import * as topojson from 'topojson-client' + +// The actual type is more extensive, this is only the part that we care about +export type WorldJsonCountryData = { properties: { a3: string } } + +export function parseWorldTopoJsonToGeoJsonFeatures(): Array { + const collection = topojson.feature( + // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library + worldJson, + worldJson.objects.countries + ) + // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection + return collection.features +} + +export type CountryEntry = { + // alpha_3 is null for non-country override entries like "A1" (Anonymous VPN) + alpha_3: string | null + flag: string +} + +type CountryTwoLetterCode = string + +export type CountriesLookup = Record + +export const COUNTRIES_BY_TWO_LETTER_CODE = + countriesMeta as unknown as CountriesLookup diff --git a/assets/js/dashboard/stats/locations/details.tsx b/assets/js/dashboard/stats/locations/details.tsx new file mode 100644 index 000000000000..8f3255e3d8df --- /dev/null +++ b/assets/js/dashboard/stats/locations/details.tsx @@ -0,0 +1,104 @@ +import React, { ReactNode } from 'react' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { chooseBreakdownMetricsByContext } from '../breakdowns' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' +import { + DetailsBreakdown, + DimensionCell, + DimensionCellProps +} from '../modals/details-breakdown' +import Modal from '../modals/modal' +import { + getCitiesFilterInfo, + getCountriesFilterInfo, + getRegionsFilterInfo, + LocationsReportKey +} from '.' +import { FlagEmoji } from './flag-emoji' + +export function LocationsDetails({ + reportKey +}: { + reportKey: LocationsReportKey +}) { + const { dashboardState } = useDashboardStateContext() + const reportConfig = BREAKDOWN_REPORTS[reportKey] + + const metrics = chooseBreakdownMetricsByContext( + reportConfig.metricsByContext, + { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: false + } + ) + + const DimensionElement = DIMENSION_ELEMENTS[reportKey] + + return ( + + + + ) +} + +const CountryDimensionCell = (props: DimensionCellProps) => { + const [countryCode, countryName] = props.row.dimensions + return ( + } + getFilterInfo={getCountriesFilterInfo} + /> + ) +} + +const RegionsDimensionCell = (props: DimensionCellProps) => { + const [_regionCode, regionName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getRegionsFilterInfo} + /> + ) +} + +const CitiesDimensionCell = (props: DimensionCellProps) => { + const [_cityCode, cityName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCitiesFilterInfo} + /> + ) +} + +const DIMENSION_ELEMENTS: Record< + LocationsReportKey, + (props: DimensionCellProps) => ReactNode +> = { + [BreakdownReportKey.countries]: CountryDimensionCell, + [BreakdownReportKey.regions]: RegionsDimensionCell, + [BreakdownReportKey.cities]: CitiesDimensionCell +} diff --git a/assets/js/dashboard/stats/locations/flag-emoji.tsx b/assets/js/dashboard/stats/locations/flag-emoji.tsx new file mode 100644 index 000000000000..2fa7f51024ab --- /dev/null +++ b/assets/js/dashboard/stats/locations/flag-emoji.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { COUNTRIES_BY_TWO_LETTER_CODE } from './countries' + +export const FlagEmoji = ({ countryCode }: { countryCode: string | null }) => { + if (!countryCode) { + return null + } + const entry = COUNTRIES_BY_TWO_LETTER_CODE[countryCode] + if (!entry?.flag) { + return null + } + return {entry.flag} +} diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js deleted file mode 100644 index b083a2da1625..000000000000 --- a/assets/js/dashboard/stats/locations/index.js +++ /dev/null @@ -1,315 +0,0 @@ -import React from 'react' - -import * as storage from '../../util/storage' -import CountriesMap from './map' - -import * as api from '../../api' -import { apiPath } from '../../util/url' -import ListReport from '../reports/list-legacy' -import * as metrics from '../reports/metrics' -import { - hasConversionGoalFilter, - getFiltersByKeyPrefix -} from '../../util/filters' -import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' -import { citiesRoute, countriesRoute, regionsRoute } from '../../router' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { ReportLayout } from '../reports/report-layout' -import { ReportHeader } from '../reports/report-header' -import { TabButton, TabWrapper } from '../../components/tabs' -import MoreLink from '../more-link' -import { MoreLinkState } from '../more-link-state' - -function Countries({ dashboardState, site, onClick, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/countries'), dashboardState, { limit: 9 }) - } - - function renderIcon(country) { - return {country.flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'country', - filter: ['is', 'country', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function Regions({ dashboardState, site, onClick, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/regions'), dashboardState, { limit: 9 }) - } - - function renderIcon(region) { - return {region.country_flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'region', - filter: ['is', 'region', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function Cities({ dashboardState, site, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/cities'), dashboardState, { limit: 9 }) - } - - function renderIcon(city) { - return {city.country_flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'city', - filter: ['is', 'city', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -class Locations extends React.Component { - constructor(props) { - super(props) - this.onCountryFilter = this.onCountryFilter.bind(this) - this.onRegionFilter = this.onRegionFilter.bind(this) - this.afterFetchData = this.afterFetchData.bind(this) - this.tabKey = `geoTab__${props.site.domain}` - const storedTab = storage.getItem(this.tabKey) - this.state = { - mode: storedTab || 'map', - loading: true, - skipImportedReason: null, - moreLinkState: MoreLinkState.LOADING - } - } - - componentDidUpdate(prevProps, prevState) { - const isRemovingFilter = (filterName) => { - return ( - getFiltersByKeyPrefix(prevProps.dashboardState, filterName).length > - 0 && - getFiltersByKeyPrefix(this.props.dashboardState, filterName).length == 0 - ) - } - - if (this.state.mode === 'cities' && isRemovingFilter('region')) { - this.setMode('regions')() - } - - if (this.state.mode === 'regions' && isRemovingFilter('country')) { - this.setMode(this.countriesRestoreMode || 'countries')() - } - - if ( - this.props.dashboardState !== prevProps.dashboardState || - this.state.mode !== prevState.mode - ) { - this.setState({ loading: true, moreLinkState: MoreLinkState.LOADING }) - } - } - - setMode(mode) { - return () => { - storage.setItem(this.tabKey, mode) - this.setState({ mode }) - } - } - - onCountryFilter(mode) { - return () => { - this.countriesRestoreMode = mode - this.setMode('regions')() - } - } - - onRegionFilter() { - this.setMode('cities')() - } - - afterFetchData(apiResponse) { - let newMoreLinkState - - if (apiResponse.results && apiResponse.results.length > 0) { - newMoreLinkState = MoreLinkState.READY - } else { - newMoreLinkState = MoreLinkState.HIDDEN - } - this.setState({ - loading: false, - moreLinkState: newMoreLinkState, - skipImportedReason: apiResponse.skip_imported_reason - }) - } - - renderContent() { - switch (this.state.mode) { - case 'cities': - return ( - - ) - case 'regions': - return ( - - ) - case 'countries': - return ( - - ) - case 'map': - default: - return ( - - ) - } - } - - getMoreLinkProps() { - let path - - if (this.state.mode === 'regions') { - path = regionsRoute.path - } else if (this.state.mode === 'cities') { - path = citiesRoute.path - } else { - path = countriesRoute.path - } - - return { path: path, search: (search) => search } - } - - render() { - return ( - - -
- - {[ - { label: 'Map', value: 'map' }, - { label: 'Countries', value: 'countries' }, - { label: 'Regions', value: 'regions' }, - { label: 'Cities', value: 'cities' } - ].map(({ value, label }) => ( - - {label} - - ))} - - -
- -
- {this.renderContent()} -
- ) - } -} - -function LocationsWithContext() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - return -} -export default LocationsWithContext diff --git a/assets/js/dashboard/stats/locations/index.tsx b/assets/js/dashboard/stats/locations/index.tsx new file mode 100644 index 000000000000..f96a19093f3b --- /dev/null +++ b/assets/js/dashboard/stats/locations/index.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' + +import * as storage from '../../util/storage' +import CountriesMap from './map' + +import { + hasConversionGoalFilter, + isRealTimeDashboard, + getFiltersByKeyPrefix +} from '../../util/filters' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import ImportedWarningBubble from '../imported-warning-bubble' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' +import { + DimensionCellWithBar, + DimensionCellWithBarProps, + IndexBreakdown +} from '../reports/index-breakdown' +import { chooseBreakdownMetricsByContext } from '../breakdowns' +import { FilterInfo } from '../../components/drilldown-link' +import { NonTimeDimension } from '../../stats-query' +import { FlagEmoji } from './flag-emoji' +import { DashboardState } from '../../dashboard-state' + +type MapTabKey = 'map' +type TabKey = + | MapTabKey + | BreakdownReportKey.countries + | BreakdownReportKey.regions + | BreakdownReportKey.cities + +export type LocationsReportKey = + | BreakdownReportKey.countries + | BreakdownReportKey.regions + | BreakdownReportKey.cities + +const BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' + +const initTab = (storedTab: string | null): TabKey => { + switch (storedTab) { + case BreakdownReportKey.countries: + return BreakdownReportKey.countries + case BreakdownReportKey.regions: + return BreakdownReportKey.regions + case BreakdownReportKey.cities: + return BreakdownReportKey.cities + case 'map': + default: + return 'map' + } +} + +const getAppliedLocationsFilters = (dashboardState: DashboardState) => ({ + countryFiltersApplied: + getFiltersByKeyPrefix(dashboardState, 'country').length > 0, + regionFiltersApplied: + getFiltersByKeyPrefix(dashboardState, 'region').length > 0 +}) + +export function Locations() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const tabKey = `geoTab__${site.domain}` + const [tab, setTab] = useState(initTab(storage.getItem(tabKey))) + // determines whether to show list or map (the default) when zooming out of Regions tab + const [prefersCountriesList, setPrefersCountriesList] = + useState(false) + const [currentData, setCurrentData] = useState(null) + + useEffect(() => { + storage.setItem(tabKey, tab) + }, [tabKey, tab]) + + const prevFilters = useRef<{ + countryFiltersApplied: boolean + regionFiltersApplied: boolean + }>(getAppliedLocationsFilters(dashboardState)) + + /** + * Clicking on a country applies "Country is ..." filter and zooms to Regions tab, + * clicking on a region applies "Region is ..." filter and zooms to Cities tab (see onClick handlers below). + * This effect handles zooming out of Regions tab on dismissing the countries filter, + * and zooming out of Cities tab on dismissing the regions filter. + */ + useEffect(() => { + setTab((currentTab) => { + const { countryFiltersApplied, regionFiltersApplied } = + getAppliedLocationsFilters(dashboardState) + const prev = { ...prevFilters.current } + prevFilters.current = { countryFiltersApplied, regionFiltersApplied } + + if ( + (currentTab === BreakdownReportKey.regions || + currentTab === BreakdownReportKey.cities) && + prev.countryFiltersApplied && + !countryFiltersApplied + ) { + return prefersCountriesList ? BreakdownReportKey.countries : 'map' + } + + if ( + currentTab === BreakdownReportKey.cities && + prev.regionFiltersApplied && + !regionFiltersApplied + ) { + return BreakdownReportKey.regions + } + + return currentTab + }) + }, [prefersCountriesList, dashboardState]) + + const selectedListKey: LocationsReportKey = + tab === 'map' ? BreakdownReportKey.countries : tab + const reportConfig = BREAKDOWN_REPORTS[selectedListKey] + + const metrics = chooseBreakdownMetricsByContext( + reportConfig.metricsByContext, + { + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: false, + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRevenueAvailable: false + } + ) + + const moreLinkState = currentData + ? currentData.results.length > 0 + ? MoreLinkState.READY + : MoreLinkState.HIDDEN + : MoreLinkState.LOADING + + const moreLinkPath = reportConfig.detailsPath + + const CountriesDimensionElement = useCallback( + (props: DimensionCellWithBarProps) => ( + { + setPrefersCountriesList(true) + setTab(BreakdownReportKey.regions) + }} + /> + ), + [] + ) + + const RegionsDimensionElement = useCallback( + (props: DimensionCellWithBarProps) => ( + setTab(BreakdownReportKey.cities)} + /> + ), + [] + ) + + const DimensionElement = { + [BreakdownReportKey.countries]: CountriesDimensionElement, + [BreakdownReportKey.regions]: RegionsDimensionElement, + [BreakdownReportKey.cities]: CitiesDimensionCell + }[selectedListKey] + + return ( + + +
+ + {( + [ + { label: 'Map', value: 'map' }, + { + label: 'Countries', + value: BreakdownReportKey.countries + }, + { label: 'Regions', value: BreakdownReportKey.regions }, + { label: 'Cities', value: BreakdownReportKey.cities } + ] as const + ).map(({ label, value }) => ( + setTab(value)} + > + {label} + + ))} + + +
+ search + }} + /> +
+ {tab === 'map' ? ( + { + setPrefersCountriesList(false) + setTab(BreakdownReportKey.regions) + }} + onDataReady={setCurrentData} + /> + ) : ( + + )} +
+ ) +} + +const CountriesDimensionCell = ( + props: DimensionCellWithBarProps & { onClick: () => void } +) => { + const [countryCode, countryName] = props.row.dimensions + return ( + } + getFilterInfo={getCountriesFilterInfo} + /> + ) +} + +const RegionsDimensionCell = ( + props: DimensionCellWithBarProps & { onClick: () => void } +) => { + const [_regionCode, regionName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getRegionsFilterInfo} + /> + ) +} + +const CitiesDimensionCell = (props: DimensionCellWithBarProps) => { + const [_cityCode, cityName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCitiesFilterInfo} + /> + ) +} + +export const getCountriesFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [countryCode, countryName] = row.dimensions + + return { + prefix: 'country', + filter: ['is', 'country', [countryCode]], + labels: { [countryCode]: countryName } + } +} + +export const getRegionsFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [regionCode, regionName, _countryCode] = row.dimensions + + return { + prefix: 'region', + filter: ['is', 'region', [regionCode]], + labels: { [regionCode]: regionName } + } +} + +export const getCitiesFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [cityCode, cityName, _countryCode] = row.dimensions + + return { + prefix: 'city', + filter: ['is', 'city', [cityCode]], + labels: { [cityCode]: cityName } + } +} + +export default Locations diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 60b0bdfddaf2..05e4df1fad39 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import classNames from 'classnames' -import * as api from '../../api' import { replaceFilterByPrefix, cleanLabels, @@ -10,17 +9,21 @@ import { } from '../../util/filters' import { useAppNavigate } from '../../navigation/use-app-navigate' import { numberShortFormatter } from '../../util/number-formatter' -import * as topojson from 'topojson-client' -import { useQuery } from '@tanstack/react-query' import { useSiteContext } from '../../site-context' import { useDashboardStateContext } from '../../dashboard-state-context' -import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' -import { apiPath } from '../../util/url' import { MIN_HEIGHT } from '../reports/list-legacy' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' import { DashboardState } from '../../dashboard-state' +import { useQueryApi } from '../../hooks/use-query-api' +import { QueryApiResponse } from '../../api' +import { + COUNTRIES_BY_TWO_LETTER_CODE, + parseWorldTopoJsonToGeoJsonFeatures, + WorldJsonCountryData +} from './countries' +import { getStatsQueryWithImplicitNotEmptyFilter } from '../breakdowns' const width = 475 const height = 335 @@ -31,7 +34,6 @@ type CountryData = { visitors: number code: string } -type WorldJsonCountryData = { properties: { name: string; a3: string } } function getMetricLabel(dashboardState: DashboardState) { if (hasConversionGoalFilter(dashboardState)) { @@ -45,10 +47,10 @@ function getMetricLabel(dashboardState: DashboardState) { const WorldMap = ({ onCountrySelect, - afterFetchData + onDataReady }: { onCountrySelect: () => void - afterFetchData: (response: unknown) => void + onDataReady: (response: QueryApiResponse) => void }) => { const navigate = useAppNavigate() const { mode } = useTheme() @@ -66,49 +68,54 @@ const WorldMap = ({ [dashboardState] ) - const { data, refetch, isFetching, isError } = useQuery({ - queryKey: ['countries', 'map', dashboardState], - placeholderData: (previousData) => previousData, - queryFn: async (): Promise<{ - results: CountryData[] - }> => { - return await api.get(apiPath(site, '/countries'), dashboardState, { - limit: 300 - }) - } - }) - - useEffect(() => { - const onTickRefetchData = () => { - if (dashboardState.period === 'realtime') { - refetch() + const { apiState } = useQueryApi( + site, + [ + 'visit:country', + { + dashboardState, + reportParams: { + metrics: ['visitors'], + dimensions: ['visit:country', 'visit:country_name'], + order_by: [['visitors', 'desc']], + pagination: { limit: 300, offset: 0 } + } } - } - document.addEventListener('tick', onTickRefetchData) - return () => document.removeEventListener('tick', onTickRefetchData) - }, [dashboardState.period, refetch]) + ], + { getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter } + ) + const { data, isFetching, isError } = apiState useEffect(() => { if (data) { - afterFetchData(data) + onDataReady(data) } - }, [afterFetchData, data, isFetching]) + }, [onDataReady, data]) - const { maxValue, dataByCountryCode } = useMemo(() => { - const dataByCountryCode: Map = new Map() + const { maxValue, dataByAlpha3Code } = useMemo(() => { + const dataByAlpha3Code: Map = new Map() let maxValue = 0 - for (const { alpha_3, visitors, name, code } of data?.results || []) { + for (const row of data?.results ?? []) { + const [countryCode, countryName] = row.dimensions + const [visitors] = row.metrics as [number] + const entry = COUNTRIES_BY_TWO_LETTER_CODE[countryCode] + if (!entry || !entry.alpha_3) continue if (visitors > maxValue) { maxValue = visitors } - dataByCountryCode.set(alpha_3, { alpha_3, visitors, name, code }) + dataByAlpha3Code.set(entry.alpha_3, { + alpha_3: entry.alpha_3, + visitors, + name: countryName, + code: countryCode + }) } - return { maxValue, dataByCountryCode } + return { maxValue, dataByAlpha3Code } }, [data]) const onCountryClick = useCallback( (d: WorldJsonCountryData) => { - const country = dataByCountryCode.get(d.properties.a3) + const country = dataByAlpha3Code.get(d.properties.a3) const clickable = country && country.visitors if (clickable) { const filters = replaceFilterByPrefix(dashboardState, 'country', [ @@ -125,7 +132,7 @@ const WorldMap = ({ }) } }, - [navigate, dashboardState, dataByCountryCode, onCountrySelect] + [navigate, dashboardState, dataByAlpha3Code, onCountrySelect] ) useEffect(() => { @@ -173,15 +180,15 @@ const WorldMap = ({ colorInCountriesWithValues( svgRef.current, getColorForValue, - dataByCountryCode + dataByAlpha3Code ).on('click', (_event, countryPath) => { onCountryClick(countryPath as unknown as WorldJsonCountryData) }) } - }, [mode, maxValue, dataByCountryCode, onCountryClick]) + }, [mode, maxValue, dataByAlpha3Code, onCountryClick]) const hoveredCountryData = tooltip.hoveredCountryAlpha3Code - ? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code) + ? dataByAlpha3Code.get(tooltip.hoveredCountryAlpha3Code) : undefined return ( @@ -274,25 +281,19 @@ function colorInCountriesWithValues( getColorForValue: d3.ScaleLinear, dataByCountryCode: Map ) { - function getCountryByCountryPath(countryPath: unknown) { - return dataByCountryCode.get( - (countryPath as unknown as WorldJsonCountryData).properties.a3 - ) - } - const svg = d3.select(element) return svg - .selectAll(countrySelector) + .selectAll(countrySelector) .style('fill', (countryPath) => { - const country = getCountryByCountryPath(countryPath) + const country = dataByCountryCode.get(countryPath.properties.a3) if (!country?.visitors) { return null } return getColorForValue(country.visitors) }) .style('cursor', (countryPath) => { - const country = getCountryByCountryPath(countryPath) + const country = dataByCountryCode.get(countryPath.properties.a3) if (!country?.visitors) { return null } @@ -304,7 +305,6 @@ function drawHighlightedCountryOutline(element: SVGSVGElement) { return d3.select(element).append('path').attr('class', initialOutlineClass) } -/** @returns the d3 selected svg element */ function drawInteractiveCountries(element: SVGSVGElement) { const path = setupProjetionPath() const data = parseWorldTopoJsonToGeoJsonFeatures() @@ -331,14 +331,4 @@ function setupProjetionPath() { return path } -function parseWorldTopoJsonToGeoJsonFeatures(): Array { - const collection = topojson.feature( - // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library - worldJson, - worldJson.objects.countries - ) - // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection - return collection.features -} - export default WorldMap diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js deleted file mode 100644 index 739a0cd084f9..000000000000 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useCallback } from 'react' - -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import BreakdownModal from './breakdown-modal-legacy' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { addFilter, revenueAvailable } from '../../dashboard-state' - -const VIEWS = { - countries: { - title: 'Top countries', - dimension: 'country', - endpoint: '/countries', - dimensionLabel: 'Country', - defaultOrder: ['visitors', 'desc'] - }, - regions: { - title: 'Top regions', - dimension: 'region', - endpoint: '/regions', - dimensionLabel: 'Region', - defaultOrder: ['visitors', 'desc'] - }, - cities: { - title: 'Top cities', - dimension: 'city', - endpoint: '/cities', - dimensionLabel: 'City', - defaultOrder: ['visitors', 'desc'] - } -} - -function LocationsModal({ currentView }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - let reportInfo = VIEWS[currentView] - reportInfo = { - ...reportInfo, - endpoint: url.apiPath(site, reportInfo.endpoint) - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.code]], - labels: { [listItem.code]: listItem.name } - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - `${reportInfo.dimension}_name`, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), - currentView === 'countries' && metrics.createPercentage() - ].filter((metric) => !!metric) - } - - const renderIcon = useCallback((listItem) => { - return ( - {listItem.country_flag || listItem.flag} - ) - }, []) - - return ( - - - - ) -} - -export default LocationsModal diff --git a/assets/js/dashboard/stats/reports/reports-config.ts b/assets/js/dashboard/stats/reports/reports-config.ts index e0d6433a116f..84d0c5b7574f 100644 --- a/assets/js/dashboard/stats/reports/reports-config.ts +++ b/assets/js/dashboard/stats/reports/reports-config.ts @@ -10,7 +10,7 @@ export type MetricsByContext = { } export type BreakdownReportConfig = { - dimensions: [NonTimeDimension] | [NonTimeDimension, NonTimeDimension] + dimensions: [NonTimeDimension, ...NonTimeDimension[]] metricsByContext: MetricsByContext detailsTitle: string detailsPath: string @@ -50,7 +50,10 @@ export enum BreakdownReportKey { 'utmSources' = 'utmSources', 'utmCampaigns' = 'utmCampaigns', 'utmContents' = 'utmContents', - 'utmTerms' = 'utmTerms' + 'utmTerms' = 'utmTerms', + 'countries' = 'countries', + 'regions' = 'regions', + 'cities' = 'cities' } export const BREAKDOWN_REPORTS: Record< @@ -190,5 +193,28 @@ export const BREAKDOWN_REPORTS: Record< detailsTitle: 'UTM terms', detailsPath: 'utm_terms', dimensionLabel: 'UTM term' + }, + [BreakdownReportKey.countries]: { + dimensions: ['visit:country', 'visit:country_name'], + metricsByContext: COMMON_METRICS_BY_CONTEXT, + detailsTitle: 'Top countries', + detailsPath: 'countries', + dimensionLabel: 'Country' + }, + [BreakdownReportKey.regions]: { + // the 3rd dimension "visit:country" is needed to render the country flag + dimensions: ['visit:region', 'visit:region_name', 'visit:country'], + metricsByContext: COMMON_METRICS_BY_CONTEXT, + detailsTitle: 'Top regions', + detailsPath: 'regions', + dimensionLabel: 'Region' + }, + [BreakdownReportKey.cities]: { + // the 3rd dimension "visit:country" is needed to render the country flag + dimensions: ['visit:city', 'visit:city_name', 'visit:country'], + metricsByContext: COMMON_METRICS_BY_CONTEXT, + detailsTitle: 'Top cities', + detailsPath: 'cities', + dimensionLabel: 'City' } } diff --git a/lib/mix/tasks/generate_countries_meta.ex b/lib/mix/tasks/generate_countries_meta.ex new file mode 100644 index 000000000000..34feee6ceef9 --- /dev/null +++ b/lib/mix/tasks/generate_countries_meta.ex @@ -0,0 +1,39 @@ +defmodule Mix.Tasks.GenerateCountriesMeta do + @moduledoc """ + Regenerates `countries_meta.json` — the compact + `alpha_2 -> %{alpha_3, flag}` lookup the dashboard frontend uses to + render country flags and to do the world map's alpha-2 -> alpha-3 join. + + The source of truth is `Location.Country.all/0` from the `:location` Hex + dependency. We materialize it to a checked-in JSON file in `assets/` folder. + + The checked-in file is protected from drifting off from the Hex dependency by a CI job. + + Run `mix generate_countries_meta` locally whenever the `:location` dependency is bumped, or any time you want to + refresh the committed file. + """ + + use Mix.Task + + @output_path "assets/data/countries_meta.json" + + @impl Mix.Task + def run(_args) do + Application.ensure_all_started(:jason) + Location.Country.load() + + json = + Location.Country.all() + |> Map.new(fn %Location.Country{ + alpha_2: code, + alpha_3: alpha_3, + flag: flag + } -> + {code, %{alpha_3: alpha_3, flag: flag}} + end) + |> Jason.encode!() + + File.write!(@output_path, json) + Mix.shell().info("Wrote #{byte_size(json)} bytes to #{@output_path}") + end +end From 213a36b0a65198a7bb7386776af489c98a27c892 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Mon, 1 Jun 2026 14:30:01 +0300 Subject: [PATCH 02/11] Show the same metrics as in prod --- .../js/dashboard/stats/reports/reports-config.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/stats/reports/reports-config.ts b/assets/js/dashboard/stats/reports/reports-config.ts index 84d0c5b7574f..c27b01237e16 100644 --- a/assets/js/dashboard/stats/reports/reports-config.ts +++ b/assets/js/dashboard/stats/reports/reports-config.ts @@ -196,7 +196,10 @@ export const BREAKDOWN_REPORTS: Record< }, [BreakdownReportKey.countries]: { dimensions: ['visit:country', 'visit:country_name'], - metricsByContext: COMMON_METRICS_BY_CONTEXT, + metricsByContext: { + ...COMMON_METRICS_BY_CONTEXT, + defaultDetailedMetrics: ['visitors', 'percentage'] + }, detailsTitle: 'Top countries', detailsPath: 'countries', dimensionLabel: 'Country' @@ -204,7 +207,10 @@ export const BREAKDOWN_REPORTS: Record< [BreakdownReportKey.regions]: { // the 3rd dimension "visit:country" is needed to render the country flag dimensions: ['visit:region', 'visit:region_name', 'visit:country'], - metricsByContext: COMMON_METRICS_BY_CONTEXT, + metricsByContext: { + ...COMMON_METRICS_BY_CONTEXT, + defaultDetailedMetrics: ['visitors', 'percentage'] + }, detailsTitle: 'Top regions', detailsPath: 'regions', dimensionLabel: 'Region' @@ -212,7 +218,10 @@ export const BREAKDOWN_REPORTS: Record< [BreakdownReportKey.cities]: { // the 3rd dimension "visit:country" is needed to render the country flag dimensions: ['visit:city', 'visit:city_name', 'visit:country'], - metricsByContext: COMMON_METRICS_BY_CONTEXT, + metricsByContext: { + ...COMMON_METRICS_BY_CONTEXT, + defaultDetailedMetrics: ['visitors', 'percentage'] + }, detailsTitle: 'Top cities', detailsPath: 'cities', dimensionLabel: 'City' From 60d575da711e0d066c2e7b49ec9ff1089567e330 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 10:09:25 +0300 Subject: [PATCH 03/11] Underline only text in drilldown links --- .../dashboard/components/drilldown-link.tsx | 27 ++++++++++++------- .../dashboard/stats/locations/flag-emoji.tsx | 2 +- .../stats/modals/breakdown-modal-legacy.tsx | 2 +- .../stats/modals/details-breakdown.tsx | 2 +- .../stats/reports/index-breakdown.tsx | 7 ++--- .../dashboard/stats/reports/list-legacy.tsx | 10 +++---- 6 files changed, 29 insertions(+), 21 deletions(-) diff --git a/assets/js/dashboard/components/drilldown-link.tsx b/assets/js/dashboard/components/drilldown-link.tsx index e9f20483a1ef..43171ea0c180 100644 --- a/assets/js/dashboard/components/drilldown-link.tsx +++ b/assets/js/dashboard/components/drilldown-link.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { ReactNode } from 'react' import { AppNavigationLink, AppNavigationLinkProps @@ -20,15 +20,16 @@ export function DrilldownLink({ filterInfo, onClick, children, - extraClass + icon, + className, + textClassName }: Pick & { - extraClass?: string + className?: string + textClassName?: string + icon?: ReactNode filterInfo: FilterInfo | null }) { const { dashboardState } = useDashboardStateContext() - const className = classNames(`${extraClass}`, { - 'hover:underline': !!filterInfo - }) if (filterInfo) { const { prefix, filter, labels } = filterInfo @@ -43,7 +44,7 @@ export function DrilldownLink({ return ( ({ @@ -52,10 +53,18 @@ export function DrilldownLink({ labels: newLabels })} > - {children} + {icon} + + {children} + ) } else { - return {children} + return ( + + {icon} + {children} + + ) } } diff --git a/assets/js/dashboard/stats/locations/flag-emoji.tsx b/assets/js/dashboard/stats/locations/flag-emoji.tsx index 2fa7f51024ab..314d725337bb 100644 --- a/assets/js/dashboard/stats/locations/flag-emoji.tsx +++ b/assets/js/dashboard/stats/locations/flag-emoji.tsx @@ -9,5 +9,5 @@ export const FlagEmoji = ({ countryCode }: { countryCode: string | null }) => { if (!entry?.flag) { return null } - return {entry.flag} + return {entry.flag} } diff --git a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx index 7189784fc3df..30a71007e27c 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx @@ -224,7 +224,7 @@ const NameCell = ({ path={rootRoute.path} filterInfo={getFilterInfo(item)} onClick={undefined} - extraClass={undefined} + className={undefined} > {item.name} diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 2a3e8f29556d..fa25fb2383ea 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -491,8 +491,8 @@ export const DimensionCell = ({ - {icon} {text} {externalLink} diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index ffc2881ed61e..7b74eb74fdd3 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -234,10 +234,11 @@ export const DimensionCellWithBar = ({ - {icon} - {text} + {text} {externalLink} diff --git a/assets/js/dashboard/stats/reports/list-legacy.tsx b/assets/js/dashboard/stats/reports/list-legacy.tsx index 43810bbdf4c2..3b913d84bc40 100644 --- a/assets/js/dashboard/stats/reports/list-legacy.tsx +++ b/assets/js/dashboard/stats/reports/list-legacy.tsx @@ -312,13 +312,11 @@ export default function ListReport< - {maybeRenderIconFor(listItem)} - - - {trimURL(listItem.name, colMinWidth)} - + {trimURL(listItem.name, colMinWidth)} Date: Tue, 2 Jun 2026 10:14:54 +0300 Subject: [PATCH 04/11] Write countries_meta prettified --- assets/data/countries_meta.json | 1007 +++++++++++++++++++++- lib/mix/tasks/generate_countries_meta.ex | 2 +- 2 files changed, 1007 insertions(+), 2 deletions(-) diff --git a/assets/data/countries_meta.json b/assets/data/countries_meta.json index 29c67793d2f9..b1729bf16807 100644 --- a/assets/data/countries_meta.json +++ b/assets/data/countries_meta.json @@ -1 +1,1006 @@ -{"YE":{"flag":"🇾🇪","alpha_3":"YEM"},"QA":{"flag":"🇶🇦","alpha_3":"QAT"},"MR":{"flag":"🇲🇷","alpha_3":"MRT"},"CH":{"flag":"🇨🇭","alpha_3":"CHE"},"AF":{"flag":"🇦🇫","alpha_3":"AFG"},"AO":{"flag":"🇦🇴","alpha_3":"AGO"},"BT":{"flag":"🇧🇹","alpha_3":"BTN"},"HK":{"flag":"🇭🇰","alpha_3":"HKG"},"CU":{"flag":"🇨🇺","alpha_3":"CUB"},"HU":{"flag":"🇭🇺","alpha_3":"HUN"},"AZ":{"flag":"🇦🇿","alpha_3":"AZE"},"SS":{"flag":"🇸🇸","alpha_3":"SSD"},"BY":{"flag":"🇧🇾","alpha_3":"BLR"},"MA":{"flag":"🇲🇦","alpha_3":"MAR"},"MZ":{"flag":"🇲🇿","alpha_3":"MOZ"},"TR":{"flag":"🇹🇷","alpha_3":"TUR"},"PL":{"flag":"🇵🇱","alpha_3":"POL"},"US":{"flag":"🇺🇸","alpha_3":"USA"},"SR":{"flag":"🇸🇷","alpha_3":"SUR"},"LR":{"flag":"🇱🇷","alpha_3":"LBR"},"OM":{"flag":"🇴🇲","alpha_3":"OMN"},"MH":{"flag":"🇲🇭","alpha_3":"MHL"},"TG":{"flag":"🇹🇬","alpha_3":"TGO"},"CW":{"flag":"🇨🇼","alpha_3":"CUW"},"AT":{"flag":"🇦🇹","alpha_3":"AUT"},"CO":{"flag":"🇨🇴","alpha_3":"COL"},"SV":{"flag":"🇸🇻","alpha_3":"SLV"},"RE":{"flag":"🇷🇪","alpha_3":"REU"},"LI":{"flag":"🇱🇮","alpha_3":"LIE"},"PR":{"flag":"🇵🇷","alpha_3":"PRI"},"GI":{"flag":"🇬🇮","alpha_3":"GIB"},"CV":{"flag":"🇨🇻","alpha_3":"CPV"},"KG":{"flag":"🇰🇬","alpha_3":"KGZ"},"SK":{"flag":"🇸🇰","alpha_3":"SVK"},"LT":{"flag":"🇱🇹","alpha_3":"LTU"},"AL":{"flag":"🇦🇱","alpha_3":"ALB"},"BL":{"flag":"🇧🇱","alpha_3":"BLM"},"FK":{"flag":"🇫🇰","alpha_3":"FLK"},"TV":{"flag":"🇹🇻","alpha_3":"TUV"},"SJ":{"flag":"🇸🇯","alpha_3":"SJM"},"CX":{"flag":"🇨🇽","alpha_3":"CXR"},"VG":{"flag":"🇻🇬","alpha_3":"VGB"},"VI":{"flag":"🇻🇮","alpha_3":"VIR"},"TT":{"flag":"🇹🇹","alpha_3":"TTO"},"AI":{"flag":"🇦🇮","alpha_3":"AIA"},"GE":{"flag":"🇬🇪","alpha_3":"GEO"},"GB":{"flag":"🇬🇧","alpha_3":"GBR"},"TW":{"flag":"🇹🇼","alpha_3":"TWN"},"BV":{"flag":"🇧🇻","alpha_3":"BVT"},"AU":{"flag":"🇦🇺","alpha_3":"AUS"},"CD":{"flag":"🇨🇩","alpha_3":"COD"},"IL":{"flag":"🇮🇱","alpha_3":"ISR"},"FJ":{"flag":"🇫🇯","alpha_3":"FJI"},"HM":{"flag":"🇭🇲","alpha_3":"HMD"},"MO":{"flag":"🇲🇴","alpha_3":"MAC"},"LU":{"flag":"🇱🇺","alpha_3":"LUX"},"TH":{"flag":"🇹🇭","alpha_3":"THA"},"TZ":{"flag":"🇹🇿","alpha_3":"TZA"},"MC":{"flag":"🇲🇨","alpha_3":"MCO"},"AD":{"flag":"🇦🇩","alpha_3":"AND"},"IQ":{"flag":"🇮🇶","alpha_3":"IRQ"},"NI":{"flag":"🇳🇮","alpha_3":"NIC"},"KW":{"flag":"🇰🇼","alpha_3":"KWT"},"MW":{"flag":"🇲🇼","alpha_3":"MWI"},"GH":{"flag":"🇬🇭","alpha_3":"GHA"},"DM":{"flag":"🇩🇲","alpha_3":"DMA"},"EE":{"flag":"🇪🇪","alpha_3":"EST"},"IS":{"flag":"🇮🇸","alpha_3":"ISL"},"BM":{"flag":"🇧🇲","alpha_3":"BMU"},"EC":{"flag":"🇪🇨","alpha_3":"ECU"},"KM":{"flag":"🇰🇲","alpha_3":"COM"},"SB":{"flag":"🇸🇧","alpha_3":"SLB"},"AE":{"flag":"🇦🇪","alpha_3":"ARE"},"CM":{"flag":"🇨🇲","alpha_3":"CMR"},"EH":{"flag":"🇪🇭","alpha_3":"ESH"},"CC":{"flag":"🇨🇨","alpha_3":"CCK"},"AS":{"flag":"🇦🇸","alpha_3":"ASM"},"KH":{"flag":"🇰🇭","alpha_3":"KHM"},"MD":{"flag":"🇲🇩","alpha_3":"MDA"},"NA":{"flag":"🇳🇦","alpha_3":"NAM"},"SA":{"flag":"🇸🇦","alpha_3":"SAU"},"HN":{"flag":"🇭🇳","alpha_3":"HND"},"MK":{"flag":"🇲🇰","alpha_3":"MKD"},"LC":{"flag":"🇱🇨","alpha_3":"LCA"},"PA":{"flag":"🇵🇦","alpha_3":"PAN"},"VC":{"flag":"🇻🇨","alpha_3":"VCT"},"TM":{"flag":"🇹🇲","alpha_3":"TKM"},"SI":{"flag":"🇸🇮","alpha_3":"SVN"},"GQ":{"flag":"🇬🇶","alpha_3":"GNQ"},"PH":{"flag":"🇵🇭","alpha_3":"PHL"},"CZ":{"flag":"🇨🇿","alpha_3":"CZE"},"BO":{"flag":"🇧🇴","alpha_3":"BOL"},"SY":{"flag":"🇸🇾","alpha_3":"SYR"},"NO":{"flag":"🇳🇴","alpha_3":"NOR"},"IM":{"flag":"🇮🇲","alpha_3":"IMN"},"SX":{"flag":"🇸🇽","alpha_3":"SXM"},"GG":{"flag":"🇬🇬","alpha_3":"GGY"},"GW":{"flag":"🇬🇼","alpha_3":"GNB"},"SH":{"flag":"🇸🇭","alpha_3":"SHN"},"NC":{"flag":"🇳🇨","alpha_3":"NCL"},"BE":{"flag":"🇧🇪","alpha_3":"BEL"},"JP":{"flag":"🇯🇵","alpha_3":"JPN"},"LV":{"flag":"🇱🇻","alpha_3":"LVA"},"AM":{"flag":"🇦🇲","alpha_3":"ARM"},"SD":{"flag":"🇸🇩","alpha_3":"SDN"},"GT":{"flag":"🇬🇹","alpha_3":"GTM"},"PY":{"flag":"🇵🇾","alpha_3":"PRY"},"MN":{"flag":"🇲🇳","alpha_3":"MNG"},"TK":{"flag":"🇹🇰","alpha_3":"TKL"},"DZ":{"flag":"🇩🇿","alpha_3":"DZA"},"KZ":{"flag":"🇰🇿","alpha_3":"KAZ"},"LY":{"flag":"🇱🇾","alpha_3":"LBY"},"AW":{"flag":"🇦🇼","alpha_3":"ABW"},"UY":{"flag":"🇺🇾","alpha_3":"URY"},"GL":{"flag":"🇬🇱","alpha_3":"GRL"},"SN":{"flag":"🇸🇳","alpha_3":"SEN"},"UM":{"flag":"🇺🇲","alpha_3":"UMI"},"JO":{"flag":"🇯🇴","alpha_3":"JOR"},"MT":{"flag":"🇲🇹","alpha_3":"MLT"},"BS":{"flag":"🇧🇸","alpha_3":"BHS"},"BI":{"flag":"🇧🇮","alpha_3":"BDI"},"BA":{"flag":"🇧🇦","alpha_3":"BIH"},"MQ":{"flag":"🇲🇶","alpha_3":"MTQ"},"MU":{"flag":"🇲🇺","alpha_3":"MUS"},"MS":{"flag":"🇲🇸","alpha_3":"MSR"},"BW":{"flag":"🇧🇼","alpha_3":"BWA"},"YT":{"flag":"🇾🇹","alpha_3":"MYT"},"PN":{"flag":"🇵🇳","alpha_3":"PCN"},"MP":{"flag":"🇲🇵","alpha_3":"MNP"},"ML":{"flag":"🇲🇱","alpha_3":"MLI"},"BH":{"flag":"🇧🇭","alpha_3":"BHR"},"LB":{"flag":"🇱🇧","alpha_3":"LBN"},"AR":{"flag":"🇦🇷","alpha_3":"ARG"},"PG":{"flag":"🇵🇬","alpha_3":"PNG"},"GR":{"flag":"🇬🇷","alpha_3":"GRC"},"HT":{"flag":"🇭🇹","alpha_3":"HTI"},"WS":{"flag":"🇼🇸","alpha_3":"WSM"},"SG":{"flag":"🇸🇬","alpha_3":"SGP"},"GP":{"flag":"🇬🇵","alpha_3":"GLP"},"BF":{"flag":"🇧🇫","alpha_3":"BFA"},"ME":{"flag":"🇲🇪","alpha_3":"MNE"},"AQ":{"flag":"🇦🇶","alpha_3":"ATA"},"PK":{"flag":"🇵🇰","alpha_3":"PAK"},"FM":{"flag":"🇫🇲","alpha_3":"FSM"},"MV":{"flag":"🇲🇻","alpha_3":"MDV"},"GS":{"flag":"🇬🇸","alpha_3":"SGS"},"BN":{"flag":"🇧🇳","alpha_3":"BRN"},"CK":{"flag":"🇨🇰","alpha_3":"COK"},"IO":{"flag":"🇮🇴","alpha_3":"IOT"},"SE":{"flag":"🇸🇪","alpha_3":"SWE"},"SC":{"flag":"🇸🇨","alpha_3":"SYC"},"ZW":{"flag":"🇿🇼","alpha_3":"ZWE"},"SL":{"flag":"🇸🇱","alpha_3":"SLE"},"AG":{"flag":"🇦🇬","alpha_3":"ATG"},"PF":{"flag":"🇵🇫","alpha_3":"PYF"},"CF":{"flag":"🇨🇫","alpha_3":"CAF"},"BD":{"flag":"🇧🇩","alpha_3":"BGD"},"AX":{"flag":"🇦🇽","alpha_3":"ALA"},"SZ":{"flag":"🇸🇿","alpha_3":"SWZ"},"HR":{"flag":"🇭🇷","alpha_3":"HRV"},"RS":{"flag":"🇷🇸","alpha_3":"SRB"},"NF":{"flag":"🇳🇫","alpha_3":"NFK"},"IE":{"flag":"🇮🇪","alpha_3":"IRL"},"NR":{"flag":"🇳🇷","alpha_3":"NRU"},"ZA":{"flag":"🇿🇦","alpha_3":"ZAF"},"CA":{"flag":"🇨🇦","alpha_3":"CAN"},"KR":{"flag":"🇰🇷","alpha_3":"KOR"},"A1":{"flag":"🏳️","alpha_3":null},"VA":{"flag":"🇻🇦","alpha_3":"VAT"},"NU":{"flag":"🇳🇺","alpha_3":"NIU"},"JE":{"flag":"🇯🇪","alpha_3":"JEY"},"TD":{"flag":"🇹🇩","alpha_3":"TCD"},"IR":{"flag":"🇮🇷","alpha_3":"IRN"},"NL":{"flag":"🇳🇱","alpha_3":"NLD"},"BB":{"flag":"🇧🇧","alpha_3":"BRB"},"FI":{"flag":"🇫🇮","alpha_3":"FIN"},"UA":{"flag":"🇺🇦","alpha_3":"UKR"},"ID":{"flag":"🇮🇩","alpha_3":"IDN"},"ST":{"flag":"🇸🇹","alpha_3":"STP"},"VU":{"flag":"🇻🇺","alpha_3":"VUT"},"RU":{"flag":"🇷🇺","alpha_3":"RUS"},"NE":{"flag":"🇳🇪","alpha_3":"NER"},"TO":{"flag":"🇹🇴","alpha_3":"TON"},"UZ":{"flag":"🇺🇿","alpha_3":"UZB"},"GN":{"flag":"🇬🇳","alpha_3":"GIN"},"JM":{"flag":"🇯🇲","alpha_3":"JAM"},"FR":{"flag":"🇫🇷","alpha_3":"FRA"},"TL":{"flag":"🇹🇱","alpha_3":"TLS"},"ET":{"flag":"🇪🇹","alpha_3":"ETH"},"KI":{"flag":"🇰🇮","alpha_3":"KIR"},"CG":{"flag":"🇨🇬","alpha_3":"COG"},"DE":{"flag":"🇩🇪","alpha_3":"DEU"},"RW":{"flag":"🇷🇼","alpha_3":"RWA"},"DO":{"flag":"🇩🇴","alpha_3":"DOM"},"VE":{"flag":"🇻🇪","alpha_3":"VEN"},"PW":{"flag":"🇵🇼","alpha_3":"PLW"},"TC":{"flag":"🇹🇨","alpha_3":"TCA"},"ZM":{"flag":"🇿🇲","alpha_3":"ZMB"},"NG":{"flag":"🇳🇬","alpha_3":"NGA"},"WF":{"flag":"🇼🇫","alpha_3":"WLF"},"GF":{"flag":"🇬🇫","alpha_3":"GUF"},"KN":{"flag":"🇰🇳","alpha_3":"KNA"},"ES":{"flag":"🇪🇸","alpha_3":"ESP"},"GM":{"flag":"🇬🇲","alpha_3":"GMB"},"KP":{"flag":"🇰🇵","alpha_3":"PRK"},"GY":{"flag":"🇬🇾","alpha_3":"GUY"},"MX":{"flag":"🇲🇽","alpha_3":"MEX"},"IN":{"flag":"🇮🇳","alpha_3":"IND"},"SM":{"flag":"🇸🇲","alpha_3":"SMR"},"BG":{"flag":"🇧🇬","alpha_3":"BGR"},"MF":{"flag":"🇲🇫","alpha_3":"MAF"},"CL":{"flag":"🇨🇱","alpha_3":"CHL"},"VN":{"flag":"🇻🇳","alpha_3":"VNM"},"NP":{"flag":"🇳🇵","alpha_3":"NPL"},"CR":{"flag":"🇨🇷","alpha_3":"CRI"},"ER":{"flag":"🇪🇷","alpha_3":"ERI"},"LK":{"flag":"🇱🇰","alpha_3":"LKA"},"CI":{"flag":"🇨🇮","alpha_3":"CIV"},"PT":{"flag":"🇵🇹","alpha_3":"PRT"},"TJ":{"flag":"🇹🇯","alpha_3":"TJK"},"MY":{"flag":"🇲🇾","alpha_3":"MYS"},"PS":{"flag":"🇵🇸","alpha_3":"PSE"},"PE":{"flag":"🇵🇪","alpha_3":"PER"},"LS":{"flag":"🇱🇸","alpha_3":"LSO"},"CY":{"flag":"🇨🇾","alpha_3":"CYP"},"TN":{"flag":"🇹🇳","alpha_3":"TUN"},"XK":{"flag":"🇽🇰","alpha_3":"XKX"},"KY":{"flag":"🇰🇾","alpha_3":"CYM"},"DK":{"flag":"🇩🇰","alpha_3":"DNK"},"BZ":{"flag":"🇧🇿","alpha_3":"BLZ"},"FO":{"flag":"🇫🇴","alpha_3":"FRO"},"LA":{"flag":"🇱🇦","alpha_3":"LAO"},"RO":{"flag":"🇷🇴","alpha_3":"ROU"},"DJ":{"flag":"🇩🇯","alpha_3":"DJI"},"EG":{"flag":"🇪🇬","alpha_3":"EGY"},"KE":{"flag":"🇰🇪","alpha_3":"KEN"},"UG":{"flag":"🇺🇬","alpha_3":"UGA"},"MM":{"flag":"🇲🇲","alpha_3":"MMR"},"BQ":{"flag":"🇧🇶","alpha_3":"BES"},"PM":{"flag":"🇵🇲","alpha_3":"SPM"},"GD":{"flag":"🇬🇩","alpha_3":"GRD"},"GU":{"flag":"🇬🇺","alpha_3":"GUM"},"CN":{"flag":"🇨🇳","alpha_3":"CHN"},"SO":{"flag":"🇸🇴","alpha_3":"SOM"},"BJ":{"flag":"🇧🇯","alpha_3":"BEN"},"BR":{"flag":"🇧🇷","alpha_3":"BRA"},"GA":{"flag":"🇬🇦","alpha_3":"GAB"},"NZ":{"flag":"🇳🇿","alpha_3":"NZL"},"MG":{"flag":"🇲🇬","alpha_3":"MDG"},"IT":{"flag":"🇮🇹","alpha_3":"ITA"},"TF":{"flag":"🇹🇫","alpha_3":"ATF"}} \ No newline at end of file +{ + "YE": { + "flag": "🇾🇪", + "alpha_3": "YEM" + }, + "QA": { + "flag": "🇶🇦", + "alpha_3": "QAT" + }, + "MR": { + "flag": "🇲🇷", + "alpha_3": "MRT" + }, + "CH": { + "flag": "🇨🇭", + "alpha_3": "CHE" + }, + "AF": { + "flag": "🇦🇫", + "alpha_3": "AFG" + }, + "AO": { + "flag": "🇦🇴", + "alpha_3": "AGO" + }, + "BT": { + "flag": "🇧🇹", + "alpha_3": "BTN" + }, + "HK": { + "flag": "🇭🇰", + "alpha_3": "HKG" + }, + "CU": { + "flag": "🇨🇺", + "alpha_3": "CUB" + }, + "HU": { + "flag": "🇭🇺", + "alpha_3": "HUN" + }, + "AZ": { + "flag": "🇦🇿", + "alpha_3": "AZE" + }, + "SS": { + "flag": "🇸🇸", + "alpha_3": "SSD" + }, + "BY": { + "flag": "🇧🇾", + "alpha_3": "BLR" + }, + "MA": { + "flag": "🇲🇦", + "alpha_3": "MAR" + }, + "MZ": { + "flag": "🇲🇿", + "alpha_3": "MOZ" + }, + "TR": { + "flag": "🇹🇷", + "alpha_3": "TUR" + }, + "PL": { + "flag": "🇵🇱", + "alpha_3": "POL" + }, + "US": { + "flag": "🇺🇸", + "alpha_3": "USA" + }, + "SR": { + "flag": "🇸🇷", + "alpha_3": "SUR" + }, + "LR": { + "flag": "🇱🇷", + "alpha_3": "LBR" + }, + "OM": { + "flag": "🇴🇲", + "alpha_3": "OMN" + }, + "MH": { + "flag": "🇲🇭", + "alpha_3": "MHL" + }, + "TG": { + "flag": "🇹🇬", + "alpha_3": "TGO" + }, + "CW": { + "flag": "🇨🇼", + "alpha_3": "CUW" + }, + "AT": { + "flag": "🇦🇹", + "alpha_3": "AUT" + }, + "CO": { + "flag": "🇨🇴", + "alpha_3": "COL" + }, + "SV": { + "flag": "🇸🇻", + "alpha_3": "SLV" + }, + "RE": { + "flag": "🇷🇪", + "alpha_3": "REU" + }, + "LI": { + "flag": "🇱🇮", + "alpha_3": "LIE" + }, + "PR": { + "flag": "🇵🇷", + "alpha_3": "PRI" + }, + "GI": { + "flag": "🇬🇮", + "alpha_3": "GIB" + }, + "CV": { + "flag": "🇨🇻", + "alpha_3": "CPV" + }, + "KG": { + "flag": "🇰🇬", + "alpha_3": "KGZ" + }, + "SK": { + "flag": "🇸🇰", + "alpha_3": "SVK" + }, + "LT": { + "flag": "🇱🇹", + "alpha_3": "LTU" + }, + "AL": { + "flag": "🇦🇱", + "alpha_3": "ALB" + }, + "BL": { + "flag": "🇧🇱", + "alpha_3": "BLM" + }, + "FK": { + "flag": "🇫🇰", + "alpha_3": "FLK" + }, + "TV": { + "flag": "🇹🇻", + "alpha_3": "TUV" + }, + "SJ": { + "flag": "🇸🇯", + "alpha_3": "SJM" + }, + "CX": { + "flag": "🇨🇽", + "alpha_3": "CXR" + }, + "VG": { + "flag": "🇻🇬", + "alpha_3": "VGB" + }, + "VI": { + "flag": "🇻🇮", + "alpha_3": "VIR" + }, + "TT": { + "flag": "🇹🇹", + "alpha_3": "TTO" + }, + "AI": { + "flag": "🇦🇮", + "alpha_3": "AIA" + }, + "GE": { + "flag": "🇬🇪", + "alpha_3": "GEO" + }, + "GB": { + "flag": "🇬🇧", + "alpha_3": "GBR" + }, + "TW": { + "flag": "🇹🇼", + "alpha_3": "TWN" + }, + "BV": { + "flag": "🇧🇻", + "alpha_3": "BVT" + }, + "AU": { + "flag": "🇦🇺", + "alpha_3": "AUS" + }, + "CD": { + "flag": "🇨🇩", + "alpha_3": "COD" + }, + "IL": { + "flag": "🇮🇱", + "alpha_3": "ISR" + }, + "FJ": { + "flag": "🇫🇯", + "alpha_3": "FJI" + }, + "HM": { + "flag": "🇭🇲", + "alpha_3": "HMD" + }, + "MO": { + "flag": "🇲🇴", + "alpha_3": "MAC" + }, + "LU": { + "flag": "🇱🇺", + "alpha_3": "LUX" + }, + "TH": { + "flag": "🇹🇭", + "alpha_3": "THA" + }, + "TZ": { + "flag": "🇹🇿", + "alpha_3": "TZA" + }, + "MC": { + "flag": "🇲🇨", + "alpha_3": "MCO" + }, + "AD": { + "flag": "🇦🇩", + "alpha_3": "AND" + }, + "IQ": { + "flag": "🇮🇶", + "alpha_3": "IRQ" + }, + "NI": { + "flag": "🇳🇮", + "alpha_3": "NIC" + }, + "KW": { + "flag": "🇰🇼", + "alpha_3": "KWT" + }, + "MW": { + "flag": "🇲🇼", + "alpha_3": "MWI" + }, + "GH": { + "flag": "🇬🇭", + "alpha_3": "GHA" + }, + "DM": { + "flag": "🇩🇲", + "alpha_3": "DMA" + }, + "EE": { + "flag": "🇪🇪", + "alpha_3": "EST" + }, + "IS": { + "flag": "🇮🇸", + "alpha_3": "ISL" + }, + "BM": { + "flag": "🇧🇲", + "alpha_3": "BMU" + }, + "EC": { + "flag": "🇪🇨", + "alpha_3": "ECU" + }, + "KM": { + "flag": "🇰🇲", + "alpha_3": "COM" + }, + "SB": { + "flag": "🇸🇧", + "alpha_3": "SLB" + }, + "AE": { + "flag": "🇦🇪", + "alpha_3": "ARE" + }, + "CM": { + "flag": "🇨🇲", + "alpha_3": "CMR" + }, + "EH": { + "flag": "🇪🇭", + "alpha_3": "ESH" + }, + "CC": { + "flag": "🇨🇨", + "alpha_3": "CCK" + }, + "AS": { + "flag": "🇦🇸", + "alpha_3": "ASM" + }, + "KH": { + "flag": "🇰🇭", + "alpha_3": "KHM" + }, + "MD": { + "flag": "🇲🇩", + "alpha_3": "MDA" + }, + "NA": { + "flag": "🇳🇦", + "alpha_3": "NAM" + }, + "SA": { + "flag": "🇸🇦", + "alpha_3": "SAU" + }, + "HN": { + "flag": "🇭🇳", + "alpha_3": "HND" + }, + "MK": { + "flag": "🇲🇰", + "alpha_3": "MKD" + }, + "LC": { + "flag": "🇱🇨", + "alpha_3": "LCA" + }, + "PA": { + "flag": "🇵🇦", + "alpha_3": "PAN" + }, + "VC": { + "flag": "🇻🇨", + "alpha_3": "VCT" + }, + "TM": { + "flag": "🇹🇲", + "alpha_3": "TKM" + }, + "SI": { + "flag": "🇸🇮", + "alpha_3": "SVN" + }, + "GQ": { + "flag": "🇬🇶", + "alpha_3": "GNQ" + }, + "PH": { + "flag": "🇵🇭", + "alpha_3": "PHL" + }, + "CZ": { + "flag": "🇨🇿", + "alpha_3": "CZE" + }, + "BO": { + "flag": "🇧🇴", + "alpha_3": "BOL" + }, + "SY": { + "flag": "🇸🇾", + "alpha_3": "SYR" + }, + "NO": { + "flag": "🇳🇴", + "alpha_3": "NOR" + }, + "IM": { + "flag": "🇮🇲", + "alpha_3": "IMN" + }, + "SX": { + "flag": "🇸🇽", + "alpha_3": "SXM" + }, + "GG": { + "flag": "🇬🇬", + "alpha_3": "GGY" + }, + "GW": { + "flag": "🇬🇼", + "alpha_3": "GNB" + }, + "SH": { + "flag": "🇸🇭", + "alpha_3": "SHN" + }, + "NC": { + "flag": "🇳🇨", + "alpha_3": "NCL" + }, + "BE": { + "flag": "🇧🇪", + "alpha_3": "BEL" + }, + "JP": { + "flag": "🇯🇵", + "alpha_3": "JPN" + }, + "LV": { + "flag": "🇱🇻", + "alpha_3": "LVA" + }, + "AM": { + "flag": "🇦🇲", + "alpha_3": "ARM" + }, + "SD": { + "flag": "🇸🇩", + "alpha_3": "SDN" + }, + "GT": { + "flag": "🇬🇹", + "alpha_3": "GTM" + }, + "PY": { + "flag": "🇵🇾", + "alpha_3": "PRY" + }, + "MN": { + "flag": "🇲🇳", + "alpha_3": "MNG" + }, + "TK": { + "flag": "🇹🇰", + "alpha_3": "TKL" + }, + "DZ": { + "flag": "🇩🇿", + "alpha_3": "DZA" + }, + "KZ": { + "flag": "🇰🇿", + "alpha_3": "KAZ" + }, + "LY": { + "flag": "🇱🇾", + "alpha_3": "LBY" + }, + "AW": { + "flag": "🇦🇼", + "alpha_3": "ABW" + }, + "UY": { + "flag": "🇺🇾", + "alpha_3": "URY" + }, + "GL": { + "flag": "🇬🇱", + "alpha_3": "GRL" + }, + "SN": { + "flag": "🇸🇳", + "alpha_3": "SEN" + }, + "UM": { + "flag": "🇺🇲", + "alpha_3": "UMI" + }, + "JO": { + "flag": "🇯🇴", + "alpha_3": "JOR" + }, + "MT": { + "flag": "🇲🇹", + "alpha_3": "MLT" + }, + "BS": { + "flag": "🇧🇸", + "alpha_3": "BHS" + }, + "BI": { + "flag": "🇧🇮", + "alpha_3": "BDI" + }, + "BA": { + "flag": "🇧🇦", + "alpha_3": "BIH" + }, + "MQ": { + "flag": "🇲🇶", + "alpha_3": "MTQ" + }, + "MU": { + "flag": "🇲🇺", + "alpha_3": "MUS" + }, + "MS": { + "flag": "🇲🇸", + "alpha_3": "MSR" + }, + "BW": { + "flag": "🇧🇼", + "alpha_3": "BWA" + }, + "YT": { + "flag": "🇾🇹", + "alpha_3": "MYT" + }, + "PN": { + "flag": "🇵🇳", + "alpha_3": "PCN" + }, + "MP": { + "flag": "🇲🇵", + "alpha_3": "MNP" + }, + "ML": { + "flag": "🇲🇱", + "alpha_3": "MLI" + }, + "BH": { + "flag": "🇧🇭", + "alpha_3": "BHR" + }, + "LB": { + "flag": "🇱🇧", + "alpha_3": "LBN" + }, + "AR": { + "flag": "🇦🇷", + "alpha_3": "ARG" + }, + "PG": { + "flag": "🇵🇬", + "alpha_3": "PNG" + }, + "GR": { + "flag": "🇬🇷", + "alpha_3": "GRC" + }, + "HT": { + "flag": "🇭🇹", + "alpha_3": "HTI" + }, + "WS": { + "flag": "🇼🇸", + "alpha_3": "WSM" + }, + "SG": { + "flag": "🇸🇬", + "alpha_3": "SGP" + }, + "GP": { + "flag": "🇬🇵", + "alpha_3": "GLP" + }, + "BF": { + "flag": "🇧🇫", + "alpha_3": "BFA" + }, + "ME": { + "flag": "🇲🇪", + "alpha_3": "MNE" + }, + "AQ": { + "flag": "🇦🇶", + "alpha_3": "ATA" + }, + "PK": { + "flag": "🇵🇰", + "alpha_3": "PAK" + }, + "FM": { + "flag": "🇫🇲", + "alpha_3": "FSM" + }, + "MV": { + "flag": "🇲🇻", + "alpha_3": "MDV" + }, + "GS": { + "flag": "🇬🇸", + "alpha_3": "SGS" + }, + "BN": { + "flag": "🇧🇳", + "alpha_3": "BRN" + }, + "CK": { + "flag": "🇨🇰", + "alpha_3": "COK" + }, + "IO": { + "flag": "🇮🇴", + "alpha_3": "IOT" + }, + "SE": { + "flag": "🇸🇪", + "alpha_3": "SWE" + }, + "SC": { + "flag": "🇸🇨", + "alpha_3": "SYC" + }, + "ZW": { + "flag": "🇿🇼", + "alpha_3": "ZWE" + }, + "SL": { + "flag": "🇸🇱", + "alpha_3": "SLE" + }, + "AG": { + "flag": "🇦🇬", + "alpha_3": "ATG" + }, + "PF": { + "flag": "🇵🇫", + "alpha_3": "PYF" + }, + "CF": { + "flag": "🇨🇫", + "alpha_3": "CAF" + }, + "BD": { + "flag": "🇧🇩", + "alpha_3": "BGD" + }, + "AX": { + "flag": "🇦🇽", + "alpha_3": "ALA" + }, + "SZ": { + "flag": "🇸🇿", + "alpha_3": "SWZ" + }, + "HR": { + "flag": "🇭🇷", + "alpha_3": "HRV" + }, + "RS": { + "flag": "🇷🇸", + "alpha_3": "SRB" + }, + "NF": { + "flag": "🇳🇫", + "alpha_3": "NFK" + }, + "IE": { + "flag": "🇮🇪", + "alpha_3": "IRL" + }, + "NR": { + "flag": "🇳🇷", + "alpha_3": "NRU" + }, + "ZA": { + "flag": "🇿🇦", + "alpha_3": "ZAF" + }, + "CA": { + "flag": "🇨🇦", + "alpha_3": "CAN" + }, + "KR": { + "flag": "🇰🇷", + "alpha_3": "KOR" + }, + "A1": { + "flag": "🏳️", + "alpha_3": null + }, + "VA": { + "flag": "🇻🇦", + "alpha_3": "VAT" + }, + "NU": { + "flag": "🇳🇺", + "alpha_3": "NIU" + }, + "JE": { + "flag": "🇯🇪", + "alpha_3": "JEY" + }, + "TD": { + "flag": "🇹🇩", + "alpha_3": "TCD" + }, + "IR": { + "flag": "🇮🇷", + "alpha_3": "IRN" + }, + "NL": { + "flag": "🇳🇱", + "alpha_3": "NLD" + }, + "BB": { + "flag": "🇧🇧", + "alpha_3": "BRB" + }, + "FI": { + "flag": "🇫🇮", + "alpha_3": "FIN" + }, + "UA": { + "flag": "🇺🇦", + "alpha_3": "UKR" + }, + "ID": { + "flag": "🇮🇩", + "alpha_3": "IDN" + }, + "ST": { + "flag": "🇸🇹", + "alpha_3": "STP" + }, + "VU": { + "flag": "🇻🇺", + "alpha_3": "VUT" + }, + "RU": { + "flag": "🇷🇺", + "alpha_3": "RUS" + }, + "NE": { + "flag": "🇳🇪", + "alpha_3": "NER" + }, + "TO": { + "flag": "🇹🇴", + "alpha_3": "TON" + }, + "UZ": { + "flag": "🇺🇿", + "alpha_3": "UZB" + }, + "GN": { + "flag": "🇬🇳", + "alpha_3": "GIN" + }, + "JM": { + "flag": "🇯🇲", + "alpha_3": "JAM" + }, + "FR": { + "flag": "🇫🇷", + "alpha_3": "FRA" + }, + "TL": { + "flag": "🇹🇱", + "alpha_3": "TLS" + }, + "ET": { + "flag": "🇪🇹", + "alpha_3": "ETH" + }, + "KI": { + "flag": "🇰🇮", + "alpha_3": "KIR" + }, + "CG": { + "flag": "🇨🇬", + "alpha_3": "COG" + }, + "DE": { + "flag": "🇩🇪", + "alpha_3": "DEU" + }, + "RW": { + "flag": "🇷🇼", + "alpha_3": "RWA" + }, + "DO": { + "flag": "🇩🇴", + "alpha_3": "DOM" + }, + "VE": { + "flag": "🇻🇪", + "alpha_3": "VEN" + }, + "PW": { + "flag": "🇵🇼", + "alpha_3": "PLW" + }, + "TC": { + "flag": "🇹🇨", + "alpha_3": "TCA" + }, + "ZM": { + "flag": "🇿🇲", + "alpha_3": "ZMB" + }, + "NG": { + "flag": "🇳🇬", + "alpha_3": "NGA" + }, + "WF": { + "flag": "🇼🇫", + "alpha_3": "WLF" + }, + "GF": { + "flag": "🇬🇫", + "alpha_3": "GUF" + }, + "KN": { + "flag": "🇰🇳", + "alpha_3": "KNA" + }, + "ES": { + "flag": "🇪🇸", + "alpha_3": "ESP" + }, + "GM": { + "flag": "🇬🇲", + "alpha_3": "GMB" + }, + "KP": { + "flag": "🇰🇵", + "alpha_3": "PRK" + }, + "GY": { + "flag": "🇬🇾", + "alpha_3": "GUY" + }, + "MX": { + "flag": "🇲🇽", + "alpha_3": "MEX" + }, + "IN": { + "flag": "🇮🇳", + "alpha_3": "IND" + }, + "SM": { + "flag": "🇸🇲", + "alpha_3": "SMR" + }, + "BG": { + "flag": "🇧🇬", + "alpha_3": "BGR" + }, + "MF": { + "flag": "🇲🇫", + "alpha_3": "MAF" + }, + "CL": { + "flag": "🇨🇱", + "alpha_3": "CHL" + }, + "VN": { + "flag": "🇻🇳", + "alpha_3": "VNM" + }, + "NP": { + "flag": "🇳🇵", + "alpha_3": "NPL" + }, + "CR": { + "flag": "🇨🇷", + "alpha_3": "CRI" + }, + "ER": { + "flag": "🇪🇷", + "alpha_3": "ERI" + }, + "LK": { + "flag": "🇱🇰", + "alpha_3": "LKA" + }, + "CI": { + "flag": "🇨🇮", + "alpha_3": "CIV" + }, + "PT": { + "flag": "🇵🇹", + "alpha_3": "PRT" + }, + "TJ": { + "flag": "🇹🇯", + "alpha_3": "TJK" + }, + "MY": { + "flag": "🇲🇾", + "alpha_3": "MYS" + }, + "PS": { + "flag": "🇵🇸", + "alpha_3": "PSE" + }, + "PE": { + "flag": "🇵🇪", + "alpha_3": "PER" + }, + "LS": { + "flag": "🇱🇸", + "alpha_3": "LSO" + }, + "CY": { + "flag": "🇨🇾", + "alpha_3": "CYP" + }, + "TN": { + "flag": "🇹🇳", + "alpha_3": "TUN" + }, + "XK": { + "flag": "🇽🇰", + "alpha_3": "XKX" + }, + "KY": { + "flag": "🇰🇾", + "alpha_3": "CYM" + }, + "DK": { + "flag": "🇩🇰", + "alpha_3": "DNK" + }, + "BZ": { + "flag": "🇧🇿", + "alpha_3": "BLZ" + }, + "FO": { + "flag": "🇫🇴", + "alpha_3": "FRO" + }, + "LA": { + "flag": "🇱🇦", + "alpha_3": "LAO" + }, + "RO": { + "flag": "🇷🇴", + "alpha_3": "ROU" + }, + "DJ": { + "flag": "🇩🇯", + "alpha_3": "DJI" + }, + "EG": { + "flag": "🇪🇬", + "alpha_3": "EGY" + }, + "KE": { + "flag": "🇰🇪", + "alpha_3": "KEN" + }, + "UG": { + "flag": "🇺🇬", + "alpha_3": "UGA" + }, + "MM": { + "flag": "🇲🇲", + "alpha_3": "MMR" + }, + "BQ": { + "flag": "🇧🇶", + "alpha_3": "BES" + }, + "PM": { + "flag": "🇵🇲", + "alpha_3": "SPM" + }, + "GD": { + "flag": "🇬🇩", + "alpha_3": "GRD" + }, + "GU": { + "flag": "🇬🇺", + "alpha_3": "GUM" + }, + "CN": { + "flag": "🇨🇳", + "alpha_3": "CHN" + }, + "SO": { + "flag": "🇸🇴", + "alpha_3": "SOM" + }, + "BJ": { + "flag": "🇧🇯", + "alpha_3": "BEN" + }, + "BR": { + "flag": "🇧🇷", + "alpha_3": "BRA" + }, + "GA": { + "flag": "🇬🇦", + "alpha_3": "GAB" + }, + "NZ": { + "flag": "🇳🇿", + "alpha_3": "NZL" + }, + "MG": { + "flag": "🇲🇬", + "alpha_3": "MDG" + }, + "IT": { + "flag": "🇮🇹", + "alpha_3": "ITA" + }, + "TF": { + "flag": "🇹🇫", + "alpha_3": "ATF" + } +} \ No newline at end of file diff --git a/lib/mix/tasks/generate_countries_meta.ex b/lib/mix/tasks/generate_countries_meta.ex index 34feee6ceef9..f6dbdcf82979 100644 --- a/lib/mix/tasks/generate_countries_meta.ex +++ b/lib/mix/tasks/generate_countries_meta.ex @@ -31,7 +31,7 @@ defmodule Mix.Tasks.GenerateCountriesMeta do } -> {code, %{alpha_3: alpha_3, flag: flag}} end) - |> Jason.encode!() + |> Jason.encode!(pretty: true) File.write!(@output_path, json) Mix.shell().info("Wrote #{byte_size(json)} bytes to #{@output_path}") From 971ad318c7cc383831ac21b6234ea5f8336a2fa3 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 12:45:51 +0300 Subject: [PATCH 05/11] Add search to locations modals --- assets/js/dashboard/stats-query.ts | 6 ++- assets/js/dashboard/stats/breakdowns.tsx | 36 +---------------- assets/js/dashboard/stats/devices/details.tsx | 1 + assets/js/dashboard/stats/devices/index.tsx | 1 + .../js/dashboard/stats/locations/details.tsx | 8 ++-- assets/js/dashboard/stats/locations/index.tsx | 13 ++++--- assets/js/dashboard/stats/locations/map.tsx | 33 ++++++++-------- .../stats/modals/details-breakdown.tsx | 9 ++--- assets/js/dashboard/stats/pages/details.tsx | 1 + assets/js/dashboard/stats/pages/index.tsx | 1 + .../stats/reports/index-breakdown.tsx | 5 ++- .../dashboard/stats/reports/reports-config.ts | 39 ++++++++++++------- assets/js/dashboard/stats/sources/details.tsx | 1 + assets/js/dashboard/stats/sources/index.tsx | 1 + 14 files changed, 72 insertions(+), 83 deletions(-) diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 1a0592bda5a0..2e49f3b1f71e 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -55,6 +55,7 @@ export type ReportParams = { include?: Partial order_by?: OrderBy pagination?: Pagination + alwaysOnFilters?: ApiFilter[] } export type StatsQuery = { @@ -84,7 +85,10 @@ export function createStatsQuery( relative_date: dashboardState.date ? formatISO(dashboardState.date) : null, dimensions: reportParams.dimensions || [], metrics: reportParams.metrics, - filters: remapToApiFilters(dashboardState.filters), + filters: [ + ...remapToApiFilters(dashboardState.filters), + ...(reportParams.alwaysOnFilters ?? []) + ], order_by: reportParams.order_by || null, pagination: reportParams.pagination || null, include: { diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index d8128121b03d..249d7785f7e3 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -14,10 +14,6 @@ import { } from '../stats-query' import { Filter } from '../dashboard-state' import { MetricsByContext } from './reports/reports-config' -import { - defaultGetStatsQuery, - StatsReportQueryKey -} from '../hooks/use-query-api' import classNames from 'classnames' import { DIRECT_NONE } from './sources' @@ -25,6 +21,7 @@ export type SharedBreakdownReportProps = { dimensionLabel: string dimensions: NonTimeDimension[] metrics: Metric[] + alwaysOnFilters?: ApiFilter[] } export type ColumnConfiguration = { @@ -43,37 +40,6 @@ export type ColumnConfiguration = { align?: 'left' | 'right' } -const FILTER_DIMENSIONS_NOT = { - 'visit:city': [0], - 'visit:country': ['\0\0', 'ZZ'], - 'visit:region': [''], - 'visit:utm_medium': [''], - 'visit:utm_source': [''], - 'visit:utm_campaign': [''], - 'visit:utm_content': [''], - 'visit:utm_term': [''], - 'visit:entry_page': [''], - 'visit:exit_page': [''] -} - -export function getStatsQueryWithImplicitNotEmptyFilter( - queryKey: StatsReportQueryKey -) { - let statsQuery = defaultGetStatsQuery(queryKey) - - const dimension = queryKey[1].reportParams.dimensions[0] - - if (Object.keys(FILTER_DIMENSIONS_NOT).includes(dimension)) { - statsQuery = addFilter(statsQuery, [ - 'is_not', - dimension, - FILTER_DIMENSIONS_NOT[dimension as keyof typeof FILTER_DIMENSIONS_NOT] - ]) - } - - return statsQuery -} - export type GetFilterInfo = ( dimension: NonTimeDimension, row: QueryResultRow diff --git a/assets/js/dashboard/stats/devices/details.tsx b/assets/js/dashboard/stats/devices/details.tsx index 6eb91a21e4c1..6dbd7c739f68 100644 --- a/assets/js/dashboard/stats/devices/details.tsx +++ b/assets/js/dashboard/stats/devices/details.tsx @@ -64,6 +64,7 @@ export function DevicesDetails({ dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} searchEnabled={searchEnabled} DimensionElement={DimensionElement} diff --git a/assets/js/dashboard/stats/devices/index.tsx b/assets/js/dashboard/stats/devices/index.tsx index 876c460ad5d0..7cc262c174f0 100644 --- a/assets/js/dashboard/stats/devices/index.tsx +++ b/assets/js/dashboard/stats/devices/index.tsx @@ -112,6 +112,7 @@ export function Devices() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={DimensionElement} onDataReady={setCurrentData} /> diff --git a/assets/js/dashboard/stats/locations/details.tsx b/assets/js/dashboard/stats/locations/details.tsx index 8f3255e3d8df..487d68ebc54a 100644 --- a/assets/js/dashboard/stats/locations/details.tsx +++ b/assets/js/dashboard/stats/locations/details.tsx @@ -50,8 +50,8 @@ export function LocationsDetails({ dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} - searchEnabled={false} DimensionElement={DimensionElement} /> @@ -59,7 +59,7 @@ export function LocationsDetails({ } const CountryDimensionCell = (props: DimensionCellProps) => { - const [countryCode, countryName] = props.row.dimensions + const [countryName, countryCode] = props.row.dimensions return ( { } const RegionsDimensionCell = (props: DimensionCellProps) => { - const [_regionCode, regionName, countryCode] = props.row.dimensions + const [regionName, _regionCode, countryCode] = props.row.dimensions return ( { } const CitiesDimensionCell = (props: DimensionCellProps) => { - const [_cityCode, cityName, countryCode] = props.row.dimensions + const [cityName, _cityCode, countryCode] = props.row.dimensions return ( @@ -234,7 +235,7 @@ export function Locations() { const CountriesDimensionCell = ( props: DimensionCellWithBarProps & { onClick: () => void } ) => { - const [countryCode, countryName] = props.row.dimensions + const [countryName, countryCode] = props.row.dimensions return ( void } ) => { - const [_regionCode, regionName, countryCode] = props.row.dimensions + const [regionName, _regionCode, countryCode] = props.row.dimensions return ( { - const [_cityCode, cityName, countryCode] = props.row.dimensions + const [cityName, _cityCode, countryCode] = props.row.dimensions return ( { - const [countryCode, countryName] = row.dimensions + const [countryName, countryCode] = row.dimensions return { prefix: 'country', @@ -291,7 +292,7 @@ export const getRegionsFilterInfo = ( _dimension: NonTimeDimension, row: QueryResultRow ): FilterInfo => { - const [regionCode, regionName, _countryCode] = row.dimensions + const [regionName, regionCode, _countryCode] = row.dimensions return { prefix: 'region', @@ -304,7 +305,7 @@ export const getCitiesFilterInfo = ( _dimension: NonTimeDimension, row: QueryResultRow ): FilterInfo => { - const [cityCode, cityName, _countryCode] = row.dimensions + const [cityName, cityCode, _countryCode] = row.dimensions return { prefix: 'city', diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 05e4df1fad39..9b2169441c18 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -23,7 +23,10 @@ import { parseWorldTopoJsonToGeoJsonFeatures, WorldJsonCountryData } from './countries' -import { getStatsQueryWithImplicitNotEmptyFilter } from '../breakdowns' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' const width = 475 const height = 335 @@ -68,22 +71,20 @@ const WorldMap = ({ [dashboardState] ) - const { apiState } = useQueryApi( - site, - [ - 'visit:country', - { - dashboardState, - reportParams: { - metrics: ['visitors'], - dimensions: ['visit:country', 'visit:country_name'], - order_by: [['visitors', 'desc']], - pagination: { limit: 300, offset: 0 } - } + const { apiState } = useQueryApi(site, [ + 'visit:country', + { + dashboardState, + reportParams: { + metrics: ['visitors'], + dimensions: BREAKDOWN_REPORTS[BreakdownReportKey.countries].dimensions, + alwaysOnFilters: + BREAKDOWN_REPORTS[BreakdownReportKey.countries].alwaysOnFilters, + order_by: [['visitors', 'desc']], + pagination: { limit: 300, offset: 0 } } - ], - { getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter } - ) + } + ]) const { data, isFetching, isError } = apiState useEffect(() => { diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index fa25fb2383ea..b0da56466572 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -30,7 +30,6 @@ import { formatDateRangeLabel, useBodyPortalRef, extractMetricValue, - getStatsQueryWithImplicitNotEmptyFilter, GetFilterInfo } from '../breakdowns' import { @@ -87,6 +86,7 @@ export function DetailsBreakdown({ dimensionLabel, dimensions, metrics, + alwaysOnFilters, defaultOrderBy = [] as MetricOrderBy, DimensionElement, searchEnabled = true, @@ -124,15 +124,14 @@ export function DetailsBreakdown({ order_by: [ ...(orderBy.length ? orderBy : storedOrderBy), ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) - ] + ], + alwaysOnFilters }, search } ] - const apiState = useSearchAndPaginateQueryAPI(site, statsReportQueryKey, { - getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter - }) + const apiState = useSearchAndPaginateQueryAPI(site, statsReportQueryKey) useEffect(() => { const pages = apiState.data?.pages diff --git a/assets/js/dashboard/stats/pages/details.tsx b/assets/js/dashboard/stats/pages/details.tsx index d68d562c09d9..0616043edf17 100644 --- a/assets/js/dashboard/stats/pages/details.tsx +++ b/assets/js/dashboard/stats/pages/details.tsx @@ -56,6 +56,7 @@ export function PagesDetails({ dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} DimensionElement={PagesDimensionElement} /> diff --git a/assets/js/dashboard/stats/pages/index.tsx b/assets/js/dashboard/stats/pages/index.tsx index 04b78e6d5bf4..980a2f394947 100644 --- a/assets/js/dashboard/stats/pages/index.tsx +++ b/assets/js/dashboard/stats/pages/index.tsx @@ -72,6 +72,7 @@ export default function Pages() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={PagesDimensionCell} onDataReady={setCurrentData} /> diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index 7b74eb74fdd3..0d5700becc8a 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -18,7 +18,6 @@ import { formatDateRangeLabel, useBodyPortalRef, extractMetricValue, - getStatsQueryWithImplicitNotEmptyFilter, MetricValueWrapper, GetFilterInfo } from '../breakdowns' @@ -61,6 +60,7 @@ export function IndexBreakdown({ dimensions, DimensionElement, dimensionLabel, + alwaysOnFilters, onDataReady, metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH }: IndexBreakdownProps) { @@ -79,6 +79,7 @@ export function IndexBreakdown({ ['visitors', 'desc'], ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) ], + alwaysOnFilters, pagination: { limit: MAX_ITEMS, offset: 0 } } } @@ -87,7 +88,7 @@ export function IndexBreakdown({ const { apiState, isRealtimeSilentUpdate } = useQueryApi( site, statsReportQueryKey, - { enabled: visible, getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter } + { enabled: visible } ) useEffect(() => { diff --git a/assets/js/dashboard/stats/reports/reports-config.ts b/assets/js/dashboard/stats/reports/reports-config.ts index c27b01237e16..3d37e6ad3b17 100644 --- a/assets/js/dashboard/stats/reports/reports-config.ts +++ b/assets/js/dashboard/stats/reports/reports-config.ts @@ -1,4 +1,4 @@ -import { NonTimeDimension } from '../../stats-query' +import { ApiFilter, NonTimeDimension } from '../../stats-query' import { Metric } from '../metrics' export type MetricsByContext = { @@ -15,6 +15,7 @@ export type BreakdownReportConfig = { detailsTitle: string detailsPath: string dimensionLabel: string + alwaysOnFilters?: ApiFilter[] } const COMMON_METRICS_BY_CONTEXT: MetricsByContext = { @@ -91,7 +92,8 @@ export const BREAKDOWN_REPORTS: Record< }, detailsTitle: 'Entry pages', detailsPath: 'entry-pages', - dimensionLabel: 'Entry page' + dimensionLabel: 'Entry page', + alwaysOnFilters: [['is_not', 'visit:entry_page', ['']]] }, [BreakdownReportKey.exitPages]: { dimensions: ['visit:exit_page'], @@ -101,7 +103,8 @@ export const BREAKDOWN_REPORTS: Record< }, detailsTitle: 'Exit pages', detailsPath: 'exit-pages', - dimensionLabel: 'Exit page' + dimensionLabel: 'Exit page', + alwaysOnFilters: [['is_not', 'visit:exit_page', ['']]] }, [BreakdownReportKey.browsers]: { dimensions: ['visit:browser'], @@ -164,66 +167,74 @@ export const BREAKDOWN_REPORTS: Record< metricsByContext: COMMON_METRICS_BY_CONTEXT, detailsTitle: 'UTM mediums', detailsPath: 'utm_mediums', - dimensionLabel: 'UTM medium' + dimensionLabel: 'UTM medium', + alwaysOnFilters: [['is_not', 'visit:utm_medium', ['']]] }, [BreakdownReportKey.utmSources]: { dimensions: ['visit:utm_source'], metricsByContext: COMMON_METRICS_BY_CONTEXT, detailsTitle: 'UTM sources', detailsPath: 'utm_sources', - dimensionLabel: 'UTM source' + dimensionLabel: 'UTM source', + alwaysOnFilters: [['is_not', 'visit:utm_source', ['']]] }, [BreakdownReportKey.utmCampaigns]: { dimensions: ['visit:utm_campaign'], metricsByContext: COMMON_METRICS_BY_CONTEXT, detailsTitle: 'UTM campaigns', detailsPath: 'utm_campaigns', - dimensionLabel: 'UTM campaign' + dimensionLabel: 'UTM campaign', + alwaysOnFilters: [['is_not', 'visit:utm_campaign', ['']]] }, [BreakdownReportKey.utmContents]: { dimensions: ['visit:utm_content'], metricsByContext: COMMON_METRICS_BY_CONTEXT, detailsTitle: 'UTM contents', detailsPath: 'utm_contents', - dimensionLabel: 'UTM content' + dimensionLabel: 'UTM content', + alwaysOnFilters: [['is_not', 'visit:utm_content', ['']]] }, [BreakdownReportKey.utmTerms]: { dimensions: ['visit:utm_term'], metricsByContext: COMMON_METRICS_BY_CONTEXT, detailsTitle: 'UTM terms', detailsPath: 'utm_terms', - dimensionLabel: 'UTM term' + dimensionLabel: 'UTM term', + alwaysOnFilters: [['is_not', 'visit:utm_term', ['']]] }, [BreakdownReportKey.countries]: { - dimensions: ['visit:country', 'visit:country_name'], + dimensions: ['visit:country_name', 'visit:country'], metricsByContext: { ...COMMON_METRICS_BY_CONTEXT, defaultDetailedMetrics: ['visitors', 'percentage'] }, detailsTitle: 'Top countries', detailsPath: 'countries', - dimensionLabel: 'Country' + dimensionLabel: 'Country', + alwaysOnFilters: [['is_not', 'visit:country', ['\0\0', 'ZZ']]] }, [BreakdownReportKey.regions]: { // the 3rd dimension "visit:country" is needed to render the country flag - dimensions: ['visit:region', 'visit:region_name', 'visit:country'], + dimensions: ['visit:region_name', 'visit:region', 'visit:country'], metricsByContext: { ...COMMON_METRICS_BY_CONTEXT, defaultDetailedMetrics: ['visitors', 'percentage'] }, detailsTitle: 'Top regions', detailsPath: 'regions', - dimensionLabel: 'Region' + dimensionLabel: 'Region', + alwaysOnFilters: [['is_not', 'visit:region', ['']]] }, [BreakdownReportKey.cities]: { // the 3rd dimension "visit:country" is needed to render the country flag - dimensions: ['visit:city', 'visit:city_name', 'visit:country'], + dimensions: ['visit:city_name', 'visit:city', 'visit:country'], metricsByContext: { ...COMMON_METRICS_BY_CONTEXT, defaultDetailedMetrics: ['visitors', 'percentage'] }, detailsTitle: 'Top cities', detailsPath: 'cities', - dimensionLabel: 'City' + dimensionLabel: 'City', + alwaysOnFilters: [['is_not', 'visit:city', [0]]] } } diff --git a/assets/js/dashboard/stats/sources/details.tsx b/assets/js/dashboard/stats/sources/details.tsx index 7b1217e808da..25447b485ec4 100644 --- a/assets/js/dashboard/stats/sources/details.tsx +++ b/assets/js/dashboard/stats/sources/details.tsx @@ -69,6 +69,7 @@ export function SourcesDetails({ reportKey }: { reportKey: SourcesReportKey }) { dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} DimensionElement={DimensionElement} /> diff --git a/assets/js/dashboard/stats/sources/index.tsx b/assets/js/dashboard/stats/sources/index.tsx index 5f0801100164..b50c34baf3c8 100644 --- a/assets/js/dashboard/stats/sources/index.tsx +++ b/assets/js/dashboard/stats/sources/index.tsx @@ -219,6 +219,7 @@ export default function Sources() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={DimensionElement} onDataReady={setCurrentQueryApiData} /> From bee72f482331c02aa8d092b9e62e182c4ed24940 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 12:47:47 +0300 Subject: [PATCH 06/11] Stay on Cities tab when country filter removed --- assets/js/dashboard/stats/locations/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/js/dashboard/stats/locations/index.tsx b/assets/js/dashboard/stats/locations/index.tsx index e7b71f3de758..a899dcf7cd4d 100644 --- a/assets/js/dashboard/stats/locations/index.tsx +++ b/assets/js/dashboard/stats/locations/index.tsx @@ -101,8 +101,7 @@ export function Locations() { prevFilters.current = { countryFiltersApplied, regionFiltersApplied } if ( - (currentTab === BreakdownReportKey.regions || - currentTab === BreakdownReportKey.cities) && + currentTab === BreakdownReportKey.regions && prev.countryFiltersApplied && !countryFiltersApplied ) { From 56c31c005aa470f9de27685f32a6fe83be4a554c Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 13:10:03 +0300 Subject: [PATCH 07/11] Update Locations breakdown modals e2e tests --- e2e/tests/dashboard/breakdowns.spec.ts | 36 +++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index cf13664d60ae..a8c94a4d6360 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -925,6 +925,10 @@ test('locations breakdown', async ({ page, request }) => { country_code: 'PL', subdivision1_code: 'PL-14', city_geoname_id: 756_135 + }, + { + name: 'pageview', + country_code: '' } ] }) @@ -968,6 +972,14 @@ test('locations breakdown', async ({ page, request }) => { await expectMetricValues(modal(page), 'Estonia', ['2']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Esto') + await expectRows(modal(page), [/Estonia/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Estonia/, /Poland/]) + await closeModalButton(page).click() }) @@ -990,7 +1002,7 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(report, ['Region', 'Visitors']) - await expectRows(report, [/Harjumaa/, /Tartumaa/, /Mazovia/]) + await expectRows(report, [/Harjumaa/, /Mazovia/, /Tartumaa/]) await expectMetricValues(report, 'Harjumaa', ['1', '33.3%']) await expectMetricValues(report, 'Tartumaa', ['1', '33.3%']) @@ -1006,10 +1018,18 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(modal(page), ['Region', /Visitors/]) - await expectRows(modal(page), [/Harjumaa/, /Tartumaa/, /Mazovia/]) + await expectRows(modal(page), [/Harjumaa/, /Mazovia/, /Tartumaa/]) await expectMetricValues(modal(page), 'Harjumaa', ['1']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Harju') + await expectRows(modal(page), [/Harjumaa/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Harjumaa/, /Mazovia/, /Tartumaa/]) + await closeModalButton(page).click() }) @@ -1032,7 +1052,7 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(report, ['City', 'Visitors']) - await expectRows(report, [/Tartu/, /Tallinn/, /Warsaw/]) + await expectRows(report, [/Tallinn/, /Tartu/, /Warsaw/]) await expectMetricValues(report, 'Tartu', ['1', '33.3%']) await expectMetricValues(report, 'Tallinn', ['1', '33.3%']) @@ -1048,10 +1068,18 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(modal(page), ['City', /Visitors/]) - await expectRows(modal(page), [/Tartu/, /Tallinn/, /Warsaw/]) + await expectRows(modal(page), [/Tallinn/, /Tartu/, /Warsaw/]) await expectMetricValues(modal(page), 'Tartu', ['1']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Tallinn') + await expectRows(modal(page), [/Tallinn/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Tallinn/, /Tartu/, /Warsaw/]) + await closeModalButton(page).click() }) }) From 1c6e6c774b4061a2cf0d4f36531d89c84d5a0fcc Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 13:39:54 +0300 Subject: [PATCH 08/11] Make sure revenue goals work with Locations --- .../js/dashboard/stats/locations/details.tsx | 9 +- e2e/tests/dashboard/breakdowns.spec.ts | 187 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/locations/details.tsx b/assets/js/dashboard/stats/locations/details.tsx index 487d68ebc54a..7d2353bf71ad 100644 --- a/assets/js/dashboard/stats/locations/details.tsx +++ b/assets/js/dashboard/stats/locations/details.tsx @@ -1,5 +1,7 @@ import React, { ReactNode } from 'react' +import { revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' import { hasConversionGoalFilter, isRealTimeDashboard @@ -29,15 +31,20 @@ export function LocationsDetails({ reportKey: LocationsReportKey }) { const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() const reportConfig = BREAKDOWN_REPORTS[reportKey] + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + const metrics = chooseBreakdownMetricsByContext( reportConfig.metricsByContext, { hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), isRealtime: isRealTimeDashboard(dashboardState), isDetailed: true, - isRevenueAvailable: false + isRevenueAvailable: isRevenueAvailable } ) diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index a8c94a4d6360..491fb28b6bf3 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -1084,6 +1084,193 @@ test('locations breakdown', async ({ page, request }) => { }) }) +test('locations breakdown with a revenue goal filter applied', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 1, + name: 'pageview', + country_code: 'A1' + }, + { + user_id: 2, + name: 'pageview', + country_code: 'A1' + }, + { + user_id: 2, + name: 'purchase', + revenue_reporting_amount: '23', + revenue_reporting_currency: 'EUR', + country_code: 'A1' + }, + { + user_id: 3, + name: 'pageview', + country_code: 'PL', + subdivision1_code: 'PL-14', + city_geoname_id: 756_135 + }, + { + user_id: 4, + name: 'pageview', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + }, + { + user_id: 4, + name: 'purchase', + revenue_reporting_amount: '12345.67', + revenue_reporting_currency: 'EUR', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + } + ] + }) + + await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' }) + + await page.goto('/' + domain + '?f=is,goal,purchase', { + waitUntil: 'commit' + }) + + const report = page.getByTestId('report-locations') + + await test.step('countries report shows conversions for revenue goal', async () => { + await tabButton(report, 'Countries').click() + + await expectHeaders(report, ['Country', 'Conversions', 'CR']) + + await expectRows(report, [/Anonymous VPN Service/, /Estonia/]) + + await expectMetricValues(report, 'Anonymous VPN Service', ['1', '50%']) + await expectMetricValues(report, 'Estonia', ['1', '100%']) + }) + + await test.step('countries details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top countries' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Country', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Anonymous VPN Service/, /Estonia/]) + + await expectMetricValues(modal(page), 'Anonymous VPN Service', [ + '2', + '1', + '50%', + '€23.0', + '€23.0' + ]) + await expectMetricValues(modal(page), 'Estonia', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + + await closeModalButton(page).click() + }) + + await test.step('regions report shows conversions for revenue goal', async () => { + await tabButton(report, 'Regions').click() + + await expectHeaders(report, ['Region', 'Conversions', 'CR']) + + await expectRows(report, [/Harjumaa/]) + + await expectMetricValues(report, 'Harjumaa', ['1', '100%']) + }) + + await test.step('regions details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top regions' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Region', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Harjumaa/]) + + await expectMetricValues(modal(page), 'Harjumaa', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + + await closeModalButton(page).click() + }) + + await test.step('cities report shows conversions for revenue goal', async () => { + await tabButton(report, 'Cities').click() + + await expectHeaders(report, ['City', 'Conversions', 'CR']) + + await expectRows(report, [/Tallinn/]) + + await expectMetricValues(report, 'Tallinn', ['1', '100%']) + }) + + await test.step('cities details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top cities' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'City', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Tallinn/]) + + await expectMetricValues(modal(page), 'Tallinn', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + + await closeModalButton(page).click() + }) +}) + test('devices breakdown', async ({ page, request }) => { const { domain } = await setupSite({ page, request }) From 06d2c5fd6fa045c5b824acc05f7e829f0130cc7a Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 2 Jun 2026 14:03:52 +0300 Subject: [PATCH 09/11] Fix map dimensions --- assets/js/dashboard/stats/locations/map.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 9b2169441c18..f773bfbd7869 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -97,7 +97,7 @@ const WorldMap = ({ const dataByAlpha3Code: Map = new Map() let maxValue = 0 for (const row of data?.results ?? []) { - const [countryCode, countryName] = row.dimensions + const [countryName, countryCode] = row.dimensions const [visitors] = row.metrics as [number] const entry = COUNTRIES_BY_TWO_LETTER_CODE[countryCode] if (!entry || !entry.alpha_3) continue From 9e87ebce36284800c7d7c0c526ce41a3375ceda9 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 3 Jun 2026 11:47:46 +0300 Subject: [PATCH 10/11] Ensure regions imported from GA4 are shown --- .../stats/imported/sql/expression.ex | 16 +++++- .../query_imported_test.exs | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/plausible/stats/imported/sql/expression.ex b/lib/plausible/stats/imported/sql/expression.ex index a69825fe87b8..f96cdbbb8396 100644 --- a/lib/plausible/stats/imported/sql/expression.ex +++ b/lib/plausible/stats/imported/sql/expression.ex @@ -276,6 +276,20 @@ defmodule Plausible.Stats.Imported.SQL.Expression do }) end + # Rows imported from Plausible store `imported_locations.region` as ISO 3166-2 code. + # Rows imported from GA4 have `imported_locations.region` set to region name + # (e.g. "California"). + # + # imported_locations.region_name is an alias column that looks up the name by the ISO code. + # Lookup by values imported from GA4 (e.g. "California"), will yield an empty string. + # + # This function ensures that in those scenarios, the region name is set to the region code. + defp select_group_fields(q, "visit:region_name", key, _query) do + select_merge_as(q, [i], %{ + key => fragment("if(empty(?), ?, ?)", i.region_name, i.region, i.region_name) + }) + end + defp select_group_fields(q, dimension, key, _query) do select_merge_as(q, [i], %{key => field(i, ^dim(dimension))}) end @@ -298,7 +312,7 @@ defmodule Plausible.Stats.Imported.SQL.Expression do defp filter_group_values(q, "visit:city"), do: where(q, [i], i.city != 0 and not is_nil(i.city)) defp filter_group_values(q, "visit:country_name"), do: where(q, [i], i.country_name != "ZZ") - defp filter_group_values(q, "visit:region_name"), do: where(q, [i], i.region_name != "") + defp filter_group_values(q, "visit:region_name"), do: where(q, [i], i.region != "") defp filter_group_values(q, "visit:city_name"), do: where(q, [i], i.city_name != "") defp filter_group_values(q, _dimension), do: q diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs index d3317f874033..9ed544765ccc 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs @@ -1152,6 +1152,58 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryImportedTest do end end + test "imported non-empty region IDs that don't have a corresponding name in our locations dictionary have region_name set to the ID itself, empty region IDs are excluded", + %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + # GA4 import: `region` is the name of the `region` + build(:imported_locations, country: "US", region: "California", visitors: 10), + # Plausible import: `region` is the region ID + build(:imported_locations, country: "EE", region: "EE-37", visitors: 5), + # Plausible import: `region` is the region ID, but the ID is stale + build(:imported_locations, country: "NO", region: "NO-99", visitors: 50), + # Plausible / GA4 import when the region is unknown + build(:imported_locations, country: "US", region: "", visitors: 99) + ]) + + for {dimensions, expected_dimension_values} <- [ + {["visit:region_name", "visit:region"], + [ + NO: ["NO-99", "NO-99"], + US: ["California", "California"], + EE: ["Harjumaa", "EE-37"] + ]}, + {["visit:region"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["EE-37"] + ]}, + {["visit:region_name"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["Harjumaa"] + ]} + ] do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => dimensions, + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => expected_dimension_values[:NO], "metrics" => [50]}, + %{"dimensions" => expected_dimension_values[:US], "metrics" => [10]}, + %{"dimensions" => expected_dimension_values[:EE], "metrics" => [5]} + ] + end + end + test "imported country and city names", %{ site: site, conn: conn From ee410d6833d9d3070985f1b5657734f0158542f4 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 3 Jun 2026 12:28:12 +0300 Subject: [PATCH 11/11] Optimise generate_countries_meta.ex --- assets/data/countries_meta.json | 2008 ++++++++--------- .../js/dashboard/stats/locations/countries.ts | 12 +- lib/mix/tasks/generate_countries_meta.ex | 18 +- 3 files changed, 1024 insertions(+), 1014 deletions(-) diff --git a/assets/data/countries_meta.json b/assets/data/countries_meta.json index b1729bf16807..4dd242e0ca49 100644 --- a/assets/data/countries_meta.json +++ b/assets/data/countries_meta.json @@ -1,1006 +1,1006 @@ { - "YE": { - "flag": "🇾🇪", - "alpha_3": "YEM" - }, - "QA": { - "flag": "🇶🇦", - "alpha_3": "QAT" - }, - "MR": { - "flag": "🇲🇷", - "alpha_3": "MRT" - }, - "CH": { - "flag": "🇨🇭", - "alpha_3": "CHE" - }, - "AF": { - "flag": "🇦🇫", - "alpha_3": "AFG" - }, - "AO": { - "flag": "🇦🇴", - "alpha_3": "AGO" - }, - "BT": { - "flag": "🇧🇹", - "alpha_3": "BTN" - }, - "HK": { - "flag": "🇭🇰", - "alpha_3": "HKG" - }, - "CU": { - "flag": "🇨🇺", - "alpha_3": "CUB" - }, - "HU": { - "flag": "🇭🇺", - "alpha_3": "HUN" - }, - "AZ": { - "flag": "🇦🇿", - "alpha_3": "AZE" - }, - "SS": { - "flag": "🇸🇸", - "alpha_3": "SSD" - }, - "BY": { - "flag": "🇧🇾", - "alpha_3": "BLR" - }, - "MA": { - "flag": "🇲🇦", - "alpha_3": "MAR" - }, - "MZ": { - "flag": "🇲🇿", - "alpha_3": "MOZ" - }, - "TR": { - "flag": "🇹🇷", - "alpha_3": "TUR" - }, - "PL": { - "flag": "🇵🇱", - "alpha_3": "POL" - }, - "US": { - "flag": "🇺🇸", - "alpha_3": "USA" - }, - "SR": { - "flag": "🇸🇷", - "alpha_3": "SUR" - }, - "LR": { - "flag": "🇱🇷", - "alpha_3": "LBR" - }, - "OM": { - "flag": "🇴🇲", - "alpha_3": "OMN" - }, - "MH": { - "flag": "🇲🇭", - "alpha_3": "MHL" - }, - "TG": { - "flag": "🇹🇬", - "alpha_3": "TGO" - }, - "CW": { - "flag": "🇨🇼", - "alpha_3": "CUW" - }, - "AT": { - "flag": "🇦🇹", - "alpha_3": "AUT" - }, - "CO": { - "flag": "🇨🇴", - "alpha_3": "COL" - }, - "SV": { - "flag": "🇸🇻", - "alpha_3": "SLV" - }, - "RE": { - "flag": "🇷🇪", - "alpha_3": "REU" - }, - "LI": { - "flag": "🇱🇮", - "alpha_3": "LIE" - }, - "PR": { - "flag": "🇵🇷", - "alpha_3": "PRI" - }, - "GI": { - "flag": "🇬🇮", - "alpha_3": "GIB" - }, - "CV": { - "flag": "🇨🇻", - "alpha_3": "CPV" - }, - "KG": { - "flag": "🇰🇬", - "alpha_3": "KGZ" - }, - "SK": { - "flag": "🇸🇰", - "alpha_3": "SVK" - }, - "LT": { - "flag": "🇱🇹", - "alpha_3": "LTU" - }, - "AL": { - "flag": "🇦🇱", - "alpha_3": "ALB" - }, - "BL": { - "flag": "🇧🇱", - "alpha_3": "BLM" - }, - "FK": { - "flag": "🇫🇰", - "alpha_3": "FLK" - }, - "TV": { - "flag": "🇹🇻", - "alpha_3": "TUV" - }, - "SJ": { - "flag": "🇸🇯", - "alpha_3": "SJM" - }, - "CX": { - "flag": "🇨🇽", - "alpha_3": "CXR" - }, - "VG": { - "flag": "🇻🇬", - "alpha_3": "VGB" - }, - "VI": { - "flag": "🇻🇮", - "alpha_3": "VIR" - }, - "TT": { - "flag": "🇹🇹", - "alpha_3": "TTO" - }, - "AI": { - "flag": "🇦🇮", - "alpha_3": "AIA" - }, - "GE": { - "flag": "🇬🇪", - "alpha_3": "GEO" - }, - "GB": { - "flag": "🇬🇧", - "alpha_3": "GBR" - }, - "TW": { - "flag": "🇹🇼", - "alpha_3": "TWN" - }, - "BV": { - "flag": "🇧🇻", - "alpha_3": "BVT" - }, - "AU": { - "flag": "🇦🇺", - "alpha_3": "AUS" - }, - "CD": { - "flag": "🇨🇩", - "alpha_3": "COD" - }, - "IL": { - "flag": "🇮🇱", - "alpha_3": "ISR" - }, - "FJ": { - "flag": "🇫🇯", - "alpha_3": "FJI" - }, - "HM": { - "flag": "🇭🇲", - "alpha_3": "HMD" - }, - "MO": { - "flag": "🇲🇴", - "alpha_3": "MAC" - }, - "LU": { - "flag": "🇱🇺", - "alpha_3": "LUX" - }, - "TH": { - "flag": "🇹🇭", - "alpha_3": "THA" - }, - "TZ": { - "flag": "🇹🇿", - "alpha_3": "TZA" - }, - "MC": { - "flag": "🇲🇨", - "alpha_3": "MCO" - }, - "AD": { - "flag": "🇦🇩", - "alpha_3": "AND" - }, - "IQ": { - "flag": "🇮🇶", - "alpha_3": "IRQ" - }, - "NI": { - "flag": "🇳🇮", - "alpha_3": "NIC" - }, - "KW": { - "flag": "🇰🇼", - "alpha_3": "KWT" - }, - "MW": { - "flag": "🇲🇼", - "alpha_3": "MWI" - }, - "GH": { - "flag": "🇬🇭", - "alpha_3": "GHA" - }, - "DM": { - "flag": "🇩🇲", - "alpha_3": "DMA" - }, - "EE": { - "flag": "🇪🇪", - "alpha_3": "EST" - }, - "IS": { - "flag": "🇮🇸", - "alpha_3": "ISL" - }, - "BM": { - "flag": "🇧🇲", - "alpha_3": "BMU" - }, - "EC": { - "flag": "🇪🇨", - "alpha_3": "ECU" - }, - "KM": { - "flag": "🇰🇲", - "alpha_3": "COM" - }, - "SB": { - "flag": "🇸🇧", - "alpha_3": "SLB" - }, - "AE": { - "flag": "🇦🇪", - "alpha_3": "ARE" - }, - "CM": { - "flag": "🇨🇲", - "alpha_3": "CMR" - }, - "EH": { - "flag": "🇪🇭", - "alpha_3": "ESH" - }, - "CC": { - "flag": "🇨🇨", - "alpha_3": "CCK" - }, - "AS": { - "flag": "🇦🇸", - "alpha_3": "ASM" - }, - "KH": { - "flag": "🇰🇭", - "alpha_3": "KHM" - }, - "MD": { - "flag": "🇲🇩", - "alpha_3": "MDA" - }, - "NA": { - "flag": "🇳🇦", - "alpha_3": "NAM" - }, - "SA": { - "flag": "🇸🇦", - "alpha_3": "SAU" - }, - "HN": { - "flag": "🇭🇳", - "alpha_3": "HND" - }, - "MK": { - "flag": "🇲🇰", - "alpha_3": "MKD" - }, - "LC": { - "flag": "🇱🇨", - "alpha_3": "LCA" - }, - "PA": { - "flag": "🇵🇦", - "alpha_3": "PAN" - }, - "VC": { - "flag": "🇻🇨", - "alpha_3": "VCT" - }, - "TM": { - "flag": "🇹🇲", - "alpha_3": "TKM" - }, - "SI": { - "flag": "🇸🇮", - "alpha_3": "SVN" - }, - "GQ": { - "flag": "🇬🇶", - "alpha_3": "GNQ" - }, - "PH": { - "flag": "🇵🇭", - "alpha_3": "PHL" - }, - "CZ": { - "flag": "🇨🇿", - "alpha_3": "CZE" - }, - "BO": { - "flag": "🇧🇴", - "alpha_3": "BOL" - }, - "SY": { - "flag": "🇸🇾", - "alpha_3": "SYR" - }, - "NO": { - "flag": "🇳🇴", - "alpha_3": "NOR" - }, - "IM": { - "flag": "🇮🇲", - "alpha_3": "IMN" - }, - "SX": { - "flag": "🇸🇽", - "alpha_3": "SXM" - }, - "GG": { - "flag": "🇬🇬", - "alpha_3": "GGY" - }, - "GW": { - "flag": "🇬🇼", - "alpha_3": "GNB" - }, - "SH": { - "flag": "🇸🇭", - "alpha_3": "SHN" - }, - "NC": { - "flag": "🇳🇨", - "alpha_3": "NCL" - }, - "BE": { - "flag": "🇧🇪", - "alpha_3": "BEL" - }, - "JP": { - "flag": "🇯🇵", - "alpha_3": "JPN" - }, - "LV": { - "flag": "🇱🇻", - "alpha_3": "LVA" - }, - "AM": { - "flag": "🇦🇲", - "alpha_3": "ARM" - }, - "SD": { - "flag": "🇸🇩", - "alpha_3": "SDN" - }, - "GT": { - "flag": "🇬🇹", - "alpha_3": "GTM" - }, - "PY": { - "flag": "🇵🇾", - "alpha_3": "PRY" - }, - "MN": { - "flag": "🇲🇳", - "alpha_3": "MNG" - }, - "TK": { - "flag": "🇹🇰", - "alpha_3": "TKL" - }, - "DZ": { - "flag": "🇩🇿", - "alpha_3": "DZA" - }, - "KZ": { - "flag": "🇰🇿", - "alpha_3": "KAZ" - }, - "LY": { - "flag": "🇱🇾", - "alpha_3": "LBY" - }, - "AW": { - "flag": "🇦🇼", - "alpha_3": "ABW" - }, - "UY": { - "flag": "🇺🇾", - "alpha_3": "URY" - }, - "GL": { - "flag": "🇬🇱", - "alpha_3": "GRL" - }, - "SN": { - "flag": "🇸🇳", - "alpha_3": "SEN" - }, - "UM": { - "flag": "🇺🇲", - "alpha_3": "UMI" - }, - "JO": { - "flag": "🇯🇴", - "alpha_3": "JOR" - }, - "MT": { - "flag": "🇲🇹", - "alpha_3": "MLT" - }, - "BS": { - "flag": "🇧🇸", - "alpha_3": "BHS" - }, - "BI": { - "flag": "🇧🇮", - "alpha_3": "BDI" - }, - "BA": { - "flag": "🇧🇦", - "alpha_3": "BIH" - }, - "MQ": { - "flag": "🇲🇶", - "alpha_3": "MTQ" - }, - "MU": { - "flag": "🇲🇺", - "alpha_3": "MUS" - }, - "MS": { - "flag": "🇲🇸", - "alpha_3": "MSR" - }, - "BW": { - "flag": "🇧🇼", - "alpha_3": "BWA" - }, - "YT": { - "flag": "🇾🇹", - "alpha_3": "MYT" - }, - "PN": { - "flag": "🇵🇳", - "alpha_3": "PCN" - }, - "MP": { - "flag": "🇲🇵", - "alpha_3": "MNP" - }, - "ML": { - "flag": "🇲🇱", - "alpha_3": "MLI" - }, - "BH": { - "flag": "🇧🇭", - "alpha_3": "BHR" - }, - "LB": { - "flag": "🇱🇧", - "alpha_3": "LBN" - }, - "AR": { - "flag": "🇦🇷", - "alpha_3": "ARG" - }, - "PG": { - "flag": "🇵🇬", - "alpha_3": "PNG" - }, - "GR": { - "flag": "🇬🇷", - "alpha_3": "GRC" - }, - "HT": { - "flag": "🇭🇹", - "alpha_3": "HTI" - }, - "WS": { - "flag": "🇼🇸", - "alpha_3": "WSM" - }, - "SG": { - "flag": "🇸🇬", - "alpha_3": "SGP" - }, - "GP": { - "flag": "🇬🇵", - "alpha_3": "GLP" - }, - "BF": { - "flag": "🇧🇫", - "alpha_3": "BFA" - }, - "ME": { - "flag": "🇲🇪", - "alpha_3": "MNE" - }, - "AQ": { - "flag": "🇦🇶", - "alpha_3": "ATA" - }, - "PK": { - "flag": "🇵🇰", - "alpha_3": "PAK" - }, - "FM": { - "flag": "🇫🇲", - "alpha_3": "FSM" - }, - "MV": { - "flag": "🇲🇻", - "alpha_3": "MDV" - }, - "GS": { - "flag": "🇬🇸", - "alpha_3": "SGS" - }, - "BN": { - "flag": "🇧🇳", - "alpha_3": "BRN" - }, - "CK": { - "flag": "🇨🇰", - "alpha_3": "COK" - }, - "IO": { - "flag": "🇮🇴", - "alpha_3": "IOT" - }, - "SE": { - "flag": "🇸🇪", - "alpha_3": "SWE" - }, - "SC": { - "flag": "🇸🇨", - "alpha_3": "SYC" - }, - "ZW": { - "flag": "🇿🇼", - "alpha_3": "ZWE" - }, - "SL": { - "flag": "🇸🇱", - "alpha_3": "SLE" - }, - "AG": { - "flag": "🇦🇬", - "alpha_3": "ATG" - }, - "PF": { - "flag": "🇵🇫", - "alpha_3": "PYF" - }, - "CF": { - "flag": "🇨🇫", - "alpha_3": "CAF" - }, - "BD": { - "flag": "🇧🇩", - "alpha_3": "BGD" - }, - "AX": { - "flag": "🇦🇽", - "alpha_3": "ALA" - }, - "SZ": { - "flag": "🇸🇿", - "alpha_3": "SWZ" - }, - "HR": { - "flag": "🇭🇷", - "alpha_3": "HRV" - }, - "RS": { - "flag": "🇷🇸", - "alpha_3": "SRB" - }, - "NF": { - "flag": "🇳🇫", - "alpha_3": "NFK" - }, - "IE": { - "flag": "🇮🇪", - "alpha_3": "IRL" - }, - "NR": { - "flag": "🇳🇷", - "alpha_3": "NRU" - }, - "ZA": { - "flag": "🇿🇦", - "alpha_3": "ZAF" - }, - "CA": { - "flag": "🇨🇦", - "alpha_3": "CAN" - }, - "KR": { - "flag": "🇰🇷", - "alpha_3": "KOR" - }, - "A1": { - "flag": "🏳️", - "alpha_3": null - }, - "VA": { - "flag": "🇻🇦", - "alpha_3": "VAT" - }, - "NU": { - "flag": "🇳🇺", - "alpha_3": "NIU" - }, - "JE": { - "flag": "🇯🇪", - "alpha_3": "JEY" - }, - "TD": { - "flag": "🇹🇩", - "alpha_3": "TCD" - }, - "IR": { - "flag": "🇮🇷", - "alpha_3": "IRN" - }, - "NL": { - "flag": "🇳🇱", - "alpha_3": "NLD" - }, - "BB": { - "flag": "🇧🇧", - "alpha_3": "BRB" - }, - "FI": { - "flag": "🇫🇮", - "alpha_3": "FIN" - }, - "UA": { - "flag": "🇺🇦", - "alpha_3": "UKR" - }, - "ID": { - "flag": "🇮🇩", - "alpha_3": "IDN" - }, - "ST": { - "flag": "🇸🇹", - "alpha_3": "STP" - }, - "VU": { - "flag": "🇻🇺", - "alpha_3": "VUT" - }, - "RU": { - "flag": "🇷🇺", - "alpha_3": "RUS" - }, - "NE": { - "flag": "🇳🇪", - "alpha_3": "NER" - }, - "TO": { - "flag": "🇹🇴", - "alpha_3": "TON" - }, - "UZ": { - "flag": "🇺🇿", - "alpha_3": "UZB" - }, - "GN": { - "flag": "🇬🇳", - "alpha_3": "GIN" - }, - "JM": { - "flag": "🇯🇲", - "alpha_3": "JAM" - }, - "FR": { - "flag": "🇫🇷", - "alpha_3": "FRA" - }, - "TL": { - "flag": "🇹🇱", - "alpha_3": "TLS" - }, - "ET": { - "flag": "🇪🇹", - "alpha_3": "ETH" - }, - "KI": { - "flag": "🇰🇮", - "alpha_3": "KIR" - }, - "CG": { - "flag": "🇨🇬", - "alpha_3": "COG" - }, - "DE": { - "flag": "🇩🇪", - "alpha_3": "DEU" - }, - "RW": { - "flag": "🇷🇼", - "alpha_3": "RWA" - }, - "DO": { - "flag": "🇩🇴", - "alpha_3": "DOM" - }, - "VE": { - "flag": "🇻🇪", - "alpha_3": "VEN" - }, - "PW": { - "flag": "🇵🇼", - "alpha_3": "PLW" - }, - "TC": { - "flag": "🇹🇨", - "alpha_3": "TCA" - }, - "ZM": { - "flag": "🇿🇲", - "alpha_3": "ZMB" - }, - "NG": { - "flag": "🇳🇬", - "alpha_3": "NGA" - }, - "WF": { - "flag": "🇼🇫", - "alpha_3": "WLF" - }, - "GF": { - "flag": "🇬🇫", - "alpha_3": "GUF" - }, - "KN": { - "flag": "🇰🇳", - "alpha_3": "KNA" - }, - "ES": { - "flag": "🇪🇸", - "alpha_3": "ESP" - }, - "GM": { - "flag": "🇬🇲", - "alpha_3": "GMB" - }, - "KP": { - "flag": "🇰🇵", - "alpha_3": "PRK" - }, - "GY": { - "flag": "🇬🇾", - "alpha_3": "GUY" - }, - "MX": { - "flag": "🇲🇽", - "alpha_3": "MEX" - }, - "IN": { - "flag": "🇮🇳", - "alpha_3": "IND" - }, - "SM": { - "flag": "🇸🇲", - "alpha_3": "SMR" - }, - "BG": { - "flag": "🇧🇬", - "alpha_3": "BGR" - }, - "MF": { - "flag": "🇲🇫", - "alpha_3": "MAF" - }, - "CL": { - "flag": "🇨🇱", - "alpha_3": "CHL" - }, - "VN": { - "flag": "🇻🇳", - "alpha_3": "VNM" - }, - "NP": { - "flag": "🇳🇵", - "alpha_3": "NPL" - }, - "CR": { - "flag": "🇨🇷", - "alpha_3": "CRI" - }, - "ER": { - "flag": "🇪🇷", - "alpha_3": "ERI" - }, - "LK": { - "flag": "🇱🇰", - "alpha_3": "LKA" - }, - "CI": { - "flag": "🇨🇮", - "alpha_3": "CIV" - }, - "PT": { - "flag": "🇵🇹", - "alpha_3": "PRT" - }, - "TJ": { - "flag": "🇹🇯", - "alpha_3": "TJK" - }, - "MY": { - "flag": "🇲🇾", - "alpha_3": "MYS" - }, - "PS": { - "flag": "🇵🇸", - "alpha_3": "PSE" - }, - "PE": { - "flag": "🇵🇪", - "alpha_3": "PER" - }, - "LS": { - "flag": "🇱🇸", - "alpha_3": "LSO" - }, - "CY": { - "flag": "🇨🇾", - "alpha_3": "CYP" - }, - "TN": { - "flag": "🇹🇳", - "alpha_3": "TUN" - }, - "XK": { - "flag": "🇽🇰", - "alpha_3": "XKX" - }, - "KY": { - "flag": "🇰🇾", - "alpha_3": "CYM" - }, - "DK": { - "flag": "🇩🇰", - "alpha_3": "DNK" - }, - "BZ": { - "flag": "🇧🇿", - "alpha_3": "BLZ" - }, - "FO": { - "flag": "🇫🇴", - "alpha_3": "FRO" - }, - "LA": { - "flag": "🇱🇦", - "alpha_3": "LAO" - }, - "RO": { - "flag": "🇷🇴", - "alpha_3": "ROU" - }, - "DJ": { - "flag": "🇩🇯", - "alpha_3": "DJI" - }, - "EG": { - "flag": "🇪🇬", - "alpha_3": "EGY" - }, - "KE": { - "flag": "🇰🇪", - "alpha_3": "KEN" - }, - "UG": { - "flag": "🇺🇬", - "alpha_3": "UGA" - }, - "MM": { - "flag": "🇲🇲", - "alpha_3": "MMR" - }, - "BQ": { - "flag": "🇧🇶", - "alpha_3": "BES" - }, - "PM": { - "flag": "🇵🇲", - "alpha_3": "SPM" - }, - "GD": { - "flag": "🇬🇩", - "alpha_3": "GRD" - }, - "GU": { - "flag": "🇬🇺", - "alpha_3": "GUM" - }, - "CN": { - "flag": "🇨🇳", - "alpha_3": "CHN" - }, - "SO": { - "flag": "🇸🇴", - "alpha_3": "SOM" - }, - "BJ": { - "flag": "🇧🇯", - "alpha_3": "BEN" - }, - "BR": { - "flag": "🇧🇷", - "alpha_3": "BRA" - }, - "GA": { - "flag": "🇬🇦", - "alpha_3": "GAB" - }, - "NZ": { - "flag": "🇳🇿", - "alpha_3": "NZL" - }, - "MG": { - "flag": "🇲🇬", - "alpha_3": "MDG" - }, - "IT": { - "flag": "🇮🇹", - "alpha_3": "ITA" - }, - "TF": { - "flag": "🇹🇫", - "alpha_3": "ATF" - } + "YE": [ + "YEM", + "🇾🇪" + ], + "QA": [ + "QAT", + "🇶🇦" + ], + "MR": [ + "MRT", + "🇲🇷" + ], + "CH": [ + "CHE", + "🇨🇭" + ], + "AF": [ + "AFG", + "🇦🇫" + ], + "AO": [ + "AGO", + "🇦🇴" + ], + "BT": [ + "BTN", + "🇧🇹" + ], + "HK": [ + "HKG", + "🇭🇰" + ], + "CU": [ + "CUB", + "🇨🇺" + ], + "HU": [ + "HUN", + "🇭🇺" + ], + "AZ": [ + "AZE", + "🇦🇿" + ], + "SS": [ + "SSD", + "🇸🇸" + ], + "BY": [ + "BLR", + "🇧🇾" + ], + "MA": [ + "MAR", + "🇲🇦" + ], + "MZ": [ + "MOZ", + "🇲🇿" + ], + "TR": [ + "TUR", + "🇹🇷" + ], + "PL": [ + "POL", + "🇵🇱" + ], + "US": [ + "USA", + "🇺🇸" + ], + "SR": [ + "SUR", + "🇸🇷" + ], + "LR": [ + "LBR", + "🇱🇷" + ], + "OM": [ + "OMN", + "🇴🇲" + ], + "MH": [ + "MHL", + "🇲🇭" + ], + "TG": [ + "TGO", + "🇹🇬" + ], + "CW": [ + "CUW", + "🇨🇼" + ], + "AT": [ + "AUT", + "🇦🇹" + ], + "CO": [ + "COL", + "🇨🇴" + ], + "SV": [ + "SLV", + "🇸🇻" + ], + "RE": [ + "REU", + "🇷🇪" + ], + "LI": [ + "LIE", + "🇱🇮" + ], + "PR": [ + "PRI", + "🇵🇷" + ], + "GI": [ + "GIB", + "🇬🇮" + ], + "CV": [ + "CPV", + "🇨🇻" + ], + "KG": [ + "KGZ", + "🇰🇬" + ], + "SK": [ + "SVK", + "🇸🇰" + ], + "LT": [ + "LTU", + "🇱🇹" + ], + "AL": [ + "ALB", + "🇦🇱" + ], + "BL": [ + "BLM", + "🇧🇱" + ], + "FK": [ + "FLK", + "🇫🇰" + ], + "TV": [ + "TUV", + "🇹🇻" + ], + "SJ": [ + "SJM", + "🇸🇯" + ], + "CX": [ + "CXR", + "🇨🇽" + ], + "VG": [ + "VGB", + "🇻🇬" + ], + "VI": [ + "VIR", + "🇻🇮" + ], + "TT": [ + "TTO", + "🇹🇹" + ], + "AI": [ + "AIA", + "🇦🇮" + ], + "GE": [ + "GEO", + "🇬🇪" + ], + "GB": [ + "GBR", + "🇬🇧" + ], + "TW": [ + "TWN", + "🇹🇼" + ], + "BV": [ + "BVT", + "🇧🇻" + ], + "AU": [ + "AUS", + "🇦🇺" + ], + "CD": [ + "COD", + "🇨🇩" + ], + "IL": [ + "ISR", + "🇮🇱" + ], + "FJ": [ + "FJI", + "🇫🇯" + ], + "HM": [ + "HMD", + "🇭🇲" + ], + "MO": [ + "MAC", + "🇲🇴" + ], + "LU": [ + "LUX", + "🇱🇺" + ], + "TH": [ + "THA", + "🇹🇭" + ], + "TZ": [ + "TZA", + "🇹🇿" + ], + "MC": [ + "MCO", + "🇲🇨" + ], + "AD": [ + "AND", + "🇦🇩" + ], + "IQ": [ + "IRQ", + "🇮🇶" + ], + "NI": [ + "NIC", + "🇳🇮" + ], + "KW": [ + "KWT", + "🇰🇼" + ], + "MW": [ + "MWI", + "🇲🇼" + ], + "GH": [ + "GHA", + "🇬🇭" + ], + "DM": [ + "DMA", + "🇩🇲" + ], + "EE": [ + "EST", + "🇪🇪" + ], + "IS": [ + "ISL", + "🇮🇸" + ], + "BM": [ + "BMU", + "🇧🇲" + ], + "EC": [ + "ECU", + "🇪🇨" + ], + "KM": [ + "COM", + "🇰🇲" + ], + "SB": [ + "SLB", + "🇸🇧" + ], + "AE": [ + "ARE", + "🇦🇪" + ], + "CM": [ + "CMR", + "🇨🇲" + ], + "EH": [ + "ESH", + "🇪🇭" + ], + "CC": [ + "CCK", + "🇨🇨" + ], + "AS": [ + "ASM", + "🇦🇸" + ], + "KH": [ + "KHM", + "🇰🇭" + ], + "MD": [ + "MDA", + "🇲🇩" + ], + "NA": [ + "NAM", + "🇳🇦" + ], + "SA": [ + "SAU", + "🇸🇦" + ], + "HN": [ + "HND", + "🇭🇳" + ], + "MK": [ + "MKD", + "🇲🇰" + ], + "LC": [ + "LCA", + "🇱🇨" + ], + "PA": [ + "PAN", + "🇵🇦" + ], + "VC": [ + "VCT", + "🇻🇨" + ], + "TM": [ + "TKM", + "🇹🇲" + ], + "SI": [ + "SVN", + "🇸🇮" + ], + "GQ": [ + "GNQ", + "🇬🇶" + ], + "PH": [ + "PHL", + "🇵🇭" + ], + "CZ": [ + "CZE", + "🇨🇿" + ], + "BO": [ + "BOL", + "🇧🇴" + ], + "SY": [ + "SYR", + "🇸🇾" + ], + "NO": [ + "NOR", + "🇳🇴" + ], + "IM": [ + "IMN", + "🇮🇲" + ], + "SX": [ + "SXM", + "🇸🇽" + ], + "GG": [ + "GGY", + "🇬🇬" + ], + "GW": [ + "GNB", + "🇬🇼" + ], + "SH": [ + "SHN", + "🇸🇭" + ], + "NC": [ + "NCL", + "🇳🇨" + ], + "BE": [ + "BEL", + "🇧🇪" + ], + "JP": [ + "JPN", + "🇯🇵" + ], + "LV": [ + "LVA", + "🇱🇻" + ], + "AM": [ + "ARM", + "🇦🇲" + ], + "SD": [ + "SDN", + "🇸🇩" + ], + "GT": [ + "GTM", + "🇬🇹" + ], + "PY": [ + "PRY", + "🇵🇾" + ], + "MN": [ + "MNG", + "🇲🇳" + ], + "TK": [ + "TKL", + "🇹🇰" + ], + "DZ": [ + "DZA", + "🇩🇿" + ], + "KZ": [ + "KAZ", + "🇰🇿" + ], + "LY": [ + "LBY", + "🇱🇾" + ], + "AW": [ + "ABW", + "🇦🇼" + ], + "UY": [ + "URY", + "🇺🇾" + ], + "GL": [ + "GRL", + "🇬🇱" + ], + "SN": [ + "SEN", + "🇸🇳" + ], + "UM": [ + "UMI", + "🇺🇲" + ], + "JO": [ + "JOR", + "🇯🇴" + ], + "MT": [ + "MLT", + "🇲🇹" + ], + "BS": [ + "BHS", + "🇧🇸" + ], + "BI": [ + "BDI", + "🇧🇮" + ], + "BA": [ + "BIH", + "🇧🇦" + ], + "MQ": [ + "MTQ", + "🇲🇶" + ], + "MU": [ + "MUS", + "🇲🇺" + ], + "MS": [ + "MSR", + "🇲🇸" + ], + "BW": [ + "BWA", + "🇧🇼" + ], + "YT": [ + "MYT", + "🇾🇹" + ], + "PN": [ + "PCN", + "🇵🇳" + ], + "MP": [ + "MNP", + "🇲🇵" + ], + "ML": [ + "MLI", + "🇲🇱" + ], + "BH": [ + "BHR", + "🇧🇭" + ], + "LB": [ + "LBN", + "🇱🇧" + ], + "AR": [ + "ARG", + "🇦🇷" + ], + "PG": [ + "PNG", + "🇵🇬" + ], + "GR": [ + "GRC", + "🇬🇷" + ], + "HT": [ + "HTI", + "🇭🇹" + ], + "WS": [ + "WSM", + "🇼🇸" + ], + "SG": [ + "SGP", + "🇸🇬" + ], + "GP": [ + "GLP", + "🇬🇵" + ], + "BF": [ + "BFA", + "🇧🇫" + ], + "ME": [ + "MNE", + "🇲🇪" + ], + "AQ": [ + "ATA", + "🇦🇶" + ], + "PK": [ + "PAK", + "🇵🇰" + ], + "FM": [ + "FSM", + "🇫🇲" + ], + "MV": [ + "MDV", + "🇲🇻" + ], + "GS": [ + "SGS", + "🇬🇸" + ], + "BN": [ + "BRN", + "🇧🇳" + ], + "CK": [ + "COK", + "🇨🇰" + ], + "IO": [ + "IOT", + "🇮🇴" + ], + "SE": [ + "SWE", + "🇸🇪" + ], + "SC": [ + "SYC", + "🇸🇨" + ], + "ZW": [ + "ZWE", + "🇿🇼" + ], + "SL": [ + "SLE", + "🇸🇱" + ], + "AG": [ + "ATG", + "🇦🇬" + ], + "PF": [ + "PYF", + "🇵🇫" + ], + "CF": [ + "CAF", + "🇨🇫" + ], + "BD": [ + "BGD", + "🇧🇩" + ], + "AX": [ + "ALA", + "🇦🇽" + ], + "SZ": [ + "SWZ", + "🇸🇿" + ], + "HR": [ + "HRV", + "🇭🇷" + ], + "RS": [ + "SRB", + "🇷🇸" + ], + "NF": [ + "NFK", + "🇳🇫" + ], + "IE": [ + "IRL", + "🇮🇪" + ], + "NR": [ + "NRU", + "🇳🇷" + ], + "ZA": [ + "ZAF", + "🇿🇦" + ], + "CA": [ + "CAN", + "🇨🇦" + ], + "KR": [ + "KOR", + "🇰🇷" + ], + "A1": [ + null, + "🏳️" + ], + "VA": [ + "VAT", + "🇻🇦" + ], + "NU": [ + "NIU", + "🇳🇺" + ], + "JE": [ + "JEY", + "🇯🇪" + ], + "TD": [ + "TCD", + "🇹🇩" + ], + "IR": [ + "IRN", + "🇮🇷" + ], + "NL": [ + "NLD", + "🇳🇱" + ], + "BB": [ + "BRB", + "🇧🇧" + ], + "FI": [ + "FIN", + "🇫🇮" + ], + "UA": [ + "UKR", + "🇺🇦" + ], + "ID": [ + "IDN", + "🇮🇩" + ], + "ST": [ + "STP", + "🇸🇹" + ], + "VU": [ + "VUT", + "🇻🇺" + ], + "RU": [ + "RUS", + "🇷🇺" + ], + "NE": [ + "NER", + "🇳🇪" + ], + "TO": [ + "TON", + "🇹🇴" + ], + "UZ": [ + "UZB", + "🇺🇿" + ], + "GN": [ + "GIN", + "🇬🇳" + ], + "JM": [ + "JAM", + "🇯🇲" + ], + "FR": [ + "FRA", + "🇫🇷" + ], + "TL": [ + "TLS", + "🇹🇱" + ], + "ET": [ + "ETH", + "🇪🇹" + ], + "KI": [ + "KIR", + "🇰🇮" + ], + "CG": [ + "COG", + "🇨🇬" + ], + "DE": [ + "DEU", + "🇩🇪" + ], + "RW": [ + "RWA", + "🇷🇼" + ], + "DO": [ + "DOM", + "🇩🇴" + ], + "VE": [ + "VEN", + "🇻🇪" + ], + "PW": [ + "PLW", + "🇵🇼" + ], + "TC": [ + "TCA", + "🇹🇨" + ], + "ZM": [ + "ZMB", + "🇿🇲" + ], + "NG": [ + "NGA", + "🇳🇬" + ], + "WF": [ + "WLF", + "🇼🇫" + ], + "GF": [ + "GUF", + "🇬🇫" + ], + "KN": [ + "KNA", + "🇰🇳" + ], + "ES": [ + "ESP", + "🇪🇸" + ], + "GM": [ + "GMB", + "🇬🇲" + ], + "KP": [ + "PRK", + "🇰🇵" + ], + "GY": [ + "GUY", + "🇬🇾" + ], + "MX": [ + "MEX", + "🇲🇽" + ], + "IN": [ + "IND", + "🇮🇳" + ], + "SM": [ + "SMR", + "🇸🇲" + ], + "BG": [ + "BGR", + "🇧🇬" + ], + "MF": [ + "MAF", + "🇲🇫" + ], + "CL": [ + "CHL", + "🇨🇱" + ], + "VN": [ + "VNM", + "🇻🇳" + ], + "NP": [ + "NPL", + "🇳🇵" + ], + "CR": [ + "CRI", + "🇨🇷" + ], + "ER": [ + "ERI", + "🇪🇷" + ], + "LK": [ + "LKA", + "🇱🇰" + ], + "CI": [ + "CIV", + "🇨🇮" + ], + "PT": [ + "PRT", + "🇵🇹" + ], + "TJ": [ + "TJK", + "🇹🇯" + ], + "MY": [ + "MYS", + "🇲🇾" + ], + "PS": [ + "PSE", + "🇵🇸" + ], + "PE": [ + "PER", + "🇵🇪" + ], + "LS": [ + "LSO", + "🇱🇸" + ], + "CY": [ + "CYP", + "🇨🇾" + ], + "TN": [ + "TUN", + "🇹🇳" + ], + "XK": [ + "XKX", + "🇽🇰" + ], + "KY": [ + "CYM", + "🇰🇾" + ], + "DK": [ + "DNK", + "🇩🇰" + ], + "BZ": [ + "BLZ", + "🇧🇿" + ], + "FO": [ + "FRO", + "🇫🇴" + ], + "LA": [ + "LAO", + "🇱🇦" + ], + "RO": [ + "ROU", + "🇷🇴" + ], + "DJ": [ + "DJI", + "🇩🇯" + ], + "EG": [ + "EGY", + "🇪🇬" + ], + "KE": [ + "KEN", + "🇰🇪" + ], + "UG": [ + "UGA", + "🇺🇬" + ], + "MM": [ + "MMR", + "🇲🇲" + ], + "BQ": [ + "BES", + "🇧🇶" + ], + "PM": [ + "SPM", + "🇵🇲" + ], + "GD": [ + "GRD", + "🇬🇩" + ], + "GU": [ + "GUM", + "🇬🇺" + ], + "CN": [ + "CHN", + "🇨🇳" + ], + "SO": [ + "SOM", + "🇸🇴" + ], + "BJ": [ + "BEN", + "🇧🇯" + ], + "BR": [ + "BRA", + "🇧🇷" + ], + "GA": [ + "GAB", + "🇬🇦" + ], + "NZ": [ + "NZL", + "🇳🇿" + ], + "MG": [ + "MDG", + "🇲🇬" + ], + "IT": [ + "ITA", + "🇮🇹" + ], + "TF": [ + "ATF", + "🇹🇫" + ] } \ No newline at end of file diff --git a/assets/js/dashboard/stats/locations/countries.ts b/assets/js/dashboard/stats/locations/countries.ts index 5c7f7bb6d1fa..37757b8ca390 100644 --- a/assets/js/dashboard/stats/locations/countries.ts +++ b/assets/js/dashboard/stats/locations/countries.ts @@ -25,5 +25,13 @@ type CountryTwoLetterCode = string export type CountriesLookup = Record -export const COUNTRIES_BY_TWO_LETTER_CODE = - countriesMeta as unknown as CountriesLookup +const remapCountriesMeta = () => { + const result: CountriesLookup = {} + for (const [alpha_2, [alpha_3, flag]] of Object.entries(countriesMeta)) { + // flag is definitely defined in the source file + const entry: CountryEntry = { alpha_3, flag: flag! } + Object.assign(result, { [alpha_2]: entry }) + } + return result +} +export const COUNTRIES_BY_TWO_LETTER_CODE = remapCountriesMeta() diff --git a/lib/mix/tasks/generate_countries_meta.ex b/lib/mix/tasks/generate_countries_meta.ex index f6dbdcf82979..0805b9c01a41 100644 --- a/lib/mix/tasks/generate_countries_meta.ex +++ b/lib/mix/tasks/generate_countries_meta.ex @@ -1,16 +1,18 @@ defmodule Mix.Tasks.GenerateCountriesMeta do @moduledoc """ - Regenerates `countries_meta.json` — the compact - `alpha_2 -> %{alpha_3, flag}` lookup the dashboard frontend uses to - render country flags and to do the world map's alpha-2 -> alpha-3 join. + Regenerates `countries_meta.json`, an `alpha_2 -> [alpha_3, flag]` dictionary. + The dashboard uses it for two things. + First, to render country flags the same way as the BE does. + Secondly, to map visitors by country to the country shapes in the world map. This is needed + because country shapes are identified in the geography dataset only with their alpha3 code. - The source of truth is `Location.Country.all/0` from the `:location` Hex + The source of truth is `Location.Country.all/0` from the `:location` dependency. We materialize it to a checked-in JSON file in `assets/` folder. - The checked-in file is protected from drifting off from the Hex dependency by a CI job. + The checked-in file is protected from drifting off from the dependency by a CI job. - Run `mix generate_countries_meta` locally whenever the `:location` dependency is bumped, or any time you want to - refresh the committed file. + Run `mix generate_countries_meta` locally whenever the `:location` dependency is bumped, + or any time you want to refresh the committed file. """ use Mix.Task @@ -29,7 +31,7 @@ defmodule Mix.Tasks.GenerateCountriesMeta do alpha_3: alpha_3, flag: flag } -> - {code, %{alpha_3: alpha_3, flag: flag}} + {code, [alpha_3, flag]} end) |> Jason.encode!(pretty: true)