[Frontend] Marc/frontend granular city/state geo proxy (#4156)
This commit is contained in:
202
skyvern-frontend/src/util/geoData.ts
Normal file
202
skyvern-frontend/src/util/geoData.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { GeoTarget, ProxyLocation } from "@/api/types";
|
||||
|
||||
export const SUPPORTED_COUNTRY_CODES = [
|
||||
"US",
|
||||
"AR",
|
||||
"AU",
|
||||
"BR",
|
||||
"CA",
|
||||
"DE",
|
||||
"ES",
|
||||
"FR",
|
||||
"GB",
|
||||
"IE",
|
||||
"IN",
|
||||
"IT",
|
||||
"JP",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"TR",
|
||||
"ZA",
|
||||
] as const;
|
||||
|
||||
export type SupportedCountryCode = (typeof SUPPORTED_COUNTRY_CODES)[number];
|
||||
|
||||
export const COUNTRY_NAMES: Record<SupportedCountryCode, string> = {
|
||||
US: "United States",
|
||||
AR: "Argentina",
|
||||
AU: "Australia",
|
||||
BR: "Brazil",
|
||||
CA: "Canada",
|
||||
DE: "Germany",
|
||||
ES: "Spain",
|
||||
FR: "France",
|
||||
GB: "United Kingdom",
|
||||
IE: "Ireland",
|
||||
IN: "India",
|
||||
IT: "Italy",
|
||||
JP: "Japan",
|
||||
MX: "Mexico",
|
||||
NL: "Netherlands",
|
||||
NZ: "New Zealand",
|
||||
TR: "Turkey",
|
||||
ZA: "South Africa",
|
||||
};
|
||||
|
||||
export const COUNTRY_FLAGS: Record<SupportedCountryCode, string> = {
|
||||
US: "🇺🇸",
|
||||
AR: "🇦🇷",
|
||||
AU: "🇦🇺",
|
||||
BR: "🇧🇷",
|
||||
CA: "🇨🇦",
|
||||
DE: "🇩🇪",
|
||||
ES: "🇪🇸",
|
||||
FR: "🇫🇷",
|
||||
GB: "🇬🇧",
|
||||
IE: "🇮🇪",
|
||||
IN: "🇮🇳",
|
||||
IT: "🇮🇹",
|
||||
JP: "🇯🇵",
|
||||
MX: "🇲🇽",
|
||||
NL: "🇳🇱",
|
||||
NZ: "🇳🇿",
|
||||
TR: "🇹🇷",
|
||||
ZA: "🇿🇦",
|
||||
};
|
||||
|
||||
// Map legacy ProxyLocation to Country Code
|
||||
const PROXY_LOCATION_TO_COUNTRY: Record<string, string> = {
|
||||
[ProxyLocation.Residential]: "US",
|
||||
[ProxyLocation.ResidentialISP]: "US",
|
||||
[ProxyLocation.ResidentialAR]: "AR",
|
||||
[ProxyLocation.ResidentialAU]: "AU",
|
||||
[ProxyLocation.ResidentialBR]: "BR",
|
||||
[ProxyLocation.ResidentialCA]: "CA",
|
||||
[ProxyLocation.ResidentialDE]: "DE",
|
||||
[ProxyLocation.ResidentialES]: "ES",
|
||||
[ProxyLocation.ResidentialFR]: "FR",
|
||||
[ProxyLocation.ResidentialGB]: "GB",
|
||||
[ProxyLocation.ResidentialIE]: "IE",
|
||||
[ProxyLocation.ResidentialIN]: "IN",
|
||||
[ProxyLocation.ResidentialIT]: "IT",
|
||||
[ProxyLocation.ResidentialJP]: "JP",
|
||||
[ProxyLocation.ResidentialMX]: "MX",
|
||||
[ProxyLocation.ResidentialNL]: "NL",
|
||||
[ProxyLocation.ResidentialNZ]: "NZ",
|
||||
[ProxyLocation.ResidentialTR]: "TR",
|
||||
[ProxyLocation.ResidentialZA]: "ZA",
|
||||
};
|
||||
|
||||
// Reverse map for round-tripping simple country selections
|
||||
const COUNTRY_TO_PROXY_LOCATION: Record<string, ProxyLocation> = {
|
||||
US: ProxyLocation.Residential,
|
||||
AR: ProxyLocation.ResidentialAR,
|
||||
AU: ProxyLocation.ResidentialAU,
|
||||
BR: ProxyLocation.ResidentialBR,
|
||||
CA: ProxyLocation.ResidentialCA,
|
||||
DE: ProxyLocation.ResidentialDE,
|
||||
ES: ProxyLocation.ResidentialES,
|
||||
FR: ProxyLocation.ResidentialFR,
|
||||
GB: ProxyLocation.ResidentialGB,
|
||||
IE: ProxyLocation.ResidentialIE,
|
||||
IN: ProxyLocation.ResidentialIN,
|
||||
IT: ProxyLocation.ResidentialIT,
|
||||
JP: ProxyLocation.ResidentialJP,
|
||||
MX: ProxyLocation.ResidentialMX,
|
||||
NL: ProxyLocation.ResidentialNL,
|
||||
NZ: ProxyLocation.ResidentialNZ,
|
||||
TR: ProxyLocation.ResidentialTR,
|
||||
ZA: ProxyLocation.ResidentialZA,
|
||||
};
|
||||
|
||||
export function proxyLocationToGeoTarget(
|
||||
input: ProxyLocation,
|
||||
): GeoTarget | null {
|
||||
if (!input) return null;
|
||||
|
||||
// If it's already a GeoTarget object
|
||||
if (typeof input === "object" && "country" in input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
// If it's a legacy string
|
||||
if (typeof input === "string") {
|
||||
if (input === ProxyLocation.None) return null;
|
||||
if (input === ProxyLocation.ResidentialISP) {
|
||||
return { country: "US", isISP: true };
|
||||
}
|
||||
const country = PROXY_LOCATION_TO_COUNTRY[input];
|
||||
if (country) {
|
||||
return { country };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function geoTargetToProxyLocationInput(
|
||||
target: GeoTarget | null,
|
||||
): ProxyLocation {
|
||||
if (!target) return ProxyLocation.None;
|
||||
|
||||
if (target.isISP) {
|
||||
return ProxyLocation.ResidentialISP;
|
||||
}
|
||||
|
||||
// Try to map back to legacy enum if it's just a country
|
||||
if (target.country && !target.subdivision && !target.city) {
|
||||
const legacyLocation = COUNTRY_TO_PROXY_LOCATION[target.country];
|
||||
if (legacyLocation) {
|
||||
return legacyLocation;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise return the object
|
||||
return target;
|
||||
}
|
||||
|
||||
export function formatGeoTarget(target: GeoTarget | null): string {
|
||||
if (!target || !target.country) return "No Proxy";
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Add Flag
|
||||
if (target.country in COUNTRY_FLAGS) {
|
||||
parts.push(COUNTRY_FLAGS[target.country as SupportedCountryCode]);
|
||||
}
|
||||
|
||||
if (target.city) parts.push(target.city);
|
||||
if (target.subdivision) parts.push(target.subdivision);
|
||||
|
||||
// Country Name
|
||||
const countryName =
|
||||
COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country;
|
||||
if (target.isISP) {
|
||||
parts.push(`${countryName} (ISP)`);
|
||||
} else {
|
||||
parts.push(countryName);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function formatGeoTargetCompact(target: GeoTarget | null): string {
|
||||
if (!target || !target.country) return "No Proxy";
|
||||
|
||||
const parts = [];
|
||||
if (target.city) parts.push(target.city);
|
||||
if (target.subdivision) parts.push(target.subdivision);
|
||||
const countryName =
|
||||
COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country;
|
||||
if (target.isISP) {
|
||||
parts.push(`${countryName} (ISP)`);
|
||||
} else {
|
||||
parts.push(countryName);
|
||||
}
|
||||
|
||||
const text = parts.join(", ");
|
||||
const flag = COUNTRY_FLAGS[target.country as SupportedCountryCode] || "";
|
||||
|
||||
return `${flag} ${text}`.trim();
|
||||
}
|
||||
174
skyvern-frontend/src/util/geoSearch.ts
Normal file
174
skyvern-frontend/src/util/geoSearch.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { GeoTarget } from "@/api/types";
|
||||
import {
|
||||
COUNTRY_FLAGS,
|
||||
COUNTRY_NAMES,
|
||||
SUPPORTED_COUNTRY_CODES,
|
||||
SupportedCountryCode,
|
||||
} from "./geoData";
|
||||
|
||||
export type SearchResultItem = {
|
||||
type: "country" | "subdivision" | "city";
|
||||
label: string;
|
||||
value: GeoTarget;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type GroupedSearchResults = {
|
||||
countries: SearchResultItem[];
|
||||
subdivisions: SearchResultItem[];
|
||||
cities: SearchResultItem[];
|
||||
};
|
||||
|
||||
let cscModule: typeof import("country-state-city") | null = null;
|
||||
|
||||
async function loadCsc() {
|
||||
if (!cscModule) {
|
||||
cscModule = await import("country-state-city");
|
||||
}
|
||||
return cscModule;
|
||||
}
|
||||
|
||||
export async function searchGeoData(
|
||||
query: string,
|
||||
): Promise<GroupedSearchResults> {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const queryMatchesISP = normalizedQuery.includes("isp");
|
||||
const results: GroupedSearchResults = {
|
||||
countries: [],
|
||||
subdivisions: [],
|
||||
cities: [],
|
||||
};
|
||||
|
||||
// 1. Countries (Always search supported countries)
|
||||
// We can do this without loading the heavy lib if we use our hardcoded lists
|
||||
SUPPORTED_COUNTRY_CODES.forEach((code) => {
|
||||
const name = COUNTRY_NAMES[code];
|
||||
const matchesCountry =
|
||||
name.toLowerCase().includes(normalizedQuery) ||
|
||||
code.toLowerCase().includes(normalizedQuery);
|
||||
const shouldIncludeUSForISP = code === "US" && queryMatchesISP;
|
||||
if (matchesCountry || shouldIncludeUSForISP) {
|
||||
results.countries.push({
|
||||
type: "country",
|
||||
label: name,
|
||||
value: { country: code },
|
||||
description: code,
|
||||
icon: COUNTRY_FLAGS[code],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (results.countries.length > 0) {
|
||||
const usIndex = results.countries.findIndex(
|
||||
(item) => item.value.country === "US" && !item.value.isISP,
|
||||
);
|
||||
if (usIndex !== -1 || queryMatchesISP) {
|
||||
const ispItem: SearchResultItem = {
|
||||
type: "country",
|
||||
label: "United States (ISP)",
|
||||
value: { country: "US", isISP: true },
|
||||
description: "US",
|
||||
icon: COUNTRY_FLAGS.US,
|
||||
};
|
||||
const insertIndex = usIndex !== -1 ? usIndex + 1 : 0;
|
||||
results.countries.splice(insertIndex, 0, ispItem);
|
||||
}
|
||||
}
|
||||
|
||||
// If query is very short, just return countries to save perf
|
||||
if (normalizedQuery.length < 2) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 2. Subdivisions & Cities (Load heavy lib)
|
||||
const csc = await loadCsc();
|
||||
|
||||
// Search Subdivisions
|
||||
// We only search subdivisions of SUPPORTED countries
|
||||
for (const countryCode of SUPPORTED_COUNTRY_CODES) {
|
||||
const states = csc.State.getStatesOfCountry(countryCode);
|
||||
for (const state of states) {
|
||||
if (
|
||||
state.name.toLowerCase().includes(normalizedQuery) ||
|
||||
state.isoCode.toLowerCase() === normalizedQuery
|
||||
) {
|
||||
results.subdivisions.push({
|
||||
type: "subdivision",
|
||||
label: state.name,
|
||||
value: { country: countryCode, subdivision: state.isoCode },
|
||||
description: `${state.isoCode}, ${COUNTRY_NAMES[countryCode]}`,
|
||||
icon: COUNTRY_FLAGS[countryCode],
|
||||
});
|
||||
}
|
||||
|
||||
// Limit subdivisions per country to avoid overwhelming
|
||||
if (results.subdivisions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Search Cities
|
||||
// Searching ALL cities of ALL supported countries is heavy.
|
||||
// We optimize by breaking early once we have enough results.
|
||||
const prefixMatches: SearchResultItem[] = [];
|
||||
const partialMatches: SearchResultItem[] = [];
|
||||
|
||||
for (const countryCode of SUPPORTED_COUNTRY_CODES) {
|
||||
const cities = csc.City.getCitiesOfCountry(countryCode) || [];
|
||||
|
||||
for (const city of cities) {
|
||||
const nameLower = city.name.toLowerCase();
|
||||
const item: SearchResultItem = {
|
||||
type: "city",
|
||||
label: city.name,
|
||||
value: {
|
||||
country: countryCode,
|
||||
subdivision: city.stateCode,
|
||||
city: city.name,
|
||||
},
|
||||
description: `${city.stateCode}, ${COUNTRY_NAMES[countryCode]}`,
|
||||
icon: COUNTRY_FLAGS[countryCode],
|
||||
};
|
||||
|
||||
if (nameLower === normalizedQuery) {
|
||||
// Exact match goes to the front
|
||||
prefixMatches.unshift(item);
|
||||
} else if (nameLower.startsWith(normalizedQuery)) {
|
||||
prefixMatches.push(item);
|
||||
} else if (nameLower.includes(normalizedQuery)) {
|
||||
partialMatches.push(item);
|
||||
}
|
||||
|
||||
// Break if we have enough total cities
|
||||
if (prefixMatches.length + partialMatches.length > 100) break;
|
||||
}
|
||||
if (prefixMatches.length + partialMatches.length > 100) break;
|
||||
}
|
||||
|
||||
results.cities = [...prefixMatches, ...partialMatches];
|
||||
|
||||
// Slice to final limits
|
||||
results.subdivisions = results.subdivisions.slice(0, 5);
|
||||
results.cities = results.cities.slice(0, 20);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getCountryName(code: string): Promise<string> {
|
||||
if (code in COUNTRY_NAMES) {
|
||||
return COUNTRY_NAMES[code as SupportedCountryCode];
|
||||
}
|
||||
const csc = await loadCsc();
|
||||
return csc.Country.getCountryByCode(code)?.name || code;
|
||||
}
|
||||
|
||||
export async function getSubdivisionName(
|
||||
countryCode: string,
|
||||
subdivisionCode: string,
|
||||
): Promise<string> {
|
||||
const csc = await loadCsc();
|
||||
return (
|
||||
csc.State.getStateByCodeAndCountry(subdivisionCode, countryCode)?.name ||
|
||||
subdivisionCode
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user