From 48c3237f3660152f947b3c73ab8712c4b8628cad Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Wed, 22 Oct 2025 23:41:07 +0300 Subject: [PATCH 01/23] wip --- .../_components/AnimatedIcons/ChartLine.tsx | 102 ++++++++++++++++++ .../_components/AnimatedIcons/index.ts | 3 +- .../dashboard/_components/Navbar/Items.tsx | 8 +- .../dashboard/_components/Navbar/Top.tsx | 1 + .../app/(org)/dashboard/analytics/page.tsx | 3 + .../(org)/dashboard/spaces/browse/page.tsx | 2 +- 6 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/page.tsx diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx new file mode 100644 index 0000000000..dc6b5a80e6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx @@ -0,0 +1,102 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface ChartLineIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface ChartLineIconProps extends HTMLAttributes { + size?: number; +} + +const variants: Variants = { + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + transition: { + delay: 0.15, + duration: 0.3, + opacity: { delay: 0.1 }, + }, + }, +}; + +const ChartLineIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + +
+ ); + }, +); + +ChartLineIcon.displayName = "ChartLineIcon"; + +export default ChartLineIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts index 17c438c799..03d1b8fb79 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts @@ -1,5 +1,6 @@ import ArrowUpIcon from "./ArrowUp"; import CapIcon from "./Cap"; +import ChartLineIcon from "./ChartLine"; import MessageCircleMoreIcon from "./Chat"; import CogIcon from "./Cog"; import DownloadIcon from "./Download"; @@ -8,7 +9,6 @@ import LayersIcon from "./Layers"; import LogoutIcon from "./Logout"; import ReferIcon from "./Refer"; import SettingsGearIcon from "./Settings"; - export { ArrowUpIcon, CapIcon, @@ -20,4 +20,5 @@ export { LogoutIcon, SettingsGearIcon, ReferIcon, + ChartLineIcon, }; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 4515df3fe4..d0a0ba2389 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -36,7 +36,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; import { useDashboardContext } from "../../Contexts"; -import { CapIcon, CogIcon } from "../AnimatedIcons"; +import { CapIcon, ChartLineIcon, CogIcon } from "../AnimatedIcons"; import type { CogIconHandle } from "../AnimatedIcons/Cog"; import CapAIBox from "./CapAIBox"; import SpacesList from "./SpacesList"; @@ -59,6 +59,12 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { icon: , subNav: [], }, + { + name: "Analytics", + href: `/dashboard/analytics`, + icon: , + subNav: [], + }, { name: "Organization Settings", href: `/dashboard/settings/organization`, diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index c1bc6469ba..c35ab2a9b5 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -62,6 +62,7 @@ const Top = () => { "/dashboard/settings/account": "Account Settings", "/dashboard/spaces": "Spaces", "/dashboard/spaces/browse": "Browse Spaces", + "/dashboard/analytics": "Analytics", }; const title = activeSpace diff --git a/apps/web/app/(org)/dashboard/analytics/page.tsx b/apps/web/app/(org)/dashboard/analytics/page.tsx new file mode 100644 index 0000000000..00709239c2 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/page.tsx @@ -0,0 +1,3 @@ +export default function AnalyticsPage() { + return
; +} diff --git a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx index b34d20368d..76cfa9f0ca 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar, Button, Input } from "@cap/ui"; +import { Button, Input } from "@cap/ui"; import { faEdit, faLayerGroup, From d268fa0cf11710e01de190821276da87ef5d582b Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:58:27 +0300 Subject: [PATCH 02/23] wip --- .../app/(org)/dashboard/analytics/page.tsx | 33 +++- packages/ui/src/components/Select.tsx | 155 +++++++++++++----- 2 files changed, 144 insertions(+), 44 deletions(-) diff --git a/apps/web/app/(org)/dashboard/analytics/page.tsx b/apps/web/app/(org)/dashboard/analytics/page.tsx index 00709239c2..82c45101a8 100644 --- a/apps/web/app/(org)/dashboard/analytics/page.tsx +++ b/apps/web/app/(org)/dashboard/analytics/page.tsx @@ -1,3 +1,34 @@ +"use client"; + +import { Select } from "@cap/ui"; + export default function AnalyticsPage() { - return
; + return ( + <> +
+ {}} + placeholder="Time range" + /> +
+ + ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index e779fd0ed0..e26b9e0a02 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -1,17 +1,104 @@ "use client"; -import { classNames } from "@cap/utils"; import * as SelectPrimitive from "@radix-ui/react-select"; +import { cva, cx } from "class-variance-authority"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import type * as React from "react"; +import * as React from "react"; + +type SelectVariant = "default" | "dark" | "white" | "gray" | "transparent"; + +const SelectVariantContext = React.createContext("default"); + +const selectTriggerVariants = cva( + cx( + "font-medium flex px-4 py-2 transition-all duration-200 text-[13px] outline-0", + "rounded-xl border-[1px] items-center justify-between gap-2 whitespace-nowrap", + "disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-gray-3 disabled:text-gray-9", + "ring-0 ring-gray-3 focus:ring-1 focus:ring-gray-12 focus:ring-offset-2 ring-offset-gray-4", + "data-[placeholder]:text-gray-1 data-[size=default]:h-[44px] data-[size=sm]:h-[40px]", + "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + ), + { + defaultVariants: { + variant: "default", + size: "default", + }, + variants: { + variant: { + default: + "bg-gray-2 border-gray-5 text-gray-12 hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", + dark: "bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5 hover:bg-gray-11 hover:border-gray-6 focus:bg-gray-11 focus:border-gray-6", + white: + "bg-gray-1 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", + gray: "bg-gray-5 text-gray-12 border-gray-5 hover:bg-gray-7 hover:border-gray-6 focus:bg-gray-7 focus:border-gray-6", + transparent: + "bg-transparent text-gray-12 border-transparent hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", + }, + size: { + default: "w-full", + fit: "w-fit", + }, + }, + }, +); + +const selectContentVariants = cva( + cx( + "rounded-xl border-[1px] overflow-hidden", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + ), + { + defaultVariants: { + variant: "default", + }, + variants: { + variant: { + default: "bg-gray-2 border-gray-5 text-gray-12", + dark: "hover:bg-gray-11/50 bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5", + white: "bg-gray-1 text-gray-12 border-gray-5", + gray: "bg-gray-5 text-gray-12 border-gray-5", + transparent: "bg-transparent text-gray-12 border-transparent", + }, + }, + }, +); + +const selectItemVariants = cva( + cx( + "relative flex w-full cursor-default items-center gap-2 py-2 pr-8 pl-3 text-[13px]", + "rounded-lg outline-none select-none transition-colors duration-200", + "data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:text-gray-9", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + ), + { + defaultVariants: { + variant: "default", + }, + variants: { + variant: { + default: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", + dark: "text-gray-1 hover:bg-[var(--gray-11-40)] focus:bg-[var(--gray-11-40)]", + white: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", + gray: "text-gray-12 hover:bg-gray-6 focus:bg-gray-6", + transparent: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", + }, + }, + }, +); function Select({ + className, + variant = "default", + size = "default", options, placeholder, onValueChange, value, ...props }: React.ComponentProps & { + className?: string; + variant?: SelectVariant; options: { value: string; label: string; @@ -19,6 +106,7 @@ function Select({ }[]; onValueChange: (value: string) => void; placeholder: string; + size?: "default" | "fit"; }) { return ( - + - + {options.map((option) => (
@@ -65,25 +153,18 @@ function SelectValue({ function SelectTrigger({ className, size = "default", + variant = "default", children, ...props }: React.ComponentProps & { - size?: "sm" | "default"; + size?: "default" | "fit"; + variant?: SelectVariant; }) { return ( {children} @@ -98,34 +179,30 @@ function SelectContent({ className, children, position = "popper", + variant = "default", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + variant?: SelectVariant; +}) { return ( - {children} + + {children} + @@ -140,7 +217,7 @@ function SelectLabel({ return ( ); @@ -151,17 +228,12 @@ function SelectItem({ children, ...props }: React.ComponentProps) { + const variant = React.useContext(SelectVariantContext); + return ( @@ -181,10 +253,7 @@ function SelectSeparator({ return ( ); @@ -197,7 +266,7 @@ function SelectScrollUpButton({ return ( Date: Mon, 27 Oct 2025 16:29:58 +0300 Subject: [PATCH 03/23] ui --- .../_components/AnimatedIcons/Clap.tsx | 120 +++++ .../_components/AnimatedIcons/Reaction.tsx | 170 ++++++ .../_components/AnimatedIcons/index.ts | 6 + .../analytics/components/ChartArea.tsx | 91 ++++ .../dashboard/analytics/components/Header.tsx | 32 ++ .../analytics/components/OtherStats.tsx | 453 ++++++++++++++++ .../analytics/components/StatsChart.tsx | 117 +++++ .../app/(org)/dashboard/analytics/page.tsx | 37 +- apps/web/app/globals.css | 2 +- apps/web/components/ui/chart.tsx | 360 +++++++++++++ apps/web/package.json | 2 + apps/web/public/logos/browsers/aloha.svg | 14 + apps/web/public/logos/browsers/bing.svg | 3 + apps/web/public/logos/browsers/brave.svg | 18 + apps/web/public/logos/browsers/chronium.svg | 40 ++ apps/web/public/logos/browsers/duckduckgo.svg | 29 ++ apps/web/public/logos/browsers/explorer.svg | 41 ++ apps/web/public/logos/browsers/firefox.svg | 106 ++++ .../public/logos/browsers/google-chrome.svg | 16 + apps/web/public/logos/browsers/maxthron.svg | 6 + apps/web/public/logos/browsers/opera-gx.svg | 4 + apps/web/public/logos/browsers/opera.svg | 18 + apps/web/public/logos/browsers/safari.svg | 29 ++ apps/web/public/logos/browsers/tor.svg | 12 + apps/web/public/logos/browsers/vivaldi.svg | 11 + apps/web/public/logos/browsers/waterfox.svg | 6 + apps/web/public/logos/browsers/yandex.svg | 11 + apps/web/public/logos/os/fedora.svg | 16 + apps/web/public/logos/os/ios.svg | 1 + apps/web/public/logos/os/linux.svg | 111 ++++ apps/web/public/logos/os/ubuntu.svg | 1 + apps/web/public/logos/os/windows.svg | 1 + packages/ui/src/components/Select.tsx | 38 +- pnpm-lock.yaml | 493 ++++++++++++++---- 34 files changed, 2270 insertions(+), 145 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx create mode 100644 apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/Header.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx create mode 100644 apps/web/components/ui/chart.tsx create mode 100644 apps/web/public/logos/browsers/aloha.svg create mode 100644 apps/web/public/logos/browsers/bing.svg create mode 100644 apps/web/public/logos/browsers/brave.svg create mode 100644 apps/web/public/logos/browsers/chronium.svg create mode 100644 apps/web/public/logos/browsers/duckduckgo.svg create mode 100644 apps/web/public/logos/browsers/explorer.svg create mode 100644 apps/web/public/logos/browsers/firefox.svg create mode 100644 apps/web/public/logos/browsers/google-chrome.svg create mode 100644 apps/web/public/logos/browsers/maxthron.svg create mode 100644 apps/web/public/logos/browsers/opera-gx.svg create mode 100644 apps/web/public/logos/browsers/opera.svg create mode 100644 apps/web/public/logos/browsers/safari.svg create mode 100644 apps/web/public/logos/browsers/tor.svg create mode 100644 apps/web/public/logos/browsers/vivaldi.svg create mode 100644 apps/web/public/logos/browsers/waterfox.svg create mode 100644 apps/web/public/logos/browsers/yandex.svg create mode 100644 apps/web/public/logos/os/fedora.svg create mode 100644 apps/web/public/logos/os/ios.svg create mode 100644 apps/web/public/logos/os/linux.svg create mode 100644 apps/web/public/logos/os/ubuntu.svg create mode 100644 apps/web/public/logos/os/windows.svg diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx new file mode 100644 index 0000000000..c112a20b8b --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx @@ -0,0 +1,120 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface ClapIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface ClapIconProps extends HTMLAttributes { + size?: number; +} + +const variants: Variants = { + normal: { + rotate: 0, + originX: "4px", + originY: "20px", + }, + animate: { + rotate: [-10, -10, 0], + transition: { + duration: 0.8, + times: [0, 0.5, 1], + ease: "easeInOut", + }, + }, +}; + +const clapVariants: Variants = { + normal: { + rotate: 0, + originX: "3px", + originY: "11px", + }, + animate: { + rotate: [0, -10, 16, 0], + transition: { + duration: 0.4, + times: [0, 0.3, 0.6, 1], + ease: "easeInOut", + }, + }, +}; + +const ClapIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + return ( +
+ + + + + + + + + + +
+ ); + }, +); + +ClapIcon.displayName = "ClapIcon"; + +export default ClapIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx new file mode 100644 index 0000000000..931221098c --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx @@ -0,0 +1,170 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface PartyPopperIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface PartyPopperIconProps extends HTMLAttributes { + size?: number; +} + +const linesVariants: Variants = { + normal: { + opacity: 1, + pathLength: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + animate: { + opacity: [0, 1], + scale: [0.3, 0.8, 1, 1.1, 1], + pathLength: [0, 0.5, 1], + translateX: [-5, 0], + translateY: [5, 0], + transition: { + duration: 0.7, + velocity: 0.3, + }, + }, +}; + +const dotsVariants: Variants = { + normal: { opacity: 1, scale: 1, translateX: 0, translateY: 0 }, + animate: { + opacity: [0, 1], + translateX: [-5, 0], + translateY: [5, 0], + scale: [0.5, 0.8, 1, 1.1, 1], + transition: { + duration: 0.7, + }, + }, +}; + +const popperVariants: Variants = { + normal: { translateX: 0, translateY: 0 }, + animate: { + translateX: [-1.5, 0], + translateY: [1.5, 0], + transition: { + velocity: 0.3, + }, + }, +}; + +const PartyPopperIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + + + + + + + + +
+ ); + }, +); + +PartyPopperIcon.displayName = "PartyPopperIcon"; + +export default PartyPopperIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts index 03d1b8fb79..1ff495d185 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts @@ -2,11 +2,14 @@ import ArrowUpIcon from "./ArrowUp"; import CapIcon from "./Cap"; import ChartLineIcon from "./ChartLine"; import MessageCircleMoreIcon from "./Chat"; +import ChatIcon from "./Chat"; +import ClapIcon from "./Clap"; import CogIcon from "./Cog"; import DownloadIcon from "./Download"; import HomeIcon from "./Home"; import LayersIcon from "./Layers"; import LogoutIcon from "./Logout"; +import ReactionIcon from "./Reaction"; import ReferIcon from "./Refer"; import SettingsGearIcon from "./Settings"; export { @@ -21,4 +24,7 @@ export { SettingsGearIcon, ReferIcon, ChartLineIcon, + ClapIcon, + ChatIcon, + ReactionIcon, }; diff --git a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx new file mode 100644 index 0000000000..83d55aa392 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useId } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +export const description = "An area chart with gradient fill"; + +const chartData = Array.from({ length: 24 }, (_, i) => ({ + hour: i + 1, + desktop: Math.floor(Math.random() * 100), +})); + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--gray-12)", + }, +} satisfies ChartConfig; + +function ChartArea() { + const desktopGradientId = useId(); + const glowFilterId = useId(); + + return ( + + + + + + } /> + + + + + + + + + + + + + + + + + ); +} + +export default ChartArea; diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx new file mode 100644 index 0000000000..56775a4e02 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Select } from "@cap/ui"; + +export default function Header() { + return ( +
+ {}} + placeholder="Time range" + /> +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx new file mode 100644 index 0000000000..c778db0e74 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@cap/ui"; +import { + faAppleWhole, + faDesktop, + faGlobe, + faMobileScreen, + faTablet, +} from "@fortawesome/free-solid-svg-icons"; +import { + FontAwesomeIcon, + type FontAwesomeIconProps, +} from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import getUnicodeFlagIcon from "country-flag-icons/unicode"; +import Image from "next/image"; + +const countryCodeToIcon = (countryCode: string) => { + return getUnicodeFlagIcon(countryCode.toUpperCase()); +}; + +export default function OtherStats() { + return ( +
+ +
+ + +
+
+ +
+ , + name: "Chrome", + percentage: "45.6%", + views: "1,234", + }, + { + icon: , + name: "Firefox", + percentage: "23.4%", + views: "892", + }, + { + icon: , + name: "Safari", + percentage: "30.9%", + views: "567", + }, + { + icon: , + name: "Edge", + percentage: "15.6%", + views: "432", + }, + { + icon: , + name: "Opera", + percentage: "10.9%", + views: "432", + }, + { + icon: , + name: "Brave", + percentage: "5.6%", + views: "432", + }, + { + icon: , + name: "Vivaldi", + percentage: "3.4%", + views: "432", + }, + { + icon: , + name: "Yandex", + percentage: "2.9%", + views: "432", + }, + { + icon: , + name: "DuckDuckGo", + percentage: "1.6%", + views: "432", + }, + ]} + /> + , + name: "Windows", + views: "1,234", + percentage: "45.6%", + }, + { + icon: , + name: "iOS", + views: "892", + percentage: "23.4%", + }, + { + icon: , + name: "Linux", + views: "892", + percentage: "15.6%", + }, + { + icon: , + name: "Fedora", + views: "892", + percentage: "10.9%", + }, + { + icon: , + name: "Ubuntu", + views: "892", + percentage: "10.9%", + }, + ]} + /> +
+
+ +
+ + ), + name: "Desktop", + views: "2,456", + percentage: "45.6%", + }, + { + icon: ( + + ), + name: "Tablet", + views: "1,234", + percentage: "23.4%", + }, + { + icon: ( + + ), + name: "Mobile", + views: "1,789", + percentage: "30.9%", + }, + ]} + /> +
+
+
+ ); +} + +interface OtherStatBoxProps { + title: string; + icon: FontAwesomeIconProps["icon"]; + children: React.ReactNode; + className?: string; +} + +const OtherStatBox = ({ + title, + icon, + children, + className, +}: OtherStatBoxProps) => { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +}; + +interface TableCardProps { + title: string; + columns: string[]; + tableClassname?: string; + rows: { + icon?: string | React.ReactNode; + name: string; + views: string; + percentage?: string; + }[]; +} + +const TableCard = ({ + title, + columns, + rows, + tableClassname, +}: TableCardProps) => { + return ( +
+

{title}

+ + + + {columns.map((column) => ( + + {column} + + ))} + + + + {rows.map((row) => ( + + + + {row.icon} + + + {row.name} + + + + {row.views} + + + {row.percentage} + + + ))} + +
+
+ ); +}; + +type OperatingSystemIconProps = { + operatingSystem: "windows" | "ios" | "linux" | "fedora" | "ubuntu"; +}; + +const OperatingSystemIcon = ({ operatingSystem }: OperatingSystemIconProps) => { + if (operatingSystem === "ios") { + return ; + } else { + return ( + {operatingSystem} + ); + } +}; + +type BrowserIconProps = { + browser: + | "google-chrome" + | "firefox" + | "safari" + | "explorer" + | "opera" + | "brave" + | "vivaldi" + | "yandex" + | "duckduckgo" + | "internet-explorer" + | "samsung-internet" + | "uc-browser" + | "qq-browser" + | "maxthon" + | "arora" + | "lunascape" + | "lunascape"; +}; + +const BrowserIcon = ({ browser }: BrowserIconProps) => { + return ( + {browser} + ); +}; diff --git a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx new file mode 100644 index 0000000000..4b88c78107 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + cloneElement, + type HTMLAttributes, + type SVGProps, + useRef, + useState, +} from "react"; +import { + CapIcon, + ChatIcon, + ClapIcon, + ReactionIcon, +} from "@/app/(org)/dashboard/_components/AnimatedIcons"; +import { classNames } from "@/utils/helpers"; +import type { CapIconHandle } from "../../_components/AnimatedIcons/Cap"; +import ChartArea from "./ChartArea"; + +export default function StatsBox() { + const [selectedBox, setSelectedBox] = useState< + "caps" | "views" | "chats" | "reactions" | null + >(null); + + const capsBoxRef = useRef(null); + const viewsBoxRef = useRef(null); + const chatsBoxRef = useRef(null); + const reactionsBoxRef = useRef(null); + + return ( +
+
+ setSelectedBox("caps")} + isSelected={selectedBox === "caps"} + title="Caps" + value="100" + onMouseEnter={() => capsBoxRef.current?.startAnimation()} + onMouseLeave={() => capsBoxRef.current?.stopAnimation()} + icon={} + /> + setSelectedBox("views")} + isSelected={selectedBox === "views"} + title="Views" + value="2,768" + onMouseEnter={() => viewsBoxRef.current?.startAnimation()} + onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} + icon={} + /> + setSelectedBox("chats")} + isSelected={selectedBox === "chats"} + title="Comments" + value="100" + onMouseEnter={() => chatsBoxRef.current?.startAnimation()} + onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} + icon={} + /> + setSelectedBox("reactions")} + isSelected={selectedBox === "reactions"} + title="Reactions" + value="100" + onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} + onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} + icon={} + /> +
+ +
+ ); +} + +interface StatBoxProps extends HTMLAttributes { + title: string; + value: string; + icon: React.ReactElement>; + isSelected?: boolean; +} +function StatBox({ + title, + value, + icon, + isSelected = false, + ...props +}: StatBoxProps) { + return ( +
+
+ {cloneElement(icon, { + className: classNames( + "group-hover:text-gray-12 transition-colors duration-200", + isSelected ? "text-gray-12" : "text-gray-10", + ), + })} +

+ {title} +

+
+

+ {value} +

+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/page.tsx b/apps/web/app/(org)/dashboard/analytics/page.tsx index 82c45101a8..37c38d9d84 100644 --- a/apps/web/app/(org)/dashboard/analytics/page.tsx +++ b/apps/web/app/(org)/dashboard/analytics/page.tsx @@ -1,34 +1,13 @@ -"use client"; - -import { Select } from "@cap/ui"; +import Header from "./components/Header"; +import OtherStats from "./components/OtherStats"; +import StatsChart from "./components/StatsChart"; export default function AnalyticsPage() { return ( - <> -
- {}} - placeholder="Time range" - /> -
- +
+
+ + +
); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bff65ab6a0..cb9cd74069 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -342,7 +342,7 @@ span, input, label, button { - @apply tracking-normal font-normal leading-[1.5rem]; + @apply tracking-normal leading-[1.5rem]; } a { diff --git a/apps/web/components/ui/chart.tsx b/apps/web/components/ui/chart.tsx new file mode 100644 index 0000000000..debdbfd409 --- /dev/null +++ b/apps/web/components/ui/chart.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { cx } from "class-variance-authority"; +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "ChartContainer"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( + \ No newline at end of file diff --git a/apps/web/public/logos/os/windows.svg b/apps/web/public/logos/os/windows.svg new file mode 100644 index 0000000000..2c7392e9c9 --- /dev/null +++ b/apps/web/public/logos/os/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index e26b9e0a02..8c38dc0067 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -5,7 +5,7 @@ import { cva, cx } from "class-variance-authority"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react"; -type SelectVariant = "default" | "dark" | "white" | "gray" | "transparent"; +type SelectVariant = "default" | "dark" | "gray" | "transparent"; const SelectVariantContext = React.createContext("default"); @@ -14,10 +14,11 @@ const selectTriggerVariants = cva( "font-medium flex px-4 py-2 transition-all duration-200 text-[13px] outline-0", "rounded-xl border-[1px] items-center justify-between gap-2 whitespace-nowrap", "disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-gray-3 disabled:text-gray-9", - "ring-0 ring-gray-3 focus:ring-1 focus:ring-gray-12 focus:ring-offset-2 ring-offset-gray-4", + "ring-0 ring-gray-12 ring-offset-0 data-[state=open]:ring-offset-2 ring-offset-gray-1 data-[state=open]:ring-1", "data-[placeholder]:text-gray-1 data-[size=default]:h-[44px] data-[size=sm]:h-[40px]", "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", - "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:transition-transform [&_svg]:duration-200", + "[&[data-state=open]_svg]:rotate-180", ), { defaultVariants: { @@ -27,13 +28,13 @@ const selectTriggerVariants = cva( variants: { variant: { default: - "bg-gray-2 border-gray-5 text-gray-12 hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", - dark: "bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5 hover:bg-gray-11 hover:border-gray-6 focus:bg-gray-11 focus:border-gray-6", + "bg-gray-2 border-gray-5 text-gray-12 hover:bg-gray-3 hover:border-gray-6", + dark: "bg-gray-12 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-1 border-gray-5 hover:bg-gray-11 hover:border-gray-6", white: - "bg-gray-1 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", - gray: "bg-gray-5 text-gray-12 border-gray-5 hover:bg-gray-7 hover:border-gray-6 focus:bg-gray-7 focus:border-gray-6", + "bg-gray-1 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6", + gray: "bg-gray-5 text-gray-12 border-gray-5 hover:bg-gray-7 hover:border-gray-6", transparent: - "bg-transparent text-gray-12 border-transparent hover:bg-gray-3 hover:border-gray-6 focus:bg-gray-3 focus:border-gray-6", + "bg-transparent text-gray-12 border-transparent hover:bg-gray-3 hover:border-gray-6", }, size: { default: "w-full", @@ -47,6 +48,11 @@ const selectContentVariants = cva( cx( "rounded-xl border-[1px] overflow-hidden", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", ), { defaultVariants: { @@ -55,10 +61,10 @@ const selectContentVariants = cva( variants: { variant: { default: "bg-gray-2 border-gray-5 text-gray-12", - dark: "hover:bg-gray-11/50 bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5", - white: "bg-gray-1 text-gray-12 border-gray-5", + dark: "hover:bg-gray-11-50 bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5", gray: "bg-gray-5 text-gray-12 border-gray-5", - transparent: "bg-transparent text-gray-12 border-transparent", + transparent: + "bg-transparent hover:bg-gray-3 text-gray-12 border-transparent", }, }, }, @@ -78,10 +84,10 @@ const selectItemVariants = cva( variants: { variant: { default: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", - dark: "text-gray-1 hover:bg-[var(--gray-11-40)] focus:bg-[var(--gray-11-40)]", - white: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", - gray: "text-gray-12 hover:bg-gray-6 focus:bg-gray-6", - transparent: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", + dark: "text-gray-1 hover:text-gray-12 focus:text-gray-12 hover:bg-gray-1 focus:bg-gray-1", + gray: "text-gray-12 hover:text-gray-12 hover:bg-gray-6 focus:bg-gray-6", + transparent: + "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3", }, }, }, @@ -118,7 +124,7 @@ function Select({ - + {options.map((option) => (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08cf69e61..a696b85955 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 0.14.10(solid-js@1.9.6) '@solidjs/start': specifier: ^1.1.3 - version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) + version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.75.4(solid-js@1.9.6) @@ -202,7 +202,7 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) webcodecs: specifier: ^0.1.0 version: 0.1.0 @@ -636,6 +636,9 @@ importers: cookies-next: specifier: ^4.1.0 version: 4.3.0 + country-flag-icons: + specifier: ^1.5.21 + version: 1.5.21 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -704,7 +707,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-contentlayer2: specifier: ^0.5.3 version: 0.5.8(acorn@8.15.0)(contentlayer2@0.5.8(acorn@8.15.0)(esbuild@0.25.5))(esbuild@0.25.5)(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -747,6 +750,9 @@ importers: react-tooltip: specifier: ^5.26.3 version: 5.28.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + recharts: + specifier: ^3.3.0 + version: 3.3.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react-is@18.3.1)(react@19.1.1)(redux@5.0.1) resend: specifier: 4.6.0 version: 4.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1017,7 +1023,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -4517,6 +4523,9 @@ packages: '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} + '@oxc-project/types@0.95.0': + resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -5418,6 +5427,17 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@reduxjs/toolkit@2.9.2': + resolution: {integrity: sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@remix-run/router@1.23.0': resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} @@ -5445,8 +5465,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-TP8bcPOb1s6UmY5syhXrDn9k0XkYcw+XaoylTN4cJxf0JOVS2j682I3aTcpfT51hOFGr2bRwNKN9RZ19XxeQbA==} + '@rolldown/binding-android-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -5457,8 +5477,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-kuVWnZsE4vEjMF/10SbSUyzucIW2zmdsqFghYMqy+fsjXnRHg0luTU6qWF8IqJf4Cbpm9NEZRnjIEPpAbdiSNQ==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -5469,8 +5489,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.43': - resolution: {integrity: sha512-u9Ps4sh6lcmJ3vgLtyEg/x4jlhI64U0mM93Ew+tlfFdLDe7yKyA+Fe80cpr2n1mNCeZXrvTSbZluKpXQ0GxLjw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.45': + resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -5481,8 +5501,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': - resolution: {integrity: sha512-h9lUtVtXgfbk/tnicMpbFfZ3DJvk5Zn2IvmlC1/e0+nUfwoc/TFqpfrRRqcNBXk/e+xiWMSKv6b0MF8N+Rtvlg==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': + resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -5493,8 +5513,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': - resolution: {integrity: sha512-IX2C6bA6wM2rX/RvD75ko+ix9yxPKjKGGq7pOhB8wGI4Z4fqX5B1nDHga/qMDmAdCAR1m9ymzxkmqhm/AFYf7A==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': + resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -5505,8 +5525,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-mcjd57vEj+CEQbZAzUiaxNzNgwwgOpFtZBWcINm8DNscvkXl5b/s622Z1dqGNWSdrZmdjdC6LWMvu8iHM6v9sQ==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5517,8 +5537,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-Pa8QMwlkrztTo/1mVjZmPIQ44tCSci10TBqxzVBvXVA5CFh5EpiEi99fPSll2dHG2uT4dCOMeC6fIhyDdb0zXA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5529,8 +5549,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-BgynXKMjeaX4AfWLARhOKDetBOOghnSiVRjAHVvhiAaDXgdQN8e65mSmXRiVoVtD3cHXx/cfU8Gw0p0K+qYKVQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5541,8 +5561,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-VIsoPlOB/tDSAw9CySckBYysoIBqLeps1/umNSYUD8pMtalJyzMTneAVI1HrUdf4ceFmQ5vARoLIXSsPwVFxNg==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5553,8 +5573,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-YDXTxVJG67PqTQMKyjVJSddoPbSWJ4yRz/E3xzTLHqNrTDGY0UuhG8EMr8zsYnfH/0cPFJ3wjQd/hJWHuR6nkA==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -5564,8 +5584,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': - resolution: {integrity: sha512-3M+2DmorXvDuAIGYQ9Z93Oy1G9ETkejLwdXXb1uRTgKN9pMcu7N+KG2zDrJwqyxeeLIFE22AZGtSJm3PJbNu9Q==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': + resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -5575,8 +5595,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-/B1j1pJs33y9ywtslOMxryUPHq8zIGu/OGEc2gyed0slimJ8fX2uR/SaJVhB4+NEgCFIeYDR4CX6jynAkeRuCA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -5587,8 +5607,8 @@ packages: cpu: [ia32] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-29oG1swCz7hNP+CQYrsM4EtylsKwuYzM8ljqbqC5TsQwmKat7P8ouDpImsqg/GZxFSXcPP9ezQm0Q0wQwGM3JA==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] @@ -5599,8 +5619,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-eWBV1Ef3gfGNehxVGCyXs7wLayRIgCmyItuCZwYYXW5bsk4EvR4n2GP5m3ohjnx7wdiY3nLmwQfH2Knb5gbNZw==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5608,8 +5628,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.42': resolution: {integrity: sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==} - '@rolldown/pluginutils@1.0.0-beta.43': - resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rolldown/pluginutils@1.0.0-beta.45': + resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -6419,6 +6439,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/addon-actions@8.6.12': resolution: {integrity: sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==} peerDependencies: @@ -6495,10 +6518,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.0.0-beta.13': - resolution: {integrity: sha512-ZPxqgoofi2yfJRQkkEBINSzPjkhrgfh4HsPs4fsbPt3jfVyfP4Wic7vHep4UlR1os2iL2MTRLcapkkiajoztWQ==} + '@storybook/builder-vite@10.0.0-rc.2': + resolution: {integrity: sha512-Vp1xN+h7BKQ6k+zE2LHExfE65yL2d2q9TiVrIHJ2O7dukVkd5UYBOS9CLVenidgfbFRzt2hUQPLLKhTjJLwMmA==} peerDependencies: - storybook: ^10.0.0-beta.13 + storybook: ^10.0.0-rc.2 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6509,12 +6532,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.0.0-beta.13': - resolution: {integrity: sha512-mjdDsl5MAn6bLZHHInut+TQjYUJDdErpXnYlXPLvZRoaCxqxLN2O2sf5LmxAP9SRjEdKnsLFvBwMCJxVcav/lQ==} + '@storybook/csf-plugin@10.0.0-rc.2': + resolution: {integrity: sha512-GCAlUdEbcdwaDT4JL8N/Dv0w3ZMNf80tdiFC26PMK+UZBBd9DKyEJfDTd2NLSzDXEG1DGRUKJymDec8ocFFqxQ==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.0-beta.13 + storybook: ^10.0.0-rc.2 vite: '*' webpack: '*' peerDependenciesMeta: @@ -7033,6 +7056,33 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -7246,6 +7296,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -8603,6 +8656,9 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + country-flag-icons@1.5.21: + resolution: {integrity: sha512-0KmU4oeiyAM+F+atzK99ghQDQJKxEY3tiDhnRraVFL4o65rZgrmrx7xKi0b+hxcVpcEpuUbu+KCC6TKTZQTDcA==} + cp-file@10.0.0: resolution: {integrity: sha512-vy2Vi1r2epK5WqxOLnskeKeZkdZvTKfFZQCplE3XWsP+SUJyd5XAUFC9lFgTjjXJF2GMne/UML14iEmkAaDfFg==} engines: {node: '>=14.16'} @@ -8669,6 +8725,50 @@ packages: custom-media-element@1.4.2: resolution: {integrity: sha512-AM6FRWqJyW7pWTvXb4uJj6yvHE7C6UutdhJ5o3XO5NEl5aWFcfnpz8/TuW8qr1+/wfbj50wRvdArnSNjTmjmVw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -8786,6 +8886,9 @@ packages: decache@4.6.2: resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -9278,6 +9381,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.41.0: + resolution: {integrity: sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -9624,6 +9730,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@1.1.1: resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} engines: {node: '>=0.4.x'} @@ -10371,6 +10480,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.3: + resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -10424,6 +10536,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ioredis@5.6.1: resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} engines: {node: '>=12.22.0'} @@ -12670,6 +12786,18 @@ packages: react-promise-suspense@0.3.4: resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -12795,6 +12923,14 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.3.0: + resolution: {integrity: sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -12819,6 +12955,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -12886,6 +13030,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@4.6.0: resolution: {integrity: sha512-D5T2I82FvEUYFlrHzaDvVtr5ADHdhuoLaXgLFGABKyNtQgPWIuz0Vp2L2Evx779qjK37aF4kcw1yXJDHhA2JnQ==} engines: {node: '>=18'} @@ -12965,8 +13112,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-beta.43: - resolution: {integrity: sha512-6RcqyRx0tY1MlRLnjXPp/849Rl/CPFhzpGGwNPEPjKwqBMqPq/Rbbkxasa8s0x+IkUk46ty4jazb5skZ/Vgdhw==} + rolldown@1.0.0-beta.45: + resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -14488,6 +14635,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vinxi@0.5.6: resolution: {integrity: sha512-K9zaoHEdLXSVw3akoKcpRaRaGNZcXAnB0XBcke74y0FbXqcR3+rlFxOH/Pi3Maq3K7wAPBGyE91HW0lATfv5Kg==} hasBin: true @@ -18868,6 +19018,8 @@ snapshots: '@oxc-project/types@0.94.0': {} + '@oxc-project/types@0.95.0': {} + '@panva/hkdf@1.2.1': {} '@paralleldrive/cuid2@2.2.2': @@ -19806,6 +19958,18 @@ snapshots: dependencies: react: 19.1.1 + '@reduxjs/toolkit@2.9.2(react-redux@9.2.0(@types/react@19.1.13)(react@19.1.1)(redux@5.0.1))(react@19.1.1)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@standard-schema/utils': 0.3.0 + immer: 10.1.3 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.1.1 + react-redux: 9.2.0(@types/react@19.1.13)(react@19.1.1)(redux@5.0.1) + '@remix-run/router@1.23.0': {} '@remotion/licensing@4.0.298': {} @@ -19827,61 +19991,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-android-arm64@1.0.0-beta.43': + '@rolldown/binding-android-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.43': + '@rolldown/binding-darwin-x64@1.0.0-beta.45': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.42': @@ -19889,7 +20053,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.6 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true @@ -19897,24 +20061,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': optional: true '@rolldown/pluginutils@1.0.0-beta.42': {} - '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rolldown/pluginutils@1.0.0-beta.45': {} '@rollup/plugin-alias@5.1.1(rollup@4.40.2)': optionalDependencies: @@ -20903,11 +21067,11 @@ snapshots: dependencies: solid-js: 1.9.6 - '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': + '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) defu: 6.1.4 error-stack-parser: 2.1.4 html-to-image: 1.11.13 @@ -20918,7 +21082,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.6) tinyglobby: 0.2.13 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -20942,6 +21106,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@storybook/addon-actions@8.6.12(storybook@8.6.12(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 @@ -21046,9 +21212,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) @@ -21078,7 +21244,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -21644,6 +21810,30 @@ snapshots: dependencies: '@types/node': 20.17.43 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -21888,6 +22078,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@9.0.8': {} '@types/yargs-parser@21.0.3': {} @@ -22261,7 +22453,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.27.2 acorn: 8.14.1 @@ -22272,18 +22464,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.14.1 acorn-loose: 8.5.0 acorn-typescript: 1.4.13(acorn@8.14.1) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) '@virtual-grid/core@2.0.1': {} @@ -23526,6 +23718,8 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + country-flag-icons@1.5.21: {} + cp-file@10.0.0: dependencies: graceful-fs: 4.2.11 @@ -23600,6 +23794,44 @@ snapshots: custom-media-element@1.4.2: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@2.0.2: {} @@ -23675,6 +23907,8 @@ snapshots: dependencies: callsite: 1.0.0 + decimal.js-light@2.5.1: {} + decimal.js@10.5.0: {} decode-named-character-reference@1.1.0: @@ -24126,6 +24360,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.41.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -24912,6 +25148,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} + events@1.1.1: {} events@3.3.0: {} @@ -25831,6 +26069,8 @@ snapshots: immediate@3.0.6: {} + immer@10.1.3: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -25877,6 +26117,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + ioredis@5.6.1: dependencies: '@ioredis/commands': 1.2.0 @@ -27538,7 +27780,7 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 @@ -27613,7 +27855,7 @@ snapshots: cors: 2.8.5 next: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2): + nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.5(encoding@0.1.13)(rollup@4.40.2) @@ -27667,7 +27909,7 @@ snapshots: pretty-bytes: 6.1.1 radix3: 1.1.2 rollup: 4.40.2 - rollup-plugin-visualizer: 5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2) + rollup-plugin-visualizer: 5.14.0(rolldown@1.0.0-beta.45)(rollup@4.40.2) scule: 1.3.0 semver: 7.7.2 serve-placeholder: 2.0.2 @@ -28666,6 +28908,15 @@ snapshots: dependencies: fast-deep-equal: 2.0.1 + react-redux@9.2.0(@types/react@19.1.13)(react@19.1.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.1.13)(react@19.1.1): @@ -28808,6 +29059,26 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.3.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react-is@18.3.1)(react@19.1.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.9.2(react-redux@9.2.0(@types/react@19.1.13)(react@19.1.1)(redux@5.0.1))(react@19.1.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.41.0 + eventemitter3: 5.0.1 + immer: 10.1.3 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@19.1.13)(react@19.1.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.5.0(react@19.1.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -28849,6 +29120,12 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -28958,6 +29235,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.1.1: {} + resend@4.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@react-email/render': 1.1.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -29008,7 +29287,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -29019,7 +29298,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.11.0 magic-string: 0.30.19 - rolldown: 1.0.0-beta.43 + rolldown: 1.0.0-beta.45 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -29047,26 +29326,25 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.42 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.42 - rolldown@1.0.0-beta.43: + rolldown@1.0.0-beta.45: dependencies: - '@oxc-project/types': 0.94.0 - '@rolldown/pluginutils': 1.0.0-beta.43 - ansis: 4.2.0 + '@oxc-project/types': 0.95.0 + '@rolldown/pluginutils': 1.0.0-beta.45 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-x64': 1.0.0-beta.43 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.43 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.43 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.43 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.43 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.43 + '@rolldown/binding-android-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-x64': 1.0.0-beta.45 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 rollup-plugin-inject@3.0.2: dependencies: @@ -29078,14 +29356,14 @@ snapshots: dependencies: rollup-plugin-inject: 3.0.2 - rollup-plugin-visualizer@5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2): + rollup-plugin-visualizer@5.14.0(rolldown@1.0.0-beta.45)(rollup@4.40.2): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rolldown: 1.0.0-beta.43 + rolldown: 1.0.0-beta.45 rollup: 4.40.2 rollup-pluginutils@2.8.2: @@ -29681,7 +29959,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6 @@ -30285,8 +30563,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.43 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3) + rolldown: 1.0.0-beta.45 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -30878,7 +31156,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) @@ -30900,7 +31195,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2) + nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.45)(xml2js@0.6.2) node-fetch-native: 1.6.6 path-to-regexp: 6.3.0 pathe: 1.1.2 From 37cfa8248a0af01cfd0ccc3d1a450a6db29b85a2 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:12:40 +0300 Subject: [PATCH 04/23] add caps table, improve chart look, and more --- .../analytics/components/ChartArea.tsx | 15 +- .../analytics/components/OtherStats.tsx | 266 +++++++++++------- .../analytics/components/StatsChart.tsx | 19 +- 3 files changed, 192 insertions(+), 108 deletions(-) diff --git a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx index 83d55aa392..06ed45b233 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx @@ -13,7 +13,7 @@ export const description = "An area chart with gradient fill"; const chartData = Array.from({ length: 24 }, (_, i) => ({ hour: i + 1, - desktop: Math.floor(Math.random() * 100), + desktop: Math.floor(Math.random() * 100).toFixed(0), })); const chartConfig = { @@ -34,25 +34,26 @@ function ChartArea() { data={chartData} margin={{ left: 20, - right: 0, + right: 20, top: 20, - bottom: 0, + bottom: 10, }} > { return getUnicodeFlagIcon(countryCode.toUpperCase()); @@ -39,50 +45,50 @@ export default function OtherStats() { { icon: countryCodeToIcon("US"), name: "United States", - views: "1,234", - percentage: "45.6%", + views: "8,452", + percentage: "34.2%", }, { icon: countryCodeToIcon("GB"), name: "United Kingdom", - views: "892", - percentage: "23.4%", + views: "3,891", + percentage: "15.7%", }, { icon: countryCodeToIcon("CA"), name: "Canada", - views: "567", - percentage: "30.9%", + views: "2,764", + percentage: "11.2%", }, { icon: countryCodeToIcon("DE"), name: "Germany", - views: "432", - percentage: "15.6%", + views: "2,143", + percentage: "8.7%", }, { icon: countryCodeToIcon("FR"), name: "France", - views: "389", - percentage: "10.9%", + views: "1,876", + percentage: "7.6%", }, { icon: countryCodeToIcon("AU"), name: "Australia", - views: "276", - percentage: "10.9%", + views: "1,542", + percentage: "6.2%", }, { icon: countryCodeToIcon("JP"), name: "Japan", - views: "198", - percentage: "10.9%", + views: "1,298", + percentage: "5.2%", }, { icon: countryCodeToIcon("BR"), name: "Brazil", - views: "156", - percentage: "10.9%", + views: "987", + percentage: "4.0%", }, ]} /> @@ -93,68 +99,68 @@ export default function OtherStats() { { name: "New York", icon: countryCodeToIcon("US"), - views: "1,234", - percentage: "45.6%", + views: "3,421", + percentage: "18.7%", }, { name: "Los Angeles", icon: countryCodeToIcon("US"), - views: "892", - percentage: "23.4%", + views: "2,876", + percentage: "15.7%", }, { - name: "Chicago", - icon: countryCodeToIcon("US"), - views: "567", - percentage: "30.9%", + name: "London", + icon: countryCodeToIcon("GB"), + views: "2,145", + percentage: "11.7%", }, { - name: "Houston", - icon: countryCodeToIcon("US"), - views: "432", - percentage: "15.6%", + name: "Toronto", + icon: countryCodeToIcon("CA"), + views: "1,892", + percentage: "10.3%", }, { - name: "Miami", + name: "San Francisco", icon: countryCodeToIcon("US"), - views: "389", - percentage: "10.9%", + views: "1,654", + percentage: "9.0%", }, { - name: "San Francisco", + name: "Chicago", icon: countryCodeToIcon("US"), - views: "389", - percentage: "10.9%", + views: "1,432", + percentage: "7.8%", }, { - name: "Seattle", - icon: countryCodeToIcon("US"), - views: "389", - percentage: "10.9%", + name: "Berlin", + icon: countryCodeToIcon("DE"), + views: "1,198", + percentage: "6.5%", }, { - name: "Boston", + name: "Seattle", icon: countryCodeToIcon("US"), - views: "389", - percentage: "10.9%", + views: "987", + percentage: "5.4%", }, { - name: "Washington, D.C. asd asd asdsa d", - icon: countryCodeToIcon("US"), - views: "1,000", - percentage: "10.9%", + name: "Sydney", + icon: countryCodeToIcon("AU"), + views: "876", + percentage: "4.8%", }, { - name: "Atlanta", - icon: countryCodeToIcon("US"), - views: "1,000", - percentage: "10.9%", + name: "Paris", + icon: countryCodeToIcon("FR"), + views: "743", + percentage: "4.1%", }, { - name: "Denver", + name: "Boston", icon: countryCodeToIcon("US"), - views: "1,000", - percentage: "10.9%", + views: "621", + percentage: "3.4%", }, ]} /> @@ -169,56 +175,56 @@ export default function OtherStats() { { icon: , name: "Chrome", - percentage: "45.6%", - views: "1,234", - }, - { - icon: , - name: "Firefox", - percentage: "23.4%", - views: "892", + views: "12,456", + percentage: "42.3%", }, { icon: , name: "Safari", - percentage: "30.9%", - views: "567", + views: "6,234", + percentage: "21.2%", }, { - icon: , - name: "Edge", - percentage: "15.6%", - views: "432", + icon: , + name: "Firefox", + views: "4,187", + percentage: "14.2%", }, { - icon: , - name: "Opera", - percentage: "10.9%", - views: "432", + icon: , + name: "Edge", + views: "3,542", + percentage: "12.0%", }, { icon: , name: "Brave", - percentage: "5.6%", - views: "432", + views: "1,876", + percentage: "6.4%", + }, + { + icon: , + name: "Opera", + views: "654", + percentage: "2.2%", }, { icon: , name: "Vivaldi", - percentage: "3.4%", - views: "432", + views: "298", + percentage: "1.0%", }, { icon: , name: "Yandex", - percentage: "2.9%", - views: "432", + views: "143", + percentage: "0.5%", }, { icon: , name: "DuckDuckGo", - percentage: "1.6%", - views: "432", + views: "67", + percentage: "0.2%", }, ]} /> @@ -229,32 +235,32 @@ export default function OtherStats() { { icon: , name: "Windows", - views: "1,234", - percentage: "45.6%", + views: "9,876", + percentage: "38.7%", }, { icon: , - name: "iOS", - views: "892", - percentage: "23.4%", + name: "macOS", + views: "7,432", + percentage: "29.1%", }, { icon: , name: "Linux", - views: "892", - percentage: "15.6%", - }, - { - icon: , - name: "Fedora", - views: "892", - percentage: "10.9%", + views: "3,654", + percentage: "14.3%", }, { icon: , name: "Ubuntu", - views: "892", - percentage: "10.9%", + views: "2,187", + percentage: "8.6%", + }, + { + icon: , + name: "Fedora", + views: "1,243", + percentage: "4.9%", }, ]} /> @@ -267,7 +273,6 @@ export default function OtherStats() { >
+ +
+ , + percentage: "28.3%", + }, + { + name: "Tutorial: Getting Started", + views: "4,156", + icon: , + percentage: "20.1%", + }, + { + name: "Team Meeting Highlights", + views: "3,421", + icon: , + percentage: "16.6%", + }, + { + name: "Bug Fix Walkthrough", + views: "2,789", + icon: , + percentage: "13.5%", + }, + { + name: "Feature Announcement", + views: "1,923", + icon: , + percentage: "9.3%", + }, + { + name: "Customer Feedback Review", + views: "1,245", + icon: , + percentage: "6.0%", + }, + { + name: "Sprint Retrospective", + views: "876", + icon: , + percentage: "4.2%", + }, + { + name: "Design System Update", + views: "543", + icon: , + percentage: "2.6%", + }, + { + name: "API Documentation Demo", + views: "289", + icon: , + percentage: "1.4%", + }, + ]} + /> +
+
); } @@ -343,6 +416,8 @@ interface TableCardProps { icon?: string | React.ReactNode; name: string; views: string; + comments?: string; + reactions?: string; percentage?: string; }[]; } @@ -381,10 +456,11 @@ const TableCard = ({ {row.icon} - + {row.name} + {row.views} diff --git a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx index 4b88c78107..d7a5a78da0 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx @@ -27,11 +27,18 @@ export default function StatsBox() { const chatsBoxRef = useRef(null); const reactionsBoxRef = useRef(null); + const selectHandler = (box: "caps" | "views" | "chats" | "reactions") => { + setSelectedBox(box); + if (selectedBox === box) { + setSelectedBox(null); + } + }; + return ( -
+
setSelectedBox("caps")} + onClick={() => selectHandler("caps")} isSelected={selectedBox === "caps"} title="Caps" value="100" @@ -40,7 +47,7 @@ export default function StatsBox() { icon={} /> setSelectedBox("views")} + onClick={() => selectHandler("views")} isSelected={selectedBox === "views"} title="Views" value="2,768" @@ -49,7 +56,7 @@ export default function StatsBox() { icon={} /> setSelectedBox("chats")} + onClick={() => selectHandler("chats")} isSelected={selectedBox === "chats"} title="Comments" value="100" @@ -58,7 +65,7 @@ export default function StatsBox() { icon={} /> setSelectedBox("reactions")} + onClick={() => selectHandler("reactions")} isSelected={selectedBox === "reactions"} title="Reactions" value="100" @@ -90,7 +97,7 @@ function StatBox({ {...props} className={classNames( "flex flex-col flex-1 gap-2 px-8 py-6 bg-transparent rounded-xl border transition-all duration-200 cursor-pointer group h-fit hover:bg-gray-3 border-gray-5", - isSelected && "bg-gray-3 border-gray-6", + isSelected && "bg-gray-3 border-gray-8", )} >
From 88c662d08a41386044057872dd2ac8bbb557944d Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:28:33 +0300 Subject: [PATCH 05/23] some refactoring and more ui updates --- .../dashboard/_components/Navbar/Items.tsx | 13 +- .../dashboard/_components/Navbar/Top.tsx | 4 +- .../analytics/components/ChartArea.tsx | 120 +++- .../analytics/components/OtherStatBox.tsx | 32 ++ .../analytics/components/OtherStats.tsx | 525 ++---------------- .../analytics/components/StatsChart.tsx | 111 ++-- .../analytics/components/TableCard.tsx | 274 +++++++++ .../app/(org)/dashboard/analytics/page.tsx | 379 ++++++++++++- .../(org)/dashboard/analytics/s/[id]/page.tsx | 18 + .../caps/components/CapCard/CapCard.tsx | 196 ++++--- 10 files changed, 1039 insertions(+), 633 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 01225b41c8..de69b941ec 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -62,6 +62,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { { name: "Analytics", href: `/dashboard/analytics`, + ignoreParams: true, icon: , subNav: [], }, @@ -84,7 +85,8 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [openAIDialog, setOpenAIDialog] = useState(false); const router = useRouter(); - const isPathActive = (path: string) => pathname.includes(path); + const isPathActive = (path: string, ignoreParams: boolean = false) => + ignoreParams ? pathname === path : pathname.includes(path); const isDomainSetupVerified = activeOrg?.organization.customDomain && activeOrg?.organization.domainVerified; @@ -273,7 +275,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { key={item.name} className="flex relative justify-center items-center mb-1.5 w-full" > - {isPathActive(item.href) && ( + {isPathActive(item.href, item.ignoreParams) && ( { toggleMobileNav={toggleMobileNav} isPathActive={isPathActive} extraText={item.extraText} + ignoreParams={item.ignoreParams ?? false} />
))} @@ -391,6 +394,7 @@ const NavItem = ({ sidebarCollapsed, toggleMobileNav, isPathActive, + ignoreParams, extraText, }: { name: string; @@ -402,8 +406,9 @@ const NavItem = ({ }>; sidebarCollapsed: boolean; toggleMobileNav?: () => void; - isPathActive: (path: string) => boolean; + isPathActive: (path: string, ignoreParams: boolean) => boolean; extraText: number | null | undefined; + ignoreParams: boolean; }) => { const iconRef = useRef(null); return ( @@ -424,7 +429,7 @@ const NavItem = ({ sidebarCollapsed ? "flex justify-center items-center px-0 w-full size-9" : "px-3 py-2 w-full", - isPathActive(href) + isPathActive(href, ignoreParams) ? "bg-transparent pointer-events-none" : "hover:bg-gray-2", "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 266689d28d..6290a4cc5d 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -17,7 +17,7 @@ import clsx from "clsx"; import { AnimatePresence } from "framer-motion"; import { MoreVertical } from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; import { cloneElement, @@ -53,6 +53,7 @@ const Top = () => { const queryClient = useQueryClient(); const pathname = usePathname(); + const params = useParams(); const titles: Record = { "/dashboard/caps": "Caps", @@ -63,6 +64,7 @@ const Top = () => { "/dashboard/spaces": "Spaces", "/dashboard/spaces/browse": "Browse Spaces", "/dashboard/analytics": "Analytics", + [`/dashboard/analytics/s/${params.id}`]: "Analytics: cap name", }; const title = activeSpace diff --git a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx index 06ed45b233..732f481e5b 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx @@ -13,18 +13,40 @@ export const description = "An area chart with gradient fill"; const chartData = Array.from({ length: 24 }, (_, i) => ({ hour: i + 1, - desktop: Math.floor(Math.random() * 100).toFixed(0), + views: Math.floor(Math.random() * 100), + comments: Math.floor(Math.random() * 100), + reactions: Math.floor(Math.random() * 100), + caps: Math.floor(Math.random() * 100), })); const chartConfig = { - desktop: { - label: "Desktop", + views: { + label: "Views", color: "var(--gray-12)", }, + comments: { + label: "Comments", + color: "#3b82f6", + }, + reactions: { + label: "Reactions", + color: "#60a5fa", + }, + caps: { + label: "Caps", + color: "#06b6d4", + }, } satisfies ChartConfig; -function ChartArea() { - const desktopGradientId = useId(); +interface ChartAreaProps { + selectedMetric: "caps" | "views" | "comments" | "reactions"; +} + +function ChartArea({ selectedMetric }: ChartAreaProps) { + const viewsGradientId = useId(); + const commentsGradientId = useId(); + const reactionsGradientId = useId(); + const capsGradientId = useId(); const glowFilterId = useId(); return ( @@ -56,7 +78,6 @@ function ChartArea() { - - - - + {selectedMetric === "views" && ( + + + + + )} + {selectedMetric === "comments" && ( + + + + + )} + {selectedMetric === "reactions" && ( + + + + + )} + {selectedMetric === "caps" && ( + + + + + )} - + {selectedMetric === "views" && ( + + )} + {selectedMetric === "comments" && ( + + )} + {selectedMetric === "reactions" && ( + + )} + {selectedMetric === "caps" && ( + + )} ); diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx new file mode 100644 index 0000000000..8a5f4f37f8 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx @@ -0,0 +1,32 @@ +import type { FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { clsx } from "clsx"; + +interface OtherStatBoxProps { + title: string; + icon: FontAwesomeIconProps["icon"]; + children: React.ReactNode; + className?: string; +} + +export default function OtherStatBox({ + title, + icon, + children, + className, +}: OtherStatBoxProps) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx index ff2508efbf..d986c1c34a 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -1,168 +1,57 @@ "use client"; import { - Logo, - LogoBadge, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@cap/ui"; -import { - faAppleWhole, - faCamera, faDesktop, faGlobe, faMobileScreen, faRecordVinyl, - faTablet, - faUserGroup, } from "@fortawesome/free-solid-svg-icons"; -import { - FontAwesomeIcon, - type FontAwesomeIconProps, -} from "@fortawesome/react-fontawesome"; -import clsx from "clsx"; -import getUnicodeFlagIcon from "country-flag-icons/unicode"; -import Image from "next/image"; -import CapIcon from "../../_components/AnimatedIcons/Cap"; +import OtherStatBox from "./OtherStatBox"; +import type { + BrowserRowData, + CapRowData, + CityRowData, + CountryRowData, + DeviceRowData, + OSRowData, +} from "./TableCard"; +import TableCard from "./TableCard"; -const countryCodeToIcon = (countryCode: string) => { - return getUnicodeFlagIcon(countryCode.toUpperCase()); -}; +export interface OtherStatsData { + countries: CountryRowData[]; + cities: CityRowData[]; + browsers: BrowserRowData[]; + operatingSystems: OSRowData[]; + deviceTypes: DeviceRowData[]; + topCaps: CapRowData[]; +} -export default function OtherStats() { +interface OtherStatsProps { + data: OtherStatsData; +} + +export default function OtherStats({ data }: OtherStatsProps) { return (
@@ -170,99 +59,27 @@ export default function OtherStats() {
, - name: "Chrome", - views: "12,456", - percentage: "42.3%", - }, - { - icon: , - name: "Safari", - views: "6,234", - percentage: "21.2%", - }, - { - icon: , - name: "Firefox", - views: "4,187", - percentage: "14.2%", - }, - { - icon: , - name: "Edge", - views: "3,542", - percentage: "12.0%", - }, - { - icon: , - name: "Brave", - views: "1,876", - percentage: "6.4%", - }, - { - icon: , - name: "Opera", - views: "654", - percentage: "2.2%", - }, - { - icon: , - name: "Vivaldi", - views: "298", - percentage: "1.0%", - }, - { - icon: , - name: "Yandex", - views: "143", - percentage: "0.5%", - }, - { - icon: , - name: "DuckDuckGo", - views: "67", - percentage: "0.2%", - }, + columns={[ + "Browser", + "Views", + "Comments", + "Reactions", + "Percentage", ]} + rows={data.browsers} + type="browser" /> , - name: "Windows", - views: "9,876", - percentage: "38.7%", - }, - { - icon: , - name: "macOS", - views: "7,432", - percentage: "29.1%", - }, - { - icon: , - name: "Linux", - views: "3,654", - percentage: "14.3%", - }, - { - icon: , - name: "Ubuntu", - views: "2,187", - percentage: "8.6%", - }, - { - icon: , - name: "Fedora", - views: "1,243", - percentage: "4.9%", - }, + columns={[ + "Operating System", + "Views", + "Comments", + "Reactions", + "Percentage", ]} + rows={data.operatingSystems} + type="os" />
@@ -274,36 +91,9 @@ export default function OtherStats() {
- ), - name: "Desktop", - views: "2,456", - percentage: "45.6%", - }, - { - icon: ( - - ), - name: "Tablet", - views: "1,234", - percentage: "23.4%", - }, - { - icon: ( - - ), - name: "Mobile", - views: "1,789", - percentage: "30.9%", - }, - ]} + columns={["Device", "Views", "Comments", "Reactions", "Percentage"]} + rows={data.deviceTypes} + type="device" />
@@ -315,215 +105,12 @@ export default function OtherStats() {
, - percentage: "28.3%", - }, - { - name: "Tutorial: Getting Started", - views: "4,156", - icon: , - percentage: "20.1%", - }, - { - name: "Team Meeting Highlights", - views: "3,421", - icon: , - percentage: "16.6%", - }, - { - name: "Bug Fix Walkthrough", - views: "2,789", - icon: , - percentage: "13.5%", - }, - { - name: "Feature Announcement", - views: "1,923", - icon: , - percentage: "9.3%", - }, - { - name: "Customer Feedback Review", - views: "1,245", - icon: , - percentage: "6.0%", - }, - { - name: "Sprint Retrospective", - views: "876", - icon: , - percentage: "4.2%", - }, - { - name: "Design System Update", - views: "543", - icon: , - percentage: "2.6%", - }, - { - name: "API Documentation Demo", - views: "289", - icon: , - percentage: "1.4%", - }, - ]} + columns={["Cap", "Views", "Comments", "Reactions", "Percentage"]} + rows={data.topCaps} + type="cap" />
); } - -interface OtherStatBoxProps { - title: string; - icon: FontAwesomeIconProps["icon"]; - children: React.ReactNode; - className?: string; -} - -const OtherStatBox = ({ - title, - icon, - children, - className, -}: OtherStatBoxProps) => { - return ( -
-
- -

{title}

-
- {children} -
- ); -}; - -interface TableCardProps { - title: string; - columns: string[]; - tableClassname?: string; - rows: { - icon?: string | React.ReactNode; - name: string; - views: string; - comments?: string; - reactions?: string; - percentage?: string; - }[]; -} - -const TableCard = ({ - title, - columns, - rows, - tableClassname, -}: TableCardProps) => { - return ( -
-

{title}

- - - - {columns.map((column) => ( - - {column} - - ))} - - - - {rows.map((row) => ( - - - - {row.icon} - - - {row.name} - - - - - {row.views} - - - {row.percentage} - - - ))} - -
-
- ); -}; - -type OperatingSystemIconProps = { - operatingSystem: "windows" | "ios" | "linux" | "fedora" | "ubuntu"; -}; - -const OperatingSystemIcon = ({ operatingSystem }: OperatingSystemIconProps) => { - if (operatingSystem === "ios") { - return ; - } else { - return ( - {operatingSystem} - ); - } -}; - -type BrowserIconProps = { - browser: - | "google-chrome" - | "firefox" - | "safari" - | "explorer" - | "opera" - | "brave" - | "vivaldi" - | "yandex" - | "duckduckgo" - | "internet-explorer" - | "samsung-internet" - | "uc-browser" - | "qq-browser" - | "maxthon" - | "arora" - | "lunascape" - | "lunascape"; -}; - -const BrowserIcon = ({ browser }: BrowserIconProps) => { - return ( - {browser} - ); -}; diff --git a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx index d7a5a78da0..974d494ca5 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx @@ -17,64 +17,81 @@ import { classNames } from "@/utils/helpers"; import type { CapIconHandle } from "../../_components/AnimatedIcons/Cap"; import ChartArea from "./ChartArea"; -export default function StatsBox() { - const [selectedBox, setSelectedBox] = useState< - "caps" | "views" | "chats" | "reactions" | null - >(null); +type boxes = "caps" | "views" | "comments" | "reactions"; +interface StatsChartProps { + counts: { + caps?: string; + views?: string; + chats?: string; + reactions?: string; + }; + defaultSelectedBox?: boxes; +} + +export default function StatsBox({ + counts, + defaultSelectedBox = "caps", +}: StatsChartProps) { + const [selectedBox, setSelectedBox] = useState(defaultSelectedBox); const capsBoxRef = useRef(null); const viewsBoxRef = useRef(null); const chatsBoxRef = useRef(null); const reactionsBoxRef = useRef(null); - const selectHandler = (box: "caps" | "views" | "chats" | "reactions") => { + const selectHandler = (box: boxes) => { setSelectedBox(box); - if (selectedBox === box) { - setSelectedBox(null); - } }; return (
-
- selectHandler("caps")} - isSelected={selectedBox === "caps"} - title="Caps" - value="100" - onMouseEnter={() => capsBoxRef.current?.startAnimation()} - onMouseLeave={() => capsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("views")} - isSelected={selectedBox === "views"} - title="Views" - value="2,768" - onMouseEnter={() => viewsBoxRef.current?.startAnimation()} - onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("chats")} - isSelected={selectedBox === "chats"} - title="Comments" - value="100" - onMouseEnter={() => chatsBoxRef.current?.startAnimation()} - onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("reactions")} - isSelected={selectedBox === "reactions"} - title="Reactions" - value="100" - onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} - onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} - icon={} - /> +
+ {counts.caps && ( + selectHandler("caps")} + isSelected={selectedBox === "caps"} + title="Caps" + value={counts.caps} + onMouseEnter={() => capsBoxRef.current?.startAnimation()} + onMouseLeave={() => capsBoxRef.current?.stopAnimation()} + icon={} + /> + )} + {counts.views && ( + selectHandler("views")} + isSelected={selectedBox === "views"} + title="Views" + value={counts.views} + onMouseEnter={() => viewsBoxRef.current?.startAnimation()} + onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} + icon={} + /> + )} + {counts.chats && ( + selectHandler("comments")} + isSelected={selectedBox === "comments"} + title="Comments" + value={counts.chats} + onMouseEnter={() => chatsBoxRef.current?.startAnimation()} + onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} + icon={} + /> + )} + {counts.reactions && ( + selectHandler("reactions")} + isSelected={selectedBox === "reactions"} + title="Reactions" + value={counts.reactions} + onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} + onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} + icon={} + /> + )}
- +
); } @@ -96,7 +113,7 @@ function StatBox({
diff --git a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx new file mode 100644 index 0000000000..c960107bc1 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx @@ -0,0 +1,274 @@ +import { + LogoBadge, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@cap/ui"; +import { + faAppleWhole, + faDesktop, + faMobileScreen, + faTablet, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import getUnicodeFlagIcon from "country-flag-icons/unicode"; +import Image from "next/image"; + +const countryCodeToIcon = (countryCode: string) => { + return getUnicodeFlagIcon(countryCode.toUpperCase()); +}; + +type BrowserType = + | "google-chrome" + | "firefox" + | "safari" + | "explorer" + | "opera" + | "brave" + | "vivaldi" + | "yandex" + | "duckduckgo" + | "internet-explorer" + | "samsung-internet" + | "uc-browser" + | "qq-browser" + | "maxthon" + | "arora" + | "lunascape"; + +type OperatingSystemType = "windows" | "ios" | "linux" | "fedora" | "ubuntu"; + +type DeviceType = "desktop" | "tablet" | "mobile"; + +export interface CountryRowData { + countryCode: string; + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface CityRowData { + countryCode: string; + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface BrowserRowData { + browser: BrowserType; + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface OSRowData { + os: OperatingSystemType; + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface DeviceRowData { + device: DeviceType; + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface CapRowData { + name: string; + views: string; + comments: string; + reactions: string; + percentage: string; +} + +export interface TableCardProps { + title: string; + columns: string[]; + tableClassname?: string; + type: "country" | "city" | "browser" | "os" | "device" | "cap"; + rows: + | CountryRowData[] + | CityRowData[] + | BrowserRowData[] + | OSRowData[] + | DeviceRowData[] + | CapRowData[]; +} + +const TableCard = ({ + title, + columns, + rows, + type, + tableClassname, +}: TableCardProps) => { + return ( +
+

{title}

+
+ + + + {columns.map((column) => ( + + {column} + + ))} + + +
+
+ + + {rows.map((row) => ( + + +
+ + {getIconForRow(row, type)} + + {row.name} +
+
+ + {row.views} + + + {row.comments} + + + {row.reactions} + + + {row.percentage} + +
+ ))} +
+
+
+
+
+ ); +}; + +const getIconForRow = ( + row: + | CountryRowData + | CityRowData + | BrowserRowData + | OSRowData + | DeviceRowData + | CapRowData, + type: TableCardProps["type"], +) => { + switch (type) { + case "country": + return countryCodeToIcon((row as CountryRowData).countryCode); + case "city": + return countryCodeToIcon((row as CityRowData).countryCode); + case "browser": + return ; + case "os": + return ; + case "device": { + const device = (row as DeviceRowData).device; + const iconMap = { + desktop: faDesktop, + tablet: faTablet, + mobile: faMobileScreen, + }; + return ( + + ); + } + case "cap": + return ; + default: + return null; + } +}; + +type OperatingSystemIconProps = { + operatingSystem: "windows" | "ios" | "linux" | "fedora" | "ubuntu"; +}; + +const OperatingSystemIcon = ({ operatingSystem }: OperatingSystemIconProps) => { + if (operatingSystem === "ios") { + return ; + } else { + return ( + {operatingSystem} + ); + } +}; + +type BrowserIconProps = { + browser: + | "google-chrome" + | "firefox" + | "safari" + | "explorer" + | "opera" + | "brave" + | "vivaldi" + | "yandex" + | "duckduckgo" + | "internet-explorer" + | "samsung-internet" + | "uc-browser" + | "qq-browser" + | "maxthon" + | "arora" + | "lunascape" + | "lunascape"; +}; + +const BrowserIcon = ({ browser }: BrowserIconProps) => { + return ( + {browser} + ); +}; + +export default TableCard; diff --git a/apps/web/app/(org)/dashboard/analytics/page.tsx b/apps/web/app/(org)/dashboard/analytics/page.tsx index 37c38d9d84..bdadbaf0d9 100644 --- a/apps/web/app/(org)/dashboard/analytics/page.tsx +++ b/apps/web/app/(org)/dashboard/analytics/page.tsx @@ -1,13 +1,386 @@ import Header from "./components/Header"; -import OtherStats from "./components/OtherStats"; +import OtherStats, { type OtherStatsData } from "./components/OtherStats"; import StatsChart from "./components/StatsChart"; export default function AnalyticsPage() { return (
- - + +
); } + +const mockData: OtherStatsData = { + countries: [ + { + countryCode: "US", + name: "United States", + views: "8,452", + comments: "100", + reactions: "100", + percentage: "34.2%", + }, + { + countryCode: "GB", + name: "United Kingdom", + views: "3,891", + comments: "100", + reactions: "100", + percentage: "15.7%", + }, + { + countryCode: "CA", + name: "Canada", + views: "2,764", + comments: "100", + reactions: "100", + percentage: "11.2%", + }, + { + countryCode: "DE", + name: "Germany", + views: "2,143", + comments: "100", + reactions: "100", + percentage: "8.7%", + }, + { + countryCode: "FR", + name: "France", + views: "1,876", + comments: "100", + reactions: "100", + percentage: "7.6%", + }, + { + countryCode: "AU", + name: "Australia", + views: "1,542", + comments: "100", + reactions: "100", + percentage: "6.2%", + }, + { + countryCode: "JP", + name: "Japan", + views: "1,298", + comments: "100", + reactions: "100", + percentage: "5.2%", + }, + { + countryCode: "BR", + name: "Brazil", + views: "987", + comments: "100", + reactions: "100", + percentage: "4.0%", + }, + ], + cities: [ + { + countryCode: "US", + name: "New York", + views: "3,421", + comments: "100", + reactions: "100", + percentage: "18.7%", + }, + { + countryCode: "US", + name: "Los Angeles", + views: "2,876", + comments: "100", + reactions: "100", + percentage: "15.7%", + }, + { + countryCode: "GB", + name: "London", + views: "2,145", + comments: "100", + reactions: "100", + percentage: "11.7%", + }, + { + countryCode: "CA", + name: "Toronto", + views: "1,892", + comments: "100", + reactions: "100", + percentage: "10.3%", + }, + { + countryCode: "US", + name: "San Francisco", + views: "1,654", + comments: "100", + reactions: "100", + percentage: "9.0%", + }, + { + countryCode: "US", + name: "Chicago", + views: "1,432", + comments: "100", + reactions: "100", + percentage: "7.8%", + }, + { + countryCode: "DE", + name: "Berlin", + views: "1,198", + comments: "100", + reactions: "100", + percentage: "6.5%", + }, + { + countryCode: "US", + name: "Seattle", + views: "987", + comments: "100", + reactions: "100", + percentage: "5.4%", + }, + { + countryCode: "AU", + name: "Sydney", + views: "876", + comments: "100", + reactions: "100", + percentage: "4.8%", + }, + { + countryCode: "FR", + name: "Paris", + views: "743", + comments: "100", + reactions: "100", + percentage: "4.1%", + }, + { + countryCode: "US", + name: "Boston", + views: "621", + comments: "100", + reactions: "100", + percentage: "3.4%", + }, + ], + browsers: [ + { + browser: "google-chrome", + name: "Chrome", + views: "12,456", + comments: "100", + reactions: "100", + percentage: "42.3%", + }, + { + browser: "safari", + name: "Safari", + views: "6,234", + comments: "100", + reactions: "100", + percentage: "21.2%", + }, + { + browser: "firefox", + name: "Firefox", + views: "4,187", + comments: "100", + reactions: "100", + percentage: "14.2%", + }, + { + browser: "explorer", + name: "Edge", + views: "3,542", + comments: "100", + reactions: "100", + percentage: "12.0%", + }, + { + browser: "brave", + name: "Brave", + views: "1,876", + comments: "100", + reactions: "100", + percentage: "6.4%", + }, + { + browser: "opera", + name: "Opera", + views: "654", + comments: "100", + reactions: "100", + percentage: "2.2%", + }, + { + browser: "vivaldi", + name: "Vivaldi", + views: "298", + comments: "100", + reactions: "100", + percentage: "1.0%", + }, + { + browser: "yandex", + name: "Yandex", + views: "143", + comments: "100", + reactions: "100", + percentage: "0.5%", + }, + { + browser: "duckduckgo", + name: "DuckDuckGo", + views: "67", + comments: "100", + reactions: "100", + percentage: "0.2%", + }, + ], + operatingSystems: [ + { + os: "windows", + name: "Windows", + views: "9,876", + comments: "100", + reactions: "100", + percentage: "38.7%", + }, + { + os: "ios", + name: "macOS", + views: "7,432", + comments: "100", + reactions: "100", + percentage: "29.1%", + }, + { + os: "linux", + name: "Linux", + views: "3,654", + comments: "100", + reactions: "100", + percentage: "14.3%", + }, + { + os: "ubuntu", + name: "Ubuntu", + views: "2,187", + comments: "100", + reactions: "100", + percentage: "8.6%", + }, + { + os: "fedora", + name: "Fedora", + views: "1,243", + comments: "100", + reactions: "100", + percentage: "4.9%", + }, + ], + deviceTypes: [ + { + device: "desktop", + name: "Desktop", + views: "2,456", + comments: "100", + reactions: "100", + percentage: "45.6%", + }, + { + device: "tablet", + name: "Tablet", + views: "1,234", + comments: "100", + reactions: "100", + percentage: "23.4%", + }, + { + device: "mobile", + name: "Mobile", + views: "1,789", + comments: "100", + reactions: "100", + percentage: "30.9%", + }, + ], + topCaps: [ + { + name: "Product Demo - Q4 2024", + views: "5,842", + comments: "100", + reactions: "100", + percentage: "28.3%", + }, + { + name: "Tutorial: Getting Started", + views: "4,156", + comments: "100", + reactions: "100", + percentage: "20.1%", + }, + { + name: "Team Meeting Highlights", + views: "3,421", + comments: "100", + reactions: "100", + percentage: "16.6%", + }, + { + name: "Bug Fix Walkthrough", + views: "2,789", + comments: "100", + reactions: "100", + percentage: "13.5%", + }, + { + name: "Feature Announcement", + views: "1,923", + comments: "100", + reactions: "100", + percentage: "9.3%", + }, + { + name: "Customer Feedback Review", + views: "1,245", + comments: "100", + reactions: "100", + percentage: "6.0%", + }, + { + name: "Sprint Retrospective", + views: "876", + comments: "100", + reactions: "100", + percentage: "4.2%", + }, + { + name: "Design System Update", + views: "543", + comments: "100", + reactions: "100", + percentage: "2.6%", + }, + { + name: "API Documentation Demo", + views: "289", + comments: "100", + reactions: "100", + percentage: "1.4%", + }, + ], +}; diff --git a/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx b/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx new file mode 100644 index 0000000000..702eca87f6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx @@ -0,0 +1,18 @@ +import Header from "../../components/Header"; +import StatsChart from "../../components/StatsChart"; + +export default function AnalyticsPage() { + return ( +
+
+ +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index eb69e3db80..3e891a4351 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -11,6 +11,7 @@ import { import type { ImageUpload, Video } from "@cap/web-domain"; import { HttpClient } from "@effect/platform"; import { + faChartSimple, faCheck, faCopy, faDownload, @@ -33,7 +34,6 @@ import { type PropsWithChildren, useState } from "react"; import { toast } from "sonner"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import { useFeatureFlag } from "@/app/Layout/features"; import ProgressCircle, { useUploadProgress, } from "@/app/s/[videoId]/_components/ProgressCircle"; @@ -42,7 +42,6 @@ import { VideoThumbnail, } from "@/components/VideoThumbnail"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; import { usePublicEnv } from "@/utils/public-env"; import { PasswordDialog } from "../PasswordDialog"; import { SettingsDialog } from "../SettingsDialog"; @@ -362,27 +361,28 @@ export const CapCard = ({ )} > {isOwner && ( - { - e.stopPropagation(); - setIsSharingDialogOpen(true); - }} - className="delay-0" - icon={} - /> + <> + { + e.stopPropagation(); + setIsSharingDialogOpen(true); + }} + className="delay-0" + icon={} + /> + { + e.stopPropagation(); + router.push(`/dashboard/analytics/s/${cap.id}`); + }} + className="delay-0" + icon={} + /> + )} - { - e.stopPropagation(); - handleDownload(); - }} - className="delay-0" - icon={} - /> - {!isOwner && ( )} - {isOwner && ( - - -
- } - /> -
-
- + + +
+ } + /> +
+
+ + {isOwner && ( { e.stopPropagation(); @@ -443,59 +443,75 @@ export const CapCard = ({

Settings

- { - e.stopPropagation(); - copyLinkHandler(); - toast.success("Link copied to clipboard"); - }} - className="flex gap-2 items-center rounded-lg" - > - -

Copy link

-
- { - toast.promise(duplicateMutation.mutateAsync(), { - loading: "Duplicating cap...", - success: "Cap duplicated successfully", - error: "Failed to duplicate cap", - }); - }} - disabled={duplicateMutation.isPending || cap.hasActiveUpload} - className="flex gap-2 items-center rounded-lg" - > - -

Duplicate

-
- { - if (!user.isPro) setUpgradeModalOpen(true); - else setIsPasswordDialogOpen(true); - }} - className="flex gap-2 items-center rounded-lg" - > - -

- {passwordProtected ? "Edit password" : "Add password"} -

-
- { - e.stopPropagation(); - setConfirmOpen(true); - }} - className="flex gap-2 items-center rounded-lg" - > - -

Delete Cap

-
-
-
- )} + )} + { + e.stopPropagation(); + handleDownload(); + }} + className="flex gap-2 items-center rounded-lg" + > + +

Download

+
+ { + e.stopPropagation(); + copyLinkHandler(); + toast.success("Link copied to clipboard"); + }} + className="flex gap-2 items-center rounded-lg" + > + +

Copy link

+
+ {isOwner && ( + <> + { + toast.promise(duplicateMutation.mutateAsync(), { + loading: "Duplicating cap...", + success: "Cap duplicated successfully", + error: "Failed to duplicate cap", + }); + }} + disabled={ + duplicateMutation.isPending || cap.hasActiveUpload + } + className="flex gap-2 items-center rounded-lg" + > + +

Duplicate

+
+ { + if (!user.isPro) setUpgradeModalOpen(true); + else setIsPasswordDialogOpen(true); + }} + className="flex gap-2 items-center rounded-lg" + > + +

+ {passwordProtected ? "Edit password" : "Add password"} +

+
+ { + e.stopPropagation(); + setConfirmOpen(true); + }} + className="flex gap-2 items-center rounded-lg" + > + +

Delete Cap

+
+ + )} +
+
Date: Wed, 29 Oct 2025 19:09:50 +0300 Subject: [PATCH 06/23] some more ui stuff --- Cargo.lock | 2 +- .../dashboard/_components/Navbar/Top.tsx | 9 +- .../components/CompareDataDialog.tsx | 260 ++++++++++++++++++ .../analytics/components/CompareFilters.tsx | 193 +++++++++++++ .../analytics/components/FiltersList.tsx | 57 ++++ .../dashboard/analytics/components/Header.tsx | 64 +++-- .../analytics/components/OtherStats.tsx | 32 ++- .../analytics/components/VideoComponents.tsx | 161 +++++++++++ .../analytics/components/VideoFilters.tsx | 44 +++ .../analytics/components/VideosPicker.tsx | 55 ++++ .../(org)/dashboard/analytics/s/[id]/page.tsx | 119 ++++++++ apps/web/package.json | 2 + packages/ui/src/components/Button.tsx | 2 +- packages/ui/src/components/Select.tsx | 6 +- pnpm-lock.yaml | 83 +++++- 15 files changed, 1027 insertions(+), 62 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/analytics/components/CompareDataDialog.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/VideoComponents.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/VideoFilters.tsx create mode 100644 apps/web/app/(org)/dashboard/analytics/components/VideosPicker.tsx diff --git a/Cargo.lock b/Cargo.lock index 0476f577e3..6675ba37b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,7 +1171,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.78" +version = "0.3.79" dependencies = [ "anyhow", "async-stream", diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 6290a4cc5d..0bd54ad09c 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -64,14 +64,11 @@ const Top = () => { "/dashboard/spaces": "Spaces", "/dashboard/spaces/browse": "Browse Spaces", "/dashboard/analytics": "Analytics", - [`/dashboard/analytics/s/${params.id}`]: "Analytics: cap name", + [`/dashboard/folder/${params.id}`]: "Caps", + [`/dashboard/analytics/s/${params.id}`]: "Analytics: Cap video title", }; - const title = activeSpace - ? activeSpace.name - : pathname.includes("/dashboard/folder") - ? "Caps" - : titles[pathname] || ""; + const title = activeSpace ? activeSpace.name : titles[pathname] || ""; const notificationsRef: MutableRefObject = useClickAway( (e) => { diff --git a/apps/web/app/(org)/dashboard/analytics/components/CompareDataDialog.tsx b/apps/web/app/(org)/dashboard/analytics/components/CompareDataDialog.tsx new file mode 100644 index 0000000000..ffa1ba73f7 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/CompareDataDialog.tsx @@ -0,0 +1,260 @@ +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@cap/ui"; +import { faChartSimple } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { LayoutGroup } from "motion/react"; +import { useRef, useState } from "react"; +import { CompareDataDroppable, type FilterValue } from "./CompareFilters"; +import { FiltersList } from "./FiltersList"; +import CompareVideos from "./VideoFilters"; +import VideosPicker from "./VideosPicker"; + +export const CompareDataDialog = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) => { + const [droppedItems, setDroppedItems] = useState<{ + compare: string | null; + compareTwo: string | null; + }>({ + compare: null, + compareTwo: null, + }); + const [droppedVideos, setDroppedVideos] = useState<{ + video1: string | null; + video2: string | null; + }>({ + video1: null, + video2: null, + }); + const [isDragging, setIsDragging] = useState(false); + const [isVideoDragging, setIsVideoDragging] = useState(false); + const [currentDragValue, setCurrentDragValue] = useState( + null, + ); + const [currentDragVideo, setCurrentDragVideo] = useState(null); + + const FILTERS: readonly FilterValue[] = [ + "views", + "comments", + "reactions", + "shares", + "downloads", + "uploads", + "deletions", + "creations", + "edits", + ] as const; + + const VIDEOS: readonly string[] = [ + "Video A", + "Video B", + "Video C", + "Video D", + ] as const; + + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const [videoDragPosition, setVideoDragPosition] = useState({ x: 0, y: 0 }); + + const compareRef = useRef(null); + const compareTwoRef = useRef(null); + const video1Ref = useRef(null); + const video2Ref = useRef(null); + + const handleDrop = (dropZoneId: "compare" | "compareTwo") => { + if (currentDragValue) { + setDroppedItems((prev) => ({ + ...prev, + [dropZoneId]: currentDragValue, + })); + } + }; + + const handleRemoveItem = (dropZoneId: "compare" | "compareTwo") => { + setDroppedItems((prev) => ({ + ...prev, + [dropZoneId]: null, + })); + }; + + const isFilterInUse = (value: string) => { + return droppedItems.compare === value || droppedItems.compareTwo === value; + }; + + const checkDropZone = (x: number, y: number): string | null => { + const zones = [ + { id: "compare", ref: compareRef }, + { id: "compareTwo", ref: compareTwoRef }, + ]; + + for (const zone of zones) { + if (zone.ref.current) { + const rect = zone.ref.current.getBoundingClientRect(); + if ( + x >= rect.left && + x <= rect.right && + y >= rect.top && + y <= rect.bottom + ) { + return zone.id; + } + } + } + return null; + }; + + const handleVideoDrop = (dropZoneId: "video1" | "video2") => { + if (currentDragVideo) { + setDroppedVideos((prev) => ({ + ...prev, + [dropZoneId]: currentDragVideo, + })); + } + }; + + const handleRemoveVideo = (dropZoneId: "video1" | "video2") => { + setDroppedVideos((prev) => ({ + ...prev, + [dropZoneId]: null, + })); + }; + + const isVideoInUse = (videoId: string) => { + return droppedVideos.video1 === videoId || droppedVideos.video2 === videoId; + }; + + const checkVideoDropZone = (x: number, y: number): string | null => { + const zones = [ + { id: "video1", ref: video1Ref }, + { id: "video2", ref: video2Ref }, + ]; + + for (const zone of zones) { + if (zone.ref.current) { + const rect = zone.ref.current.getBoundingClientRect(); + if ( + x >= rect.left && + x <= rect.right && + y >= rect.top && + y <= rect.bottom + ) { + return zone.id; + } + } + } + return null; + }; + + return ( + + + } + > + Compare data + + +
+ { + setIsDragging(true); + setCurrentDragValue(value); + }} + onFilterDragEnd={(x, y) => { + setIsDragging(false); + const dropZone = checkDropZone(x, y); + if (dropZone) { + handleDrop(dropZone as "compare" | "compareTwo"); + } + setCurrentDragValue(null); + }} + onFilterDrag={(x, y) => setDragPosition({ x, y })} + /> + {/*Main side*/} +
+
+

+ Analysis +

+
+
+
+
+
+

+ Compare +

+ {/* biome-ignore lint: Static ID for drop zone identification */} + handleRemoveItem("compare")} + isDragging={isDragging} + dragPosition={dragPosition} + /> +
+
+

+ with +

+ {/* biome-ignore lint: Static ID for drop zone identification */} + handleRemoveItem("compareTwo")} + isDragging={isDragging} + dragPosition={dragPosition} + /> +
+
+ +
+ +
+
+ {/*Videos Picker*/} + { + setIsVideoDragging(true); + setCurrentDragVideo(videoId); + }} + onVideoDragEnd={(x, y) => { + setIsVideoDragging(false); + const dropZone = checkVideoDropZone(x, y); + if (dropZone) { + handleVideoDrop(dropZone as "video1" | "video2"); + } + setCurrentDragVideo(null); + }} + onVideoDrag={(x, y) => setVideoDragPosition({ x, y })} + /> +
+
+
+
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx b/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx new file mode 100644 index 0000000000..fa5c356097 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import { motion, useDragControls, useMotionValue } from "motion/react"; +import React, { useEffect, useRef, useState } from "react"; + +const filterColorMap = { + views: "bg-blue-300", + comments: "bg-green-300", + reactions: "bg-red-300", + shares: "bg-yellow-300", + downloads: "bg-purple-300", + uploads: "bg-pink-300", + deletions: "bg-gray-300", + creations: "bg-orange-300", + edits: "bg-teal-300", +} as const; + +const labelMap = { + views: "Views", + comments: "Comments", + reactions: "Reactions", + shares: "Shares", + downloads: "Downloads", + uploads: "Uploads", + deletions: "Deletions", + creations: "Creations", + edits: "Edits", +} as const; + +export type FilterValue = keyof typeof filterColorMap; + +interface CompareDataFilterItemProps { + label: string; + value: FilterValue; + isInUse: boolean; + onDragStart: () => void; + onDragEnd: (x: number, y: number) => void; + onDrag: (x: number, y: number) => void; +} + +export const CompareDataFilterItem = ({ + label, + value, + isInUse, + onDragStart, + onDragEnd, + onDrag, +}: CompareDataFilterItemProps) => { + const controls = useDragControls(); + const x = useMotionValue(0); + const y = useMotionValue(0); + const elementRef = useRef(null); + + const handleDragEnd = () => { + if (elementRef.current) { + const rect = elementRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + onDragEnd(centerX, centerY); + } + }; + + const handleDrag = () => { + if (elementRef.current) { + const rect = elementRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + onDrag(centerX, centerY); + } + }; + + if (isInUse) { + return null; + } + + return ( + controls.start(e)} + onDragStart={onDragStart} + onDrag={handleDrag} + style={{ + x, + y, + touchAction: "none", + }} + layout="position" + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ + layout: { + type: "spring", + stiffness: 600, + damping: 35, + mass: 0.8, + }, + opacity: { duration: 0.15, ease: "easeInOut" }, + scale: { duration: 0.15, ease: "easeInOut" }, + x: { type: "spring", stiffness: 600, damping: 35 }, + y: { type: "spring", stiffness: 600, damping: 35 }, + }} + onDragEnd={handleDragEnd} + className={clsx( + "flex-1 px-2 h-6 rounded-full cursor-grab active:cursor-grabbing max-w-fit min-w-fit", + filterColorMap[value as keyof typeof filterColorMap] ?? "bg-gray-5", + )} + > +

{label}

+
+ ); +}; + +interface CompareDataDroppableProps { + id: string; + droppedValue: string | null; + onRemove: () => void; + isDragging: boolean; + dragPosition: { x: number; y: number }; +} + +export const CompareDataDroppable = React.forwardRef< + HTMLDivElement, + CompareDataDroppableProps +>(({ droppedValue, onRemove, isDragging, dragPosition }, ref) => { + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (!isDragging) { + setIsOver(false); + return; + } + + const checkIsOver = () => { + if (ref && typeof ref !== "function" && ref.current) { + const rect = ref.current.getBoundingClientRect(); + const over = + dragPosition.x >= rect.left && + dragPosition.x <= rect.right && + dragPosition.y >= rect.top && + dragPosition.y <= rect.bottom; + setIsOver(over); + } + }; + + checkIsOver(); + }, [isDragging, dragPosition, ref]); + + return ( +
+ {droppedValue && ( +
+

+ {labelMap[droppedValue as keyof typeof labelMap] ?? droppedValue} +

+ +
+ )} +
+ ); +}); diff --git a/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx b/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx new file mode 100644 index 0000000000..54302d0f11 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { CompareDataFilterItem, type FilterValue } from "./CompareFilters"; + +interface FiltersListProps { + filters: readonly FilterValue[]; + isFilterInUse: (value: FilterValue) => boolean; + onFilterDragStart: (value: FilterValue) => void; + onFilterDragEnd: (x: number, y: number) => void; + onFilterDrag: (x: number, y: number) => void; +} + +const FILTER_LABELS: Record = { + views: "Views", + comments: "Comments", + reactions: "Reactions", + shares: "Shares", + downloads: "Downloads", + uploads: "Uploads", + deletions: "Deletions", + creations: "Creations", + edits: "Edits", +}; + +export const FiltersList = ({ + filters, + isFilterInUse, + onFilterDragStart, + onFilterDragEnd, + onFilterDrag, +}: FiltersListProps) => { + return ( +
+
+

+ Filters +

+
+ + + {filters.map((filter) => ( + onFilterDragStart(filter)} + onDragEnd={onFilterDragEnd} + onDrag={onFilterDrag} + /> + ))} + + +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx index 56775a4e02..637302b3e2 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -1,32 +1,48 @@ "use client"; -import { Select } from "@cap/ui"; +import { Button, Select } from "@cap/ui"; +import { useState } from "react"; +import { CompareDataDialog } from "./CompareDataDialog"; export default function Header() { + const [openCompareDataDialog, setOpenCompareDataDialog] = useState(false); return ( -
- {}} - placeholder="Time range" - /> -
+
+ {}} + placeholder="Time range" + /> + +
+ ); } diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx index d986c1c34a..aff9033a75 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -23,7 +23,7 @@ export interface OtherStatsData { browsers: BrowserRowData[]; operatingSystems: OSRowData[]; deviceTypes: DeviceRowData[]; - topCaps: CapRowData[]; + topCaps?: CapRowData[] | null; } interface OtherStatsProps { @@ -97,20 +97,22 @@ export default function OtherStats({ data }: OtherStatsProps) { />
- -
- -
-
+ {data.topCaps && ( + +
+ +
+
+ )}
); } diff --git a/apps/web/app/(org)/dashboard/analytics/components/VideoComponents.tsx b/apps/web/app/(org)/dashboard/analytics/components/VideoComponents.tsx new file mode 100644 index 0000000000..89d26f869f --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/VideoComponents.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import { motion, useDragControls, useMotionValue } from "motion/react"; +import React, { useEffect, useRef, useState } from "react"; + +export type VideoValue = string; // Can be video IDs in the future + +interface DraggableVideoItemProps { + videoId: string; + isInUse: boolean; + onDragStart: () => void; + onDragEnd: (x: number, y: number) => void; + onDrag: (x: number, y: number) => void; +} + +export const DraggableVideoItem = ({ + videoId, + isInUse, + onDragStart, + onDragEnd, + onDrag, +}: DraggableVideoItemProps) => { + const controls = useDragControls(); + const x = useMotionValue(0); + const y = useMotionValue(0); + const elementRef = useRef(null); + + const handleDragEnd = () => { + if (elementRef.current) { + const rect = elementRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + onDragEnd(centerX, centerY); + } + }; + + const handleDrag = () => { + if (elementRef.current) { + const rect = elementRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + onDrag(centerX, centerY); + } + }; + + if (isInUse) { + return null; + } + + return ( + controls.start(e)} + onDragStart={onDragStart} + onDrag={handleDrag} + style={{ + x, + y, + touchAction: "none", + }} + layout="position" + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} + exit={{ opacity: 0, scale: 0.9 }} + transition={{ + layout: { + type: "spring", + stiffness: 600, + damping: 35, + mass: 0.8, + }, + opacity: { duration: 0.15, ease: "easeInOut" }, + scale: { duration: 0.15, ease: "easeInOut" }, + x: { type: "spring", stiffness: 600, damping: 35 }, + y: { type: "spring", stiffness: 600, damping: 35 }, + }} + onDragEnd={handleDragEnd} + className="flex items-center justify-center w-full h-[64px] hover:bg-gray-5 hover:border-gray-7 transition-colors duration-200 rounded-lg bg-gray-4 border border-gray-6 cursor-grab active:cursor-grabbing" + > +

{videoId}

+
+ ); +}; + +interface VideoDroppableProps { + id: string; + droppedValue: string | null; + onRemove: () => void; + isDragging: boolean; + dragPosition: { x: number; y: number }; + label: string; +} + +export const VideoDroppable = React.forwardRef< + HTMLDivElement, + VideoDroppableProps +>(({ droppedValue, onRemove, isDragging, dragPosition, label }, ref) => { + const [isOver, setIsOver] = useState(false); + + useEffect(() => { + if (!isDragging) { + setIsOver(false); + return; + } + + const checkIsOver = () => { + if (ref && typeof ref !== "function" && ref.current) { + const rect = ref.current.getBoundingClientRect(); + const over = + dragPosition.x >= rect.left && + dragPosition.x <= rect.right && + dragPosition.y >= rect.top && + dragPosition.y <= rect.bottom; + setIsOver(over); + } + }; + + checkIsOver(); + }, [isDragging, dragPosition, ref]); + + return ( +
+ {droppedValue ? ( +
+

{droppedValue}

+ +
+ ) : ( +

{label}

+ )} +
+ ); +}); + +VideoDroppable.displayName = "VideoDroppable"; diff --git a/apps/web/app/(org)/dashboard/analytics/components/VideoFilters.tsx b/apps/web/app/(org)/dashboard/analytics/components/VideoFilters.tsx new file mode 100644 index 0000000000..5f63e5b9f3 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/VideoFilters.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { DraggableVideoItem } from "./VideoComponents"; + +interface VideoFiltersProps { + videos: readonly string[]; + isVideoInUse: (videoId: string) => boolean; + onVideoDragStart: (videoId: string) => void; + onVideoDragEnd: (x: number, y: number) => void; + onVideoDrag: (x: number, y: number) => void; +} + +export default function VideoFilters({ + videos, + isVideoInUse, + onVideoDragStart, + onVideoDragEnd, + onVideoDrag, +}: VideoFiltersProps) { + return ( +
+
+

+ Videos +

+
+ + + {videos.map((videoId) => ( + onVideoDragStart(videoId)} + onDragEnd={onVideoDragEnd} + onDrag={onVideoDrag} + /> + ))} + + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/VideosPicker.tsx b/apps/web/app/(org)/dashboard/analytics/components/VideosPicker.tsx new file mode 100644 index 0000000000..aac2c71767 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/VideosPicker.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { faRightLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type React from "react"; +import { VideoDroppable } from "./VideoComponents"; + +interface VideosPickerProps { + droppedVideos: { + video1: string | null; + video2: string | null; + }; + onRemoveVideo: (slotId: "video1" | "video2") => void; + isDragging: boolean; + dragPosition: { x: number; y: number }; + video1Ref: React.RefObject; + video2Ref: React.RefObject; +} + +export default function VideosPicker({ + droppedVideos, + onRemoveVideo, + isDragging, + dragPosition, + video1Ref, + video2Ref, +}: VideosPickerProps) { + return ( +
+ {/* biome-ignore lint: Static ID for drop zone identification */} + onRemoveVideo("video1")} + isDragging={isDragging} + dragPosition={dragPosition} + label="Video 1" + /> +
+ +
+ {/* biome-ignore lint: Static ID for drop zone identification */} + onRemoveVideo("video2")} + isDragging={isDragging} + dragPosition={dragPosition} + label="Video 2" + /> +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx b/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx index 702eca87f6..2ecb23c042 100644 --- a/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx @@ -1,4 +1,5 @@ import Header from "../../components/Header"; +import OtherStats, { type OtherStatsData } from "../../components/OtherStats"; import StatsChart from "../../components/StatsChart"; export default function AnalyticsPage() { @@ -13,6 +14,124 @@ export default function AnalyticsPage() { }} defaultSelectedBox="views" /> +
); } + +const mockData: OtherStatsData = { + countries: [ + { + countryCode: "US", + name: "United States", + views: "8,452", + comments: "100", + reactions: "100", + percentage: "34.2%", + }, + { + countryCode: "GB", + name: "United Kingdom", + views: "3,891", + comments: "100", + reactions: "100", + percentage: "15.7%", + }, + { + countryCode: "CA", + name: "Canada", + views: "2,764", + comments: "100", + reactions: "100", + percentage: "11.2%", + }, + { + countryCode: "DE", + name: "Germany", + views: "2,143", + comments: "100", + reactions: "100", + percentage: "8.7%", + }, + { + countryCode: "FR", + name: "France", + views: "1,876", + comments: "100", + reactions: "100", + percentage: "7.6%", + }, + { + countryCode: "AU", + name: "Australia", + views: "1,542", + comments: "100", + reactions: "100", + percentage: "6.2%", + }, + ], + cities: [ + { + countryCode: "US", + name: "New York", + views: "3,421", + comments: "100", + reactions: "100", + percentage: "18.7%", + }, + { + countryCode: "US", + name: "Los Angeles", + views: "2,876", + comments: "100", + reactions: "100", + percentage: "15.7%", + }, + { + countryCode: "GB", + name: "London", + views: "2,145", + comments: "100", + reactions: "100", + percentage: "11.7%", + }, + { + countryCode: "CA", + name: "Toronto", + views: "1,892", + comments: "100", + reactions: "100", + percentage: "10.3%", + }, + ], + browsers: [ + { + browser: "google-chrome", + name: "Chrome", + views: "8,452", + comments: "100", + reactions: "100", + percentage: "34.2%", + }, + ], + operatingSystems: [ + { + os: "windows", + name: "Windows", + views: "8,452", + comments: "100", + reactions: "100", + percentage: "34.2%", + }, + ], + deviceTypes: [ + { + device: "desktop", + name: "Desktop", + views: "8,452", + comments: "100", + reactions: "100", + percentage: "34.2%", + }, + ], +}; diff --git a/apps/web/package.json b/apps/web/package.json index b471f866a9..e35a5c9a86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,8 @@ "@cap/web-backend": "workspace:*", "@cap/web-domain": "workspace:*", "@deepgram/sdk": "^3.3.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@dub/analytics": "^0.0.27", "@effect/cluster": "^0.50.4", "@effect/experimental": "^0.56.0", diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 3e64fc3c3b..0ea1c1448b 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -21,7 +21,7 @@ const buttonVariants = cva( outline: "border border-gray-4 hover:border-gray-5 hover:bg-gray-3 text-gray-12 disabled:bg-gray-8", white: - "bg-gray-1 border border-gray-5 text-gray-12 hover:bg-gray-3 disabled:bg-gray-8", + "bg-gray-3 border border-gray-5 hover:border-gray-6 text-gray-12 hover:bg-gray-6 disabled:bg-gray-8", ghost: "hover:bg-white/20 hover:text-white", gray: "bg-gray-5 hover:bg-gray-7 border gray-button-border gray-button-shadow text-gray-12 disabled:border-gray-7 disabled:bg-gray-8 disabled:text-gray-11", dark: "bg-gray-12 dark-button-shadow hover:bg-gray-11 border dark-button-border text-gray-1 disabled:cursor-not-allowed disabled:text-gray-10 disabled:bg-gray-7 disabled:border-gray-8", diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index 8c38dc0067..0840309ce7 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -11,7 +11,7 @@ const SelectVariantContext = React.createContext("default"); const selectTriggerVariants = cva( cx( - "font-medium flex px-4 py-2 transition-all duration-200 text-[13px] outline-0", + "font-medium flex px-4 py-2 transition-all duration-200 text-sm outline-0", "rounded-xl border-[1px] items-center justify-between gap-2 whitespace-nowrap", "disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-gray-3 disabled:text-gray-9", "ring-0 ring-gray-12 ring-offset-0 data-[state=open]:ring-offset-2 ring-offset-gray-1 data-[state=open]:ring-1", @@ -122,7 +122,7 @@ function Select({ {...props} > - + {options.map((option) => ( @@ -223,7 +223,7 @@ function SelectLabel({ return ( ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a696b85955..3ac2a81c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 0.44.0 dotenv-cli: specifier: latest - version: 10.0.0 + version: 11.0.0 tsdown: specifier: ^0.15.6 version: 0.15.6(typescript@5.8.3) @@ -471,6 +471,12 @@ importers: '@deepgram/sdk': specifier: ^3.3.4 version: 3.13.0(encoding@0.1.13) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.1) '@dub/analytics': specifier: ^0.0.27 version: 0.0.27 @@ -1051,7 +1057,7 @@ importers: version: 19.1.9(@types/react@19.1.13) dotenv-cli: specifier: latest - version: 10.0.0 + version: 11.0.0 drizzle-kit: specifier: 0.31.0 version: 0.31.0 @@ -2230,6 +2236,22 @@ packages: resolution: {integrity: sha512-KrkT6qO5NxqNfy68sBl6CTSoJ4SNDIS5iQArkibhlbGU4LaDukZ3q2HIkh8aUKDio6o4itU4xDR7t82Y2eP1Bg==} engines: {node: '>=14'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -6518,10 +6540,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.0.0-rc.2': - resolution: {integrity: sha512-Vp1xN+h7BKQ6k+zE2LHExfE65yL2d2q9TiVrIHJ2O7dukVkd5UYBOS9CLVenidgfbFRzt2hUQPLLKhTjJLwMmA==} + '@storybook/builder-vite@10.1.0-alpha.0': + resolution: {integrity: sha512-ipGNG3YeYnSsnnWyKJKQUv6On8OIK8Vk7Pr51rA4BDH8Jw1zI5ulSd2zTCu6iCCH2G26KrMoXaoZ49rrJ1XEdw==} peerDependencies: - storybook: ^10.0.0-rc.2 + storybook: ^10.1.0-alpha.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6532,12 +6554,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.0.0-rc.2': - resolution: {integrity: sha512-GCAlUdEbcdwaDT4JL8N/Dv0w3ZMNf80tdiFC26PMK+UZBBd9DKyEJfDTd2NLSzDXEG1DGRUKJymDec8ocFFqxQ==} + '@storybook/csf-plugin@10.1.0-alpha.0': + resolution: {integrity: sha512-UKvA8vmGf7De9YCMsR5FRZ5J4jlTKwebheCopni3nCTn744Y9k2JmucdzfBTXIwwEl6SSuAnNc6eIKzUdSM0KQ==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.0-rc.2 + storybook: ^10.1.0-alpha.0 vite: '*' webpack: '*' peerDependenciesMeta: @@ -9104,10 +9126,18 @@ packages: resolution: {integrity: sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==} hasBin: true + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -16782,6 +16812,24 @@ snapshots: gonzales-pe: 4.3.0 node-source-walk: 6.0.2 + '@dnd-kit/accessibility@3.1.1(react@19.1.1)': + dependencies: + react: 19.1.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.1) + '@dnd-kit/utilities': 3.2.2(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.1)': + dependencies: + react: 19.1.1 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@dub/analytics@0.0.27': @@ -21212,9 +21260,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) @@ -21244,7 +21292,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -24114,10 +24162,21 @@ snapshots: dotenv-expand: 11.0.7 minimist: 1.2.8 + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.2.1 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + dotenv-expand@11.0.7: dependencies: dotenv: 16.5.0 + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.5.0 + dotenv@16.0.3: {} dotenv@16.5.0: {} @@ -29959,7 +30018,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.0.0-rc.2(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6 From d77c1812ed1f68e6724ad0ce744ddee525df3758 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:37:47 +0300 Subject: [PATCH 07/23] comment out metric select --- apps/web/app/(org)/dashboard/analytics/components/Header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx index 637302b3e2..eab2d82c00 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -13,7 +13,7 @@ export default function Header() { onOpenChange={setOpenCompareDataDialog} />
- Date: Fri, 31 Oct 2025 14:51:08 +0300 Subject: [PATCH 08/23] make select look better --- .../dashboard/analytics/components/Header.tsx | 18 ++--- .../analytics/components/TableCard.tsx | 28 ++++++-- packages/ui/src/components/Select.tsx | 65 +++++++++++++------ 3 files changed, 74 insertions(+), 37 deletions(-) diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx index eab2d82c00..a203976624 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -3,6 +3,8 @@ import { Button, Select } from "@cap/ui"; import { useState } from "react"; import { CompareDataDialog } from "./CompareDataDialog"; +import { faCalendar } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export default function Header() { const [openCompareDataDialog, setOpenCompareDataDialog] = useState(false); @@ -13,20 +15,10 @@ export default function Header() { onOpenChange={setOpenCompareDataDialog} />
- {/* } + size="md" options={[ { value: "24_hours", label: "24 hours" }, { value: "7_days", label: "7 days" }, diff --git a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx index c960107bc1..f9e50cbb6f 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx @@ -1,5 +1,6 @@ import { LogoBadge, + Select, Table, TableBody, TableCell, @@ -11,7 +12,9 @@ import { faAppleWhole, faDesktop, faMobileScreen, + faFilter, faTablet, + faDownLong, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -120,7 +123,22 @@ const TableCard = ({ }: TableCardProps) => { return (
-

{title}

+
+

{title}

+ } - size="md" - options={[ - { value: "24_hours", label: "24 hours" }, - { value: "7_days", label: "7 days" }, - { value: "30_days", label: "30 days" }, - ]} - onValueChange={() => {}} - placeholder="Time range" - /> - -
- - ); +interface HeaderProps { + options: { value: AnalyticsRange; label: string }[]; + value: AnalyticsRange; + onChange: (value: AnalyticsRange) => void; + isLoading?: boolean; +} + +export default function Header({ + options, + value, + onChange, + isLoading, +}: HeaderProps) { + return ( + <> +
+ } - size="md" - options={[ - { label: "Views", value: "views_desc" }, - { label: "Comments", value: "comments_desc" }, - { label: "Reactions", value: "reactions_desc" }, - { label: "Percentage", value: "percentage_desc" }, - ]} - onValueChange={() => {}} - placeholder="Sort by" - /> -
-
- - - - {columns.map((column) => ( - - {column} - - ))} - - -
-
- - - {rows.map((row) => ( - - -
- - {getIconForRow(row, type)} - - {row.name} -
-
- - {row.views} - - - {row.comments} - - - {row.reactions} - - - {row.percentage} - -
- ))} -
-
-
-
-
- ); + const hasRows = rows.length > 0; + const placeholders = Array.from({ length: 4 }, (_, index) => ({ + name: `placeholder-${index}`, + views: 0, + comments: null, + reactions: null, + percentage: 0, + })) as TableCardProps["rows"]; + + const displayRows = hasRows ? rows : placeholders; + + return ( +
+
+

{title}

+ } - size="md" - options={options} - value={value} - onValueChange={(val) => onChange(val as AnalyticsRange)} +
+ {capId && capName && ( +
+ +
+ + {capName} + +
+
+ )} + {!hideCapsSelect && ( + -
- + > + + {displayIconName && ( + + )} + + {displayName} + + + + + + + + + + + + + {user?.name ? `${user.name}'s Caps` : "My Caps"} + + + {filteredSpaces && filteredSpaces.length > 0 && ( + <> +
+ Spaces +
+ {filteredSpaces.map((space) => { + return ( + + + + {space.name} + + + ); + })} + + )} +
+
+
+ + )} + + +
+ + + + + {selectedOption.label} + + + + + + + +
+ + + + + {DATE_RANGE_OPTIONS.map((option) => { + const isSelected = selectedOption.value === option.value; + return ( + + + {option.label} + + + ); + })} + + + +
+
); } diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx index 31b0fa568a..2f9fc413cc 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -79,6 +79,7 @@ const toCapRow = (row: BreakdownRow & { id?: string }): CapRowData => ({ comments: null, reactions: null, percentage: row.percentage, + id: row.id, }); const browserNameToSlug = (name: string): BrowserRowData["browser"] => { @@ -102,10 +103,11 @@ const browserNameToSlug = (name: string): BrowserRowData["browser"] => { }; const osNameToKey = (name: string): OSRowData["os"] => { - switch (name.toLowerCase()) { - case "macos": - case "ios": - return "ios"; + const normalized = name.toLowerCase().trim(); + if (normalized.includes("mac") || normalized === "ios") { + return "ios"; + } + switch (normalized) { case "linux": return "linux"; case "ubuntu": @@ -127,8 +129,6 @@ export default function OtherStats({ data, isLoading }: OtherStatsProps) { columns={[ "Country", "Views", - "Comments", - "Reactions", "Percentage", ]} rows={data.countries.map(toCountryRow)} @@ -137,7 +137,7 @@ export default function OtherStats({ data, isLoading }: OtherStatsProps) { /> ({ device: deviceMap[device.name.toLowerCase()] ?? "desktop", name: device.name, @@ -205,7 +201,7 @@ export default function OtherStats({ data, isLoading }: OtherStatsProps) {
{ interface StatsChartProps { counts: Record; data: ChartPoint[]; - defaultSelectedBox?: boxes; isLoading?: boolean; + capId?: string | null; } export default function StatsBox({ counts, data, - defaultSelectedBox = "caps", isLoading, + capId, }: StatsChartProps) { - const [selectedBox, setSelectedBox] = useState(defaultSelectedBox); + const [selectedBoxes, setSelectedBoxes] = useState>( + new Set(["views", "comments", "reactions"]) + ); const capsBoxRef = useRef(null); const viewsBoxRef = useRef(null); const chatsBoxRef = useRef(null); const reactionsBoxRef = useRef(null); - const selectHandler = (box: boxes) => { - setSelectedBox(box); + const toggleHandler = (box: boxes) => { + setSelectedBoxes((prev) => { + const next = new Set(prev); + if (next.has(box)) { + next.delete(box); + } else { + next.add(box); + } + return next; + }); }; const formattedCounts = useMemo( @@ -68,47 +72,65 @@ export default function StatsBox({ ); return ( -
+
- selectHandler("caps")} - isSelected={selectedBox === "caps"} - title="Caps" - value={formattedCounts.caps} - onMouseEnter={() => capsBoxRef.current?.startAnimation()} - onMouseLeave={() => capsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("views")} - isSelected={selectedBox === "views"} - title="Views" - value={formattedCounts.views} - onMouseEnter={() => viewsBoxRef.current?.startAnimation()} - onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("comments")} - isSelected={selectedBox === "comments"} - title="Comments" - value={formattedCounts.comments} - onMouseEnter={() => chatsBoxRef.current?.startAnimation()} - onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} - icon={} - /> - selectHandler("reactions")} - isSelected={selectedBox === "reactions"} - title="Reactions" - value={formattedCounts.reactions} - onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} - onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} - icon={} - /> + {isLoading ? ( + <> + {Array.from({ length: capId ? 3 : 4 }, (_, index) => ( + + ))} + + ) : ( + <> + toggleHandler("views")} + isSelected={selectedBoxes.has("views")} + title="Views" + value={formattedCounts.views} + metric="views" + onMouseEnter={() => viewsBoxRef.current?.startAnimation()} + onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} + icon={} + /> + toggleHandler("comments")} + isSelected={selectedBoxes.has("comments")} + title="Comments" + value={formattedCounts.comments} + metric="comments" + onMouseEnter={() => chatsBoxRef.current?.startAnimation()} + onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} + icon={} + /> + toggleHandler("reactions")} + isSelected={selectedBoxes.has("reactions")} + title="Reactions" + value={formattedCounts.reactions} + metric="reactions" + onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} + onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} + icon={} + /> + {!capId && ( + toggleHandler("caps")} + isSelected={selectedBoxes.has("caps")} + title="Caps" + value={formattedCounts.caps} + metric="caps" + onMouseEnter={() => capsBoxRef.current?.startAnimation()} + onMouseLeave={() => capsBoxRef.current?.stopAnimation()} + icon={} + /> + )} + + )}
!capId || metric !== "caps" + )} data={data} isLoading={isLoading} /> @@ -116,46 +138,124 @@ export default function StatsBox({ ); } -interface StatBoxProps extends HTMLAttributes { +const metricColors = { + views: { + bg: "rgba(59, 130, 246, 0.03)", + bgHover: "rgba(59, 130, 246, 0.05)", + bgSelected: "rgba(59, 130, 246, 0.08)", + border: "rgba(59, 130, 246, 0.15)", + borderSelected: "rgba(59, 130, 246, 0.25)", + }, + comments: { + bg: "rgba(236, 72, 153, 0.03)", + bgHover: "rgba(236, 72, 153, 0.05)", + bgSelected: "rgba(236, 72, 153, 0.08)", + border: "rgba(236, 72, 153, 0.15)", + borderSelected: "rgba(236, 72, 153, 0.25)", + }, + reactions: { + bg: "rgba(249, 115, 22, 0.03)", + bgHover: "rgba(249, 115, 22, 0.05)", + bgSelected: "rgba(249, 115, 22, 0.08)", + border: "rgba(249, 115, 22, 0.15)", + borderSelected: "rgba(249, 115, 22, 0.25)", + }, + caps: { + bg: "rgba(0, 0, 0, 0.02)", + bgHover: "rgba(0, 0, 0, 0.03)", + bgSelected: "rgba(0, 0, 0, 0.05)", + border: "rgba(0, 0, 0, 0.12)", + borderSelected: "rgba(0, 0, 0, 0.2)", + }, +} as const; + +interface StatBoxProps extends React.ButtonHTMLAttributes { title: string; value: string; icon: React.ReactElement>; isSelected?: boolean; + metric: boxes; } function StatBox({ title, value, icon, isSelected = false, + metric, ...props }: StatBoxProps) { + const colors = metricColors[metric]; + return ( -
{ + if (!isSelected) { + e.currentTarget.style.backgroundColor = colors.bgHover; + } + props.onMouseEnter?.(e); + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = colors.bg; + } + props.onMouseLeave?.(e); + }} > -
- {cloneElement(icon, { - className: classNames( - "group-hover:text-gray-12 transition-colors duration-200", - isSelected ? "text-gray-12" : "text-gray-10" - ), - })} -

+

+ {cloneElement(icon, { + className: classNames( + "group-hover:text-gray-12 transition-colors duration-200", + isSelected ? "text-gray-12" : "text-gray-10" + ), + })} +

+ {title} +

+
+
- {title} -

+ {isSelected && ( + + )} +

{value}

+ + ); +} + +function StatBoxSkeleton() { + return ( +
+
+
+
+
+
); } diff --git a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx index 72b27aa37c..40eeb30172 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx @@ -1,6 +1,7 @@ +"use client"; + import { LogoBadge, - Select, Table, TableBody, TableCell, @@ -11,8 +12,6 @@ import { import { faAppleWhole, faDesktop, - faDownLong, - faFilter, faMobileScreen, faTablet, } from "@fortawesome/free-solid-svg-icons"; @@ -20,8 +19,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import getUnicodeFlagIcon from "country-flag-icons/unicode"; import Image from "next/image"; +import { useRouter } from "next/navigation"; -const countryCodeToIcon = (countryCode: string) => { +const countryCodeToIcon = (countryCode: string | undefined | null) => { + if (!countryCode || countryCode.trim() === "") { + return null; + } return getUnicodeFlagIcon(countryCode.toUpperCase()); }; @@ -30,7 +33,10 @@ const formatNumber = (value?: number | null) => const formatPercentage = (value?: number | null) => value == null ? "—" : `${Math.round(value * 100)}%`; const skeletonBar = (width = 48) => ( -
+
); type BrowserType = @@ -106,6 +112,7 @@ export interface CapRowData { comments?: number | null; reactions?: number | null; percentage: number; + id?: string; } export interface TableCardProps { @@ -124,13 +131,14 @@ export interface TableCardProps { } const TableCard = ({ - title, + title: _title, columns, rows, type, tableClassname, isLoading, }: TableCardProps) => { + const router = useRouter(); const hasRows = rows.length > 0; const placeholders = Array.from({ length: 4 }, (_, index) => ({ name: `placeholder-${index}`, @@ -140,11 +148,18 @@ const TableCard = ({ percentage: 0, })) as TableCardProps["rows"]; - const displayRows = hasRows ? rows : placeholders; + const displayRows = isLoading || !hasRows ? placeholders : rows; + const showSkeletons = isLoading || !hasRows; + + const handleCapNameClick = (capId: string | undefined) => { + if (capId && type === "cap") { + router.push(`/dashboard/analytics?capId=${capId}`); + } + }; return ( -
-
+
+ {/*

{title}

*/} -
-
- - - - {columns.map((column) => ( - - {column} - - ))} - - - - {displayRows.map((row, index) => { - const capRow = type === "cap" ? (row as CapRowData) : null; - const uniqueKey = `${row.name ?? `row-${index}`}-${index}`; - const isCap = type === "cap"; - const isClickable = isCap && capRow?.id && !showSkeletons; - return ( - - -
- - {showSkeletons ? ( -
- ) : ( - getIconForRow(row, type) - )} - - {isClickable ? ( - - ) : ( - - {showSkeletons ? ( -
- ) : ( - row.name - )} - - )} -
- - - {showSkeletons - ? skeletonBar(48) - : formatNumber(row.views)} - - - {showSkeletons - ? skeletonBar(40) - : formatPercentage(row.percentage)} - - - ); - })} - -
-
-
-
- ); +
+
+ + + + {columns.map((column) => ( + + {column} + + ))} + + + + {displayRows.map((row, index) => { + const capRow = type === "cap" ? (row as CapRowData) : null; + const uniqueKey = `${row.name ?? `row-${index}`}-${index}`; + const isCap = type === "cap"; + const isClickable = isCap && capRow?.id && !showSkeletons; + return ( + + +
+ + {showSkeletons ? ( +
+ ) : ( + getIconForRow(row, type) + )} + + {isClickable ? ( + + ) : ( + + {showSkeletons ? ( +
+ ) : ( + row.name + )} + + )} +
+ + + {showSkeletons + ? skeletonBar(48) + : formatNumber(row.views)} + + + {showSkeletons + ? skeletonBar(40) + : formatPercentage(row.percentage)} + + + ); + })} + +
+
+
+
+ ); }; const getIconForRow = ( - row: - | CountryRowData - | CityRowData - | BrowserRowData - | OSRowData - | DeviceRowData - | CapRowData, - type: TableCardProps["type"] + row: + | CountryRowData + | CityRowData + | BrowserRowData + | OSRowData + | DeviceRowData + | CapRowData, + type: TableCardProps["type"], ) => { - switch (type) { - case "country": - return countryCodeToIcon((row as CountryRowData).countryCode); - case "city": - return countryCodeToIcon((row as CityRowData).countryCode); - case "browser": - return ; - case "os": - return ; - case "device": { - const device = (row as DeviceRowData).device; - const iconMap = { - desktop: faDesktop, - tablet: faTablet, - mobile: faMobileScreen, - }; - return ( - - ); - } - case "cap": - return ; - default: - return null; - } + switch (type) { + case "country": + return countryCodeToIcon((row as CountryRowData).countryCode); + case "city": + return countryCodeToIcon((row as CityRowData).countryCode); + case "browser": + return ; + case "os": + return ; + case "device": { + const device = (row as DeviceRowData).device; + const iconMap = { + desktop: faDesktop, + tablet: faTablet, + mobile: faMobileScreen, + }; + return ( + + ); + } + case "cap": + return ; + default: + return null; + } }; type OperatingSystemIconProps = { - operatingSystem: OperatingSystemType; + operatingSystem: OperatingSystemType; }; const OperatingSystemIcon = ({ operatingSystem }: OperatingSystemIconProps) => { - if (operatingSystem === "ios") { - return ; - } - return ( - {operatingSystem} - ); + if (operatingSystem === "ios") { + return ; + } + return ( + {operatingSystem} + ); }; type BrowserIconProps = { - browser: BrowserType; + browser: BrowserType; }; const BrowserIcon = ({ browser }: BrowserIconProps) => ( - {browser} + {browser} ); export default TableCard; diff --git a/apps/web/app/(org)/dashboard/analytics/data.ts b/apps/web/app/(org)/dashboard/analytics/data.ts index 116d9c5c86..debc4d6a4c 100644 --- a/apps/web/app/(org)/dashboard/analytics/data.ts +++ b/apps/web/app/(org)/dashboard/analytics/data.ts @@ -1,5 +1,5 @@ import { db } from "@cap/database"; -import { comments, videos, spaceVideos } from "@cap/database/schema"; +import { comments, spaceVideos, videos } from "@cap/database/schema"; import { Tinybird } from "@cap/web-backend"; import { and, between, eq, inArray, sql } from "drizzle-orm"; import { Effect } from "effect"; @@ -31,7 +31,10 @@ type TinybirdAnalyticsData = { topCapsRaw: TopCapRow[]; }; -const RANGE_CONFIG: Record = { +const RANGE_CONFIG: Record< + AnalyticsRange, + { hours: number; bucket: "hour" | "day" } +> = { "24h": { hours: 24, bucket: "hour" }, "7d": { hours: 7 * 24, bucket: "day" }, "30d": { hours: 30 * 24, bucket: "day" }, @@ -39,17 +42,23 @@ const RANGE_CONFIG: Record value.replace(/'/g, "''"); const toDateString = (date: Date) => date.toISOString().slice(0, 10); -const toDateTimeString = (date: Date) => date.toISOString().slice(0, 19).replace("T", " "); +const toDateTimeString = (date: Date) => + date.toISOString().slice(0, 19).replace("T", " "); const buildPathnameFilter = (spaceVideoIds?: VideoId[]): string => { if (!spaceVideoIds || spaceVideoIds.length === 0) { return ""; } - const pathnames = spaceVideoIds.map((id) => `'/s/${escapeLiteral(id)}'`).join(", "); + const pathnames = spaceVideoIds + .map((id) => `'/s/${escapeLiteral(id)}'`) + .join(", "); return `AND pathname IN (${pathnames})`; }; -const normalizeBucket = (input: string | null | undefined, bucket: "hour" | "day") => { +const normalizeBucket = ( + input: string | null | undefined, + bucket: "hour" | "day", +) => { if (!input) return undefined; if (input.endsWith("Z")) return input; if (bucket === "day" && input.length === 10) return `${input}T00:00:00Z`; @@ -77,10 +86,10 @@ const fallbackIfEmpty = ( ) => fallback ? primary.pipe( - Effect.flatMap((rows) => - rows.length > 0 ? Effect.succeed(rows) : fallback, - ), - ) + Effect.flatMap((rows) => + rows.length > 0 ? Effect.succeed(rows) : fallback, + ), + ) : primary; const withTinybirdFallback = ( @@ -132,7 +141,10 @@ export const getOrgAnalyticsData = async ( const capVideoIds = capId ? [capId as VideoId] : undefined; const videoIds = capVideoIds || spaceVideoIds; - if ((spaceId && spaceVideoIds && spaceVideoIds.length === 0) || (capId && !capVideoIds)) { + if ( + (spaceId && spaceVideoIds && spaceVideoIds.length === 0) || + (capId && !capVideoIds) + ) { let capName: string | undefined; if (capId) { const capNames = await loadVideoNames([capId]); @@ -166,16 +178,35 @@ export const getOrgAnalyticsData = async ( const [capsSeries, commentSeries, reactionSeries] = await Promise.all([ queryVideoSeries(typedOrgId, from, to, rangeConfig.bucket, videoIds), - queryCommentsSeries(typedOrgId, from, to, "text", rangeConfig.bucket, videoIds), - queryCommentsSeries(typedOrgId, from, to, "emoji", rangeConfig.bucket, videoIds), + queryCommentsSeries( + typedOrgId, + from, + to, + "text", + rangeConfig.bucket, + videoIds, + ), + queryCommentsSeries( + typedOrgId, + from, + to, + "emoji", + rangeConfig.bucket, + videoIds, + ), ]); const tinybirdData = await runPromise( Effect.gen(function* () { const tinybird = yield* Tinybird; console.log("getOrgAnalyticsData - orgId:", orgId, "range:", range); - console.log("getOrgAnalyticsData - from:", from.toISOString(), "to:", to.toISOString()); - + console.log( + "getOrgAnalyticsData - from:", + from.toISOString(), + "to:", + to.toISOString(), + ); + const viewSeries = yield* queryViewSeries( tinybird, typedOrgId, @@ -184,20 +215,59 @@ export const getOrgAnalyticsData = async ( rangeConfig.bucket, videoIds, ); - console.log("getOrgAnalyticsData - viewSeries:", JSON.stringify(viewSeries, null, 2)); - - const countries = yield* queryCountries(tinybird, typedOrgId, from, to, videoIds); - console.log("getOrgAnalyticsData - countries:", JSON.stringify(countries, null, 2)); - - const cities = yield* queryCities(tinybird, typedOrgId, from, to, videoIds); - console.log("getOrgAnalyticsData - cities:", JSON.stringify(cities, null, 2)); - - const browsers = yield* queryBrowsers(tinybird, typedOrgId, from, to, videoIds); - console.log("getOrgAnalyticsData - browsers:", JSON.stringify(browsers, null, 2)); - - const devices = yield* queryDevices(tinybird, typedOrgId, from, to, videoIds); - console.log("getOrgAnalyticsData - devices:", JSON.stringify(devices, null, 2)); - + console.log( + "getOrgAnalyticsData - viewSeries:", + JSON.stringify(viewSeries, null, 2), + ); + + const countries = yield* queryCountries( + tinybird, + typedOrgId, + from, + to, + videoIds, + ); + console.log( + "getOrgAnalyticsData - countries:", + JSON.stringify(countries, null, 2), + ); + + const cities = yield* queryCities( + tinybird, + typedOrgId, + from, + to, + videoIds, + ); + console.log( + "getOrgAnalyticsData - cities:", + JSON.stringify(cities, null, 2), + ); + + const browsers = yield* queryBrowsers( + tinybird, + typedOrgId, + from, + to, + videoIds, + ); + console.log( + "getOrgAnalyticsData - browsers:", + JSON.stringify(browsers, null, 2), + ); + + const devices = yield* queryDevices( + tinybird, + typedOrgId, + from, + to, + videoIds, + ); + console.log( + "getOrgAnalyticsData - devices:", + JSON.stringify(devices, null, 2), + ); + const operatingSystems = yield* queryOperatingSystems( tinybird, typedOrgId, @@ -205,11 +275,19 @@ export const getOrgAnalyticsData = async ( to, videoIds, ); - console.log("getOrgAnalyticsData - operatingSystems:", JSON.stringify(operatingSystems, null, 2)); - - const topCapsRaw = capId ? [] : yield* queryTopCaps(tinybird, typedOrgId, from, to, videoIds); - console.log("getOrgAnalyticsData - topCapsRaw:", JSON.stringify(topCapsRaw, null, 2)); - + console.log( + "getOrgAnalyticsData - operatingSystems:", + JSON.stringify(operatingSystems, null, 2), + ); + + const topCapsRaw = capId + ? [] + : yield* queryTopCaps(tinybird, typedOrgId, from, to, videoIds); + console.log( + "getOrgAnalyticsData - topCapsRaw:", + JSON.stringify(topCapsRaw, null, 2), + ); + return { viewSeries, countries, @@ -222,15 +300,22 @@ export const getOrgAnalyticsData = async ( }), ); - const totalViews = tinybirdData.viewSeries.reduce((sum, row) => sum + row.views, 0); + const totalViews = tinybirdData.viewSeries.reduce( + (sum, row) => sum + row.views, + 0, + ); const totalCaps = capsSeries.reduce((sum, row) => sum + row.count, 0); const totalComments = commentSeries.reduce((sum, row) => sum + row.count, 0); - const totalReactions = reactionSeries.reduce((sum, row) => sum + row.count, 0); + const totalReactions = reactionSeries.reduce( + (sum, row) => sum + row.count, + 0, + ); const chartData = buckets.map((bucket) => ({ bucket, caps: capsSeries.find((row) => row.bucket === bucket)?.count ?? 0, - views: tinybirdData.viewSeries.find((row) => row.bucket === bucket)?.views ?? 0, + views: + tinybirdData.viewSeries.find((row) => row.bucket === bucket)?.views ?? 0, comments: commentSeries.find((row) => row.bucket === bucket)?.count ?? 0, reactions: reactionSeries.find((row) => row.bucket === bucket)?.count ?? 0, })); @@ -298,8 +383,13 @@ const normalizeOSName = (name: string): string => { const normalized = name.trim(); if (!normalized) return "Unknown"; const lower = normalized.toLowerCase(); - - if (lower.includes("mac") || lower === "macos" || lower === "mac os" || lower === "darwin") { + + if ( + lower.includes("mac") || + lower === "macos" || + lower === "mac os" || + lower === "darwin" + ) { return "macOS"; } if (lower.includes("windows") || lower === "win") { @@ -320,7 +410,7 @@ const normalizeOSName = (name: string): string => { if (lower.includes("fedora")) { return "Fedora"; } - + return normalized; }; @@ -329,7 +419,7 @@ const normalizeDeviceName = (name: string): string => { const normalized = name.trim(); if (!normalized) return "Unknown"; const lower = normalized.toLowerCase(); - + if (lower === "desktop" || lower === "desktop computer" || lower === "pc") { return "Desktop"; } @@ -339,7 +429,7 @@ const normalizeDeviceName = (name: string): string => { if (lower === "tablet" || lower === "ipad") { return "Tablet"; } - + return normalized; }; @@ -348,7 +438,7 @@ const normalizeBrowserName = (name: string): string => { const normalized = name.trim(); if (!normalized) return "Unknown"; const lower = normalized.toLowerCase(); - + if (lower.includes("chrome") && !lower.includes("chromium")) { return "Chrome"; } @@ -370,7 +460,7 @@ const normalizeBrowserName = (name: string): string => { if (lower.includes("internet explorer") || lower === "ie") { return "Internet Explorer"; } - + return normalized; }; @@ -381,14 +471,14 @@ const normalizeAndAggregate = ( normalizeFn: (name: string) => string, ): BreakdownRow[] => { const aggregated = new Map(); - + for (const row of rows) { const originalName = getName(row); const normalizedName = normalizeFn(originalName); const currentViews = aggregated.get(normalizedName) ?? 0; aggregated.set(normalizedName, currentViews + row.views); } - + return Array.from(aggregated.entries()) .map(([name, views]) => ({ name, @@ -435,7 +525,7 @@ const queryVideoSeries = async ( eq(videos.orgId, orgId), between(videos.createdAt, from, to), ]; - + if (spaceVideoIds && spaceVideoIds.length > 0) { conditions.push(inArray(videos.id, spaceVideoIds)); } @@ -473,7 +563,7 @@ const queryCommentsSeries = async ( eq(comments.type, type), between(comments.createdAt, from, to), ]; - + if (spaceVideoIds && spaceVideoIds.length > 0) { conditions.push(inArray(videos.id, spaceVideoIds)); } @@ -502,7 +592,8 @@ const queryViewSeries = ( spaceVideoIds?: VideoId[], ) => { const pathnameFilter = buildPathnameFilter(spaceVideoIds); - const bucketFormatter = bucket === "hour" ? "%Y-%m-%dT%H:00:00Z" : "%Y-%m-%dT00:00:00Z"; + const bucketFormatter = + bucket === "hour" ? "%Y-%m-%dT%H:00:00Z" : "%Y-%m-%dT00:00:00Z"; const rawSql = ` SELECT formatDateTime(${bucket === "hour" ? "toStartOfHour" : "toStartOfDay"}(timestamp), '${bucketFormatter}') as bucket, @@ -534,9 +625,9 @@ const queryViewSeries = ( bucket === "hour" ? rawEffect : fallbackIfEmpty( - withTinybirdFallback(tinybird.querySql(aggregatedSql)), - rawEffect, - ); + withTinybirdFallback(tinybird.querySql(aggregatedSql)), + rawEffect, + ); return effect.pipe( Effect.map((rows) => diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 903a55a140..bb4a846e22 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -96,7 +96,9 @@ export const Caps = ({ }); }; - const rpc = useRpcClient(); + const rpc = useRpcClient() as { + VideoDelete: (id: Video.VideoId) => Effect.Effect; + }; const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { @@ -124,7 +126,9 @@ export const Caps = ({ } }), onMutate: (ids: Video.VideoId[]) => { - toast.loading(`Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`); + toast.loading( + `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, + ); }, onSuccess: (data: { success: number; error?: number }) => { setSelectedCaps([]); @@ -133,17 +137,23 @@ export const Caps = ({ toast.success( `Successfully deleted ${data.success} cap${ data.success === 1 ? "" : "s" - }, but failed to delete ${data.error} cap${data.error === 1 ? "" : "s"}`, + }, but failed to delete ${data.error} cap${ + data.error === 1 ? "" : "s" + }`, ); } else { toast.success( - `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s"}`, + `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }`, ); } }, onError: (error: unknown) => { const message = - error instanceof Error ? error.message : "An error occurred while deleting caps"; + error instanceof Error + ? error.message + : "An error occurred while deleting caps"; toast.error(message); }, }); diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics.tsx index e2d6629282..230eac01c7 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics.tsx @@ -3,13 +3,13 @@ import { FontAwesomeIcon, type FontAwesomeIconProps, } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; import { type ComponentProps, type ForwardedRef, forwardRef, type PropsWithChildren, } from "react"; -import Link from "next/link"; import { Tooltip } from "@/components/Tooltip"; interface CapCardAnalyticsProps { @@ -42,9 +42,7 @@ export const CapCardAnalytics = Object.assign( className="inline-flex cursor-pointer" > - - {displayCount} - + {displayCount} @@ -58,9 +56,7 @@ export const CapCardAnalytics = Object.assign( className="inline-flex cursor-pointer" > - - {totalComments} - + {totalComments} @@ -74,9 +70,7 @@ export const CapCardAnalytics = Object.assign( className="inline-flex cursor-pointer" > - - {totalReactions} - + {totalReactions} diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index aca950f225..c534ed2f5c 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -266,9 +266,9 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { data={processedVideoData} folders={foldersData} count={totalCount} - analyticsEnabled={ - Boolean(serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST) - } + analyticsEnabled={Boolean( + serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST, + )} /> ); } diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index b3fa93edb2..4f63bf6b7a 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -84,9 +84,9 @@ const FolderPage = async (props: PageProps<"/dashboard/folder/[id]">) => { {/* Display Videos */}
); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 3fcfc6688f..cfa50bcec4 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -103,9 +103,9 @@ const FolderPage = async (props: { {/* Display Videos */}
); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 10d9116caa..a6bb80a84d 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -258,9 +258,9 @@ export default async function SharedCapsPage(props: { count={totalCount} spaceData={space} spaceId={params.spaceId as Space.SpaceIdOrOrganisationId} - analyticsEnabled={ - Boolean(serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST) - } + analyticsEnabled={Boolean( + serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST, + )} spaceMembers={spaceMembersData} organizationMembers={organizationMembersData} currentUserId={user.id} @@ -364,9 +364,9 @@ export default async function SharedCapsPage(props: { hideSharedWith organizationData={organization} spaceId={params.spaceId as Space.SpaceIdOrOrganisationId} - analyticsEnabled={ - Boolean(serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST) - } + analyticsEnabled={Boolean( + serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST, + )} organizationMembers={organizationMembersData} currentUserId={user.id} folders={foldersData} diff --git a/apps/web/app/api/analytics/route.ts b/apps/web/app/api/analytics/route.ts index 949edfb8f2..178741e6b5 100644 --- a/apps/web/app/api/analytics/route.ts +++ b/apps/web/app/api/analytics/route.ts @@ -5,15 +5,16 @@ const parseRangeParam = (value: string | null) => { if (!value) return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; - const normalized = trimmed.endsWith("d") || trimmed.endsWith("D") - ? trimmed.slice(0, -1) - : trimmed; + const normalized = + trimmed.endsWith("d") || trimmed.endsWith("D") + ? trimmed.slice(0, -1) + : trimmed; const parsed = Number.parseInt(normalized, 10); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; return parsed; }; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const url = new URL(request.url); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 5d526a30fa..db7a10142a 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -44,16 +44,12 @@ export async function POST(request: NextRequest) { const osName = parser.getOS().name ?? "unknown"; const deviceType = parser.getDevice().type ?? "desktop"; - const timestamp = body.occurredAt - ? new Date(body.occurredAt) - : new Date(); + const timestamp = body.occurredAt ? new Date(body.occurredAt) : new Date(); const country = - sanitizeString(request.headers.get("x-vercel-ip-country")) || - ""; + sanitizeString(request.headers.get("x-vercel-ip-country")) || ""; const region = - sanitizeString(request.headers.get("x-vercel-ip-country-region")) || - ""; + sanitizeString(request.headers.get("x-vercel-ip-country-region")) || ""; const city = sanitizeString(request.headers.get("x-vercel-ip-city")) || ""; const hostname = @@ -62,9 +58,7 @@ export async function POST(request: NextRequest) { ""; const tenantId = - body.orgId || - body.ownerId || - (hostname ? `domain:${hostname}` : "public"); + body.orgId || body.ownerId || (hostname ? `domain:${hostname}` : "public"); const pathname = body.pathname ?? `/s/${body.videoId}`; diff --git a/apps/web/app/api/dashboard/analytics/route.ts b/apps/web/app/api/dashboard/analytics/route.ts index 2d298f1b7d..f31b4122c0 100644 --- a/apps/web/app/api/dashboard/analytics/route.ts +++ b/apps/web/app/api/dashboard/analytics/route.ts @@ -10,8 +10,7 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const user = await getCurrentUser(); - if (!user) - return Response.json({ error: "Unauthorized" }, { status: 401 }); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); const { searchParams } = new URL(request.url); const requestedOrgId = searchParams.get("orgId"); @@ -36,6 +35,9 @@ export async function GET(request: NextRequest) { return Response.json({ data }); } catch (error) { console.error("Failed to load analytics", error); - return Response.json({ error: "Failed to load analytics" }, { status: 500 }); + return Response.json( + { error: "Failed to load analytics" }, + { status: 500 }, + ); } } diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index d830277c17..442dc05199 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -80,20 +80,20 @@ const trackVideoView = (payload: { referrer: document.referrer, hostname: window.location.hostname, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - language: - typeof navigator !== "undefined" ? navigator.language : undefined, + language: typeof navigator !== "undefined" ? navigator.language : undefined, locale: typeof navigator !== "undefined" && navigator.languages?.length ? navigator.languages[0] : undefined, screen: screen ? { - width: screen.width, - height: screen.height, - colorDepth: screen.colorDepth, - } + width: screen.width, + height: screen.height, + colorDepth: screen.colorDepth, + } : undefined, - userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : undefined, occurredAt: new Date().toISOString(), }; diff --git a/apps/web/lib/Queries/Analytics.ts b/apps/web/lib/Queries/Analytics.ts index ba0a070b46..973970f167 100644 --- a/apps/web/lib/Queries/Analytics.ts +++ b/apps/web/lib/Queries/Analytics.ts @@ -52,13 +52,15 @@ export function useVideosAnalyticsQuery( ), { concurrency: "unbounded" }, ).pipe( - Effect.map((rows: Array<{ videoId: Video.VideoId; count: number }>) => { - const output: Partial> = {}; - for (const row of rows) { - output[row.videoId] = row.count; - } - return output as Record; - }), + Effect.map( + (rows: Array<{ videoId: Video.VideoId; count: number }>) => { + const output: Partial> = {}; + for (const row of rows) { + output[row.videoId] = row.count; + } + return output as Record; + }, + ), ), ); }, diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index c9105e3365..573cc1ffd7 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -13,11 +13,11 @@ import { S3Buckets, Spaces, SpacesPolicy, + Tinybird, Users, Videos, VideosPolicy, VideosRepo, - Tinybird, Workflows, } from "@cap/web-backend"; import { type HttpAuthMiddleware, Video } from "@cap/web-domain"; diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index 254072ddf6..6b176400ab 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -5,8 +5,8 @@ import { cva, cx } from "class-variance-authority"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react"; -type SelectVariant = "default" | "light" | "dark" | "gray" | "transparent" -type Size = "default" | "fit" | "sm" | "md" | "lg" +type SelectVariant = "default" | "light" | "dark" | "gray" | "transparent"; +type Size = "default" | "fit" | "sm" | "md" | "lg"; const SelectVariantContext = React.createContext("default"); @@ -31,7 +31,8 @@ const selectTriggerVariants = cva( default: "bg-gray-2 border-gray-5 text-gray-12 hover:bg-gray-3 hover:border-gray-6", dark: "bg-gray-12 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-1 border-gray-5 hover:bg-gray-11 hover:border-gray-6", - light: "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6", + light: + "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6", gray: "bg-gray-5 text-gray-12 border-gray-5 hover:bg-gray-7 hover:border-gray-6", transparent: "bg-transparent text-gray-12 border-transparent hover:bg-gray-3 hover:border-gray-6", @@ -65,7 +66,8 @@ const selectContentVariants = cva( variant: { default: "bg-gray-2 border-gray-5 text-gray-12", dark: "hover:bg-gray-11-50 bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5", - light: "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5", + light: + "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5", gray: "bg-gray-5 text-gray-12 border-gray-5", transparent: "bg-transparent hover:bg-gray-3 text-gray-12 border-transparent", @@ -89,7 +91,8 @@ const selectItemVariants = cva( variant: { default: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3", dark: "text-gray-1 hover:text-gray-12 focus:text-gray-12 hover:bg-gray-1 focus:bg-gray-1", - light: "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3", + light: + "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3", gray: "text-gray-12 hover:text-gray-12 hover:bg-gray-6 focus:bg-gray-6", transparent: "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3", @@ -129,12 +132,21 @@ function Select({ data-slot="select" {...props} > - + {options.map((option) => ( - +
{option.image && option.image} {option.label} @@ -176,13 +188,13 @@ function SelectTrigger({ variant?: SelectVariant; icon?: React.ReactNode; }) { - const iconSizeVariant = { + const iconSizeVariant = { default: "size-2.5", fit: "size-2", sm: "size-2", md: "size-3", lg: "size-3", - } + }; return ( - {icon && React.cloneElement(icon as React.ReactElement<{ className: string }>, { className: cx(iconSizeVariant[size], "text-gray-9") })} + {icon && + React.cloneElement(icon as React.ReactElement<{ className: string }>, { + className: cx(iconSizeVariant[size], "text-gray-9"), + })} {children} @@ -265,15 +280,18 @@ function SelectItem({ className={cx(selectItemVariants({ variant }), className)} {...props} > -
- {children} - {icon && React.cloneElement(icon as React.ReactElement<{ className: string }>, { className: cx("size-3", "text-gray-9") })} + {children} + {icon && + React.cloneElement( + icon as React.ReactElement<{ className: string }>, + { className: cx("size-3", "text-gray-9") }, + )}
); diff --git a/packages/web-backend/src/Tinybird/index.ts b/packages/web-backend/src/Tinybird/index.ts index d80ccc401b..67ddc8afc9 100644 --- a/packages/web-backend/src/Tinybird/index.ts +++ b/packages/web-backend/src/Tinybird/index.ts @@ -32,9 +32,7 @@ export class Tinybird extends Effect.Service()("Tinybird", { const host = env.TINYBIRD_HOST; if (!host) { - yield* Effect.die( - new Error("TINYBIRD_HOST must be set"), - ); + yield* Effect.die(new Error("TINYBIRD_HOST must be set")); } yield* Effect.logDebug("Initializing Tinybird service", { @@ -66,37 +64,38 @@ export class Tinybird extends Effect.Service()("Tinybird", { }, }); - const textBody = await response.text(); + const textBody = await response.text(); - if (!response.ok) { - const errorMessage = textBody || `Tinybird request failed (${response.status})`; - console.error("Tinybird request failed", { - path, - status: response.status, - statusText: response.statusText, - body: textBody, - }); - throw new Error(errorMessage); - } + if (!response.ok) { + const errorMessage = + textBody || `Tinybird request failed (${response.status})`; + console.error("Tinybird request failed", { + path, + status: response.status, + statusText: response.statusText, + body: textBody, + }); + throw new Error(errorMessage); + } - if (!textBody) { - console.log("Tinybird empty response", { path }); - return { data: [] } as TinybirdResponse; - } + if (!textBody) { + console.log("Tinybird empty response", { path }); + return { data: [] } as TinybirdResponse; + } - let parsed: unknown; - try { - parsed = JSON.parse(textBody); - } catch (parseError) { - console.error("Tinybird JSON parse error", { - path, - responseBody: textBody, - bodyLength: textBody.length, - bodyPreview: textBody.slice(0, 500), - parseError, - }); - throw new Error(`Tinybird returned invalid JSON for ${path}`); - } + let parsed: unknown; + try { + parsed = JSON.parse(textBody); + } catch (parseError) { + console.error("Tinybird JSON parse error", { + path, + responseBody: textBody, + bodyLength: textBody.length, + bodyPreview: textBody.slice(0, 500), + parseError, + }); + throw new Error(`Tinybird returned invalid JSON for ${path}`); + } const normalized: TinybirdResponse = Array.isArray(parsed) ? ({ data: parsed } as TinybirdResponse) @@ -235,7 +234,8 @@ export class Tinybird extends Effect.Service()("Tinybird", { }); const textBody = await response.text(); if (!response.ok) { - const errorMessage = textBody || `Tinybird request failed (${response.status})`; + const errorMessage = + textBody || `Tinybird request failed (${response.status})`; console.error("Tinybird request failed", { path, status: response.status, @@ -256,7 +256,9 @@ export class Tinybird extends Effect.Service()("Tinybird", { ? (parsed as TinybirdResponse) : ({ data: [parsed as T] } as TinybirdResponse); if ((normalizedRes as TinybirdResponse).error) { - throw new Error((normalizedRes as TinybirdResponse).error as string); + throw new Error( + (normalizedRes as TinybirdResponse).error as string, + ); } return normalizedRes; } catch { diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 2af44bc569..5bcf1ea1df 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -110,9 +110,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( VideosGetAnalytics: (videoIds) => videos.getAnalyticsBulk(videoIds).pipe( - Effect.map((results) => - results.map((result) => Unify.unify(result)), - ), + Effect.map((results) => results.map((result) => Unify.unify(result))), provideOptionalAuth, Effect.catchTag( "DatabaseError", diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index c8f101119c..ca26c15f7b 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -16,7 +16,8 @@ import { VideosRepo } from "./VideosRepo.ts"; const DEFAULT_ANALYTICS_RANGE_DAYS = 90; const escapeSqlLiteral = (value: string) => value.replace(/'/g, "''"); const formatDate = (date: Date) => date.toISOString().slice(0, 10); -const formatDateTime = (date: Date) => date.toISOString().slice(0, 19).replace("T", " "); +const formatDateTime = (date: Date) => + date.toISOString().slice(0, 19).replace("T", " "); const buildPathname = (videoId: Video.VideoId) => `/s/${videoId}`; type UploadProgressUpdateInput = Schema.Type< @@ -110,22 +111,20 @@ export class Videos extends Effect.Service()("Videos", { } const runTinybirdQuery = < - Row extends { pathname?: string | null; views?: number } + Row extends { pathname?: string | null; views?: number }, >( sql: string, ) => - tinybird - .querySql(sql) - .pipe( - Effect.catchAll((error) => { - console.error("tinybird analytics query failed", { - sql, - error, - }); - return Effect.succeed<{ data: Row[] }>({ data: [] }); - }), - Effect.map((response) => response.data ?? []), - ); + tinybird.querySql(sql).pipe( + Effect.catchAll((error) => { + console.error("tinybird analytics query failed", { + sql, + error, + }); + return Effect.succeed<{ data: Row[] }>({ data: [] }); + }), + Effect.map((response) => response.data ?? []), + ); for (const [orgKey, entries] of videosByOrg) { const pathnames = entries.map((entry) => entry.pathname); @@ -158,7 +157,9 @@ export class Videos extends Effect.Service()("Videos", { const aggregateRows = yield* runTinybirdQuery(aggregateSql); const rows = - aggregateRows.length > 0 ? aggregateRows : yield* runTinybirdQuery(rawSql); + aggregateRows.length > 0 + ? aggregateRows + : yield* runTinybirdQuery(rawSql); for (const row of rows) { const pathname = row.pathname ?? ""; @@ -178,9 +179,7 @@ export class Videos extends Effect.Service()("Videos", { return videoExits.map((exit, index) => Exit.map(exit, () => ({ count: - countsByPathname.get( - buildPathname(videoIds[index] ?? ""), - ) ?? 0, + countsByPathname.get(buildPathname(videoIds[index] ?? "")) ?? 0, })), ); }, @@ -500,8 +499,7 @@ export class Videos extends Effect.Service()("Videos", { videoId: Video.VideoId, ) { const [result] = yield* getAnalyticsBulkInternal([videoId]); - if (!result) - return { count: 0 }; + if (!result) return { count: 0 }; return yield* Exit.matchEffect(result, { onSuccess: (value) => Effect.succeed(value), onFailure: (error) => Effect.fail(error), diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index f1715d2a30..011fca9ce7 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -11,9 +11,9 @@ export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; +export { Tinybird } from "./Tinybird/index.ts"; export { Users } from "./Users/index.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; export { VideosRepo } from "./Videos/VideosRepo.ts"; -export { Tinybird } from "./Tinybird/index.ts"; export * as Workflows from "./Workflows.ts"; diff --git a/scripts/analytics/check-analytics.js b/scripts/analytics/check-analytics.js index ec425250d6..441975a485 100755 --- a/scripts/analytics/check-analytics.js +++ b/scripts/analytics/check-analytics.js @@ -3,172 +3,181 @@ import process from "node:process"; import { - PIPE_DEFINITIONS, - TABLE_DEFINITIONS, - buildSchemaLines, - createTinybirdClient, - normalizeWhitespace, + buildSchemaLines, + createTinybirdClient, + normalizeWhitespace, + PIPE_DEFINITIONS, + TABLE_DEFINITIONS, } from "./shared.js"; const normalizeColumnDef = (definition) => - normalizeWhitespace( - definition - .replace(/`/g, "") - .replace(/\bjson:\$[^\s,\)]+/gi, "") - .replace(/\bdefault\s+(?:'[^']*'|"[^"]*"|[^\s,\)]+)/gi, ""), - ).toLowerCase(); + normalizeWhitespace( + definition + .replace(/`/g, "") + .replace(/\bjson:\$[^\s,)]+/gi, "") + .replace(/\bdefault\s+(?:'[^']*'|"[^"]*"|[^\s,)]+)/gi, ""), + ).toLowerCase(); const buildExpectedSchema = (table) => - buildSchemaLines(table).map((column) => normalizeColumnDef(column)); + buildSchemaLines(table).map((column) => normalizeColumnDef(column)); const splitSchema = (schema) => { - const parts = []; - let depth = 0; - let current = ""; - for (const char of schema) { - if (char === "(") depth += 1; - if (char === ")" && depth > 0) depth -= 1; - if (char === "," && depth === 0) { - if (current.trim()) parts.push(current.trim()); - current = ""; - continue; - } - current += char; - } - if (current.trim()) parts.push(current.trim()); - return parts; + const parts = []; + let depth = 0; + let current = ""; + for (const char of schema) { + if (char === "(") depth += 1; + if (char === ")" && depth > 0) depth -= 1; + if (char === "," && depth === 0) { + if (current.trim()) parts.push(current.trim()); + current = ""; + continue; + } + current += char; + } + if (current.trim()) parts.push(current.trim()); + return parts; }; async function validateTables(client) { - const issues = []; - for (const table of TABLE_DEFINITIONS) { - try { - const datasource = await client.getDatasource(table.name); - if (!datasource) { - issues.push(`Missing data source ${table.name}`); - continue; - } - - const remoteSchema = datasource?.schema?.sql_schema; - if (!remoteSchema) { - issues.push(`Data source ${table.name} does not expose schema metadata`); - continue; - } - - const remoteColumns = splitSchema(remoteSchema).map(normalizeColumnDef); - const expectedColumns = buildExpectedSchema(table); - - let hasIssue = false; - - if (remoteColumns.length !== expectedColumns.length) { - issues.push( - `Schema mismatch for ${table.name}. Expected ${expectedColumns.length} columns, got ${remoteColumns.length}.`, - ); - hasIssue = true; - } - - const missingColumns = expectedColumns.filter((column, index) => remoteColumns[index] !== column); - if (missingColumns.length > 0) { - issues.push( - `Schema mismatch for ${table.name}. Expected columns (${expectedColumns.join(", ")}), got (${remoteColumns.join(", ")}).`, - ); - hasIssue = true; - } - - if (table.engine) { - const actualEngine = typeof datasource.engine === "string" - ? datasource.engine - : datasource.engine?.engine; - if (actualEngine && actualEngine !== table.engine) { - issues.push( - `Data source ${table.name} has engine ${actualEngine}, expected ${table.engine}.`, - ); - hasIssue = true; - } - } - - if (!hasIssue) { - console.log(`✔ Data source ${table.name} schema matches`); - } - } catch (error) { - issues.push( - `Failed to inspect ${table.name}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return issues; + const issues = []; + for (const table of TABLE_DEFINITIONS) { + try { + const datasource = await client.getDatasource(table.name); + if (!datasource) { + issues.push(`Missing data source ${table.name}`); + continue; + } + + const remoteSchema = datasource?.schema?.sql_schema; + if (!remoteSchema) { + issues.push( + `Data source ${table.name} does not expose schema metadata`, + ); + continue; + } + + const remoteColumns = splitSchema(remoteSchema).map(normalizeColumnDef); + const expectedColumns = buildExpectedSchema(table); + + let hasIssue = false; + + if (remoteColumns.length !== expectedColumns.length) { + issues.push( + `Schema mismatch for ${table.name}. Expected ${expectedColumns.length} columns, got ${remoteColumns.length}.`, + ); + hasIssue = true; + } + + const missingColumns = expectedColumns.filter( + (column, index) => remoteColumns[index] !== column, + ); + if (missingColumns.length > 0) { + issues.push( + `Schema mismatch for ${table.name}. Expected columns (${expectedColumns.join(", ")}), got (${remoteColumns.join(", ")}).`, + ); + hasIssue = true; + } + + if (table.engine) { + const actualEngine = + typeof datasource.engine === "string" + ? datasource.engine + : datasource.engine?.engine; + if (actualEngine && actualEngine !== table.engine) { + issues.push( + `Data source ${table.name} has engine ${actualEngine}, expected ${table.engine}.`, + ); + hasIssue = true; + } + } + + if (!hasIssue) { + console.log(`✔ Data source ${table.name} schema matches`); + } + } catch (error) { + issues.push( + `Failed to inspect ${table.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return issues; } async function validatePipes(client) { - const issues = []; - for (const pipeDef of PIPE_DEFINITIONS) { - try { - const pipe = await client.getPipe(pipeDef.name); - if (!pipe) { - issues.push(`Missing pipe ${pipeDef.name}`); - continue; - } - - if ((pipe.type || "").toLowerCase() !== "materialized") { - issues.push(`Pipe ${pipeDef.name} is not materialized (type=${pipe.type ?? "unknown"}).`); - continue; - } - - const materializedNode = pipe.nodes?.find((node) => - (node.type || "").toLowerCase() === "materialized" || - (node.node_type || "").toLowerCase() === "materialized" || - node.materialized, - ); - - const targetDatasource = - materializedNode?.tags?.materializing_target_datasource || - materializedNode?.materialized?.datasource || - materializedNode?.datasource || - materializedNode?.params?.datasource; - - if (targetDatasource !== pipeDef.targetDatasource) { - issues.push( - `Pipe ${pipeDef.name} does not target ${pipeDef.targetDatasource} (found ${targetDatasource ?? "unknown"}).`, - ); - continue; - } - - console.log(`✔ Pipe ${pipeDef.name} feeds ${pipeDef.targetDatasource}`); - } catch (error) { - const errorMessage = error instanceof Error - ? error.message - : String(error); - const errorDetails = error instanceof Error && error.cause - ? ` (cause: ${error.cause})` - : ""; - issues.push( - `Failed to inspect pipe ${pipeDef.name}: ${errorMessage}${errorDetails}`, - ); - } - } - return issues; + const issues = []; + for (const pipeDef of PIPE_DEFINITIONS) { + try { + const pipe = await client.getPipe(pipeDef.name); + if (!pipe) { + issues.push(`Missing pipe ${pipeDef.name}`); + continue; + } + + if ((pipe.type || "").toLowerCase() !== "materialized") { + issues.push( + `Pipe ${pipeDef.name} is not materialized (type=${pipe.type ?? "unknown"}).`, + ); + continue; + } + + const materializedNode = pipe.nodes?.find( + (node) => + (node.type || "").toLowerCase() === "materialized" || + (node.node_type || "").toLowerCase() === "materialized" || + node.materialized, + ); + + const targetDatasource = + materializedNode?.tags?.materializing_target_datasource || + materializedNode?.materialized?.datasource || + materializedNode?.datasource || + materializedNode?.params?.datasource; + + if (targetDatasource !== pipeDef.targetDatasource) { + issues.push( + `Pipe ${pipeDef.name} does not target ${pipeDef.targetDatasource} (found ${targetDatasource ?? "unknown"}).`, + ); + continue; + } + + console.log(`✔ Pipe ${pipeDef.name} feeds ${pipeDef.targetDatasource}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorDetails = + error instanceof Error && error.cause ? ` (cause: ${error.cause})` : ""; + issues.push( + `Failed to inspect pipe ${pipeDef.name}: ${errorMessage}${errorDetails}`, + ); + } + } + return issues; } async function main() { - try { - const client = createTinybirdClient(); - const tableIssues = await validateTables(client); - const pipeIssues = await validatePipes(client); - const issues = [...tableIssues, ...pipeIssues]; - - if (issues.length > 0) { - console.error("❌ Tinybird analytics validation failed:"); - for (const issue of issues) { - console.error(` - ${issue}`); - } - process.exit(1); - } - - console.log("✅ Tinybird analytics setup is valid."); - } catch (error) { - console.error("❌ Tinybird analytics validation crashed:", error instanceof Error ? error.message : error); - process.exit(1); - } + try { + const client = createTinybirdClient(); + const tableIssues = await validateTables(client); + const pipeIssues = await validatePipes(client); + const issues = [...tableIssues, ...pipeIssues]; + + if (issues.length > 0) { + console.error("❌ Tinybird analytics validation failed:"); + for (const issue of issues) { + console.error(` - ${issue}`); + } + process.exit(1); + } + + console.log("✅ Tinybird analytics setup is valid."); + } catch (error) { + console.error( + "❌ Tinybird analytics validation crashed:", + error instanceof Error ? error.message : error, + ); + process.exit(1); + } } main(); diff --git a/scripts/analytics/delete-all-data.js b/scripts/analytics/delete-all-data.js index 27160f7bc1..f0328fa910 100644 --- a/scripts/analytics/delete-all-data.js +++ b/scripts/analytics/delete-all-data.js @@ -1,135 +1,156 @@ #!/usr/bin/env node +import { intro, isCancel, log, outro, text } from "@clack/prompts"; import { createTinybirdClient, resolveTinybirdAuth } from "./shared.js"; -import { intro, outro, text, isCancel, log } from "@clack/prompts"; async function getAllDatasources(client) { - try { - const payload = await client.request(`/v0/datasources`); - const list = Array.isArray(payload?.datasources) - ? payload.datasources - : Array.isArray(payload?.data) - ? payload.data - : Array.isArray(payload) - ? payload - : []; - const names = list - .map((ds) => (typeof ds === "string" ? ds : ds?.name)) - .filter(Boolean); - const unique = Array.from(new Set(names)); - if (unique.length > 0) return unique; - } catch { - // fall through to fallback list - } - return ["analytics_events", "analytics_pages_mv", "analytics_sessions_mv"]; + try { + const payload = await client.request(`/v0/datasources`); + const list = Array.isArray(payload?.datasources) + ? payload.datasources + : Array.isArray(payload?.data) + ? payload.data + : Array.isArray(payload) + ? payload + : []; + const names = list + .map((ds) => (typeof ds === "string" ? ds : ds?.name)) + .filter(Boolean); + const unique = Array.from(new Set(names)); + if (unique.length > 0) return unique; + } catch { + // fall through to fallback list + } + return ["analytics_events", "analytics_pages_mv", "analytics_sessions_mv"]; } -async function doubleConfirm({ workspaceName, workspaceId, host, datasources }) { - const workspaceLabel = - workspaceName || workspaceId || new URL(host).host || "unknown-workspace"; +async function doubleConfirm({ + workspaceName, + workspaceId, + host, + datasources, +}) { + const workspaceLabel = + workspaceName || workspaceId || new URL(host).host || "unknown-workspace"; - intro("Delete ALL analytics data from Tinybird"); - log.warn( - `This will TRUNCATE all datasources in the Tinybird workspace:\n` + - `- Workspace: ${workspaceLabel}\n` + - `- Host: ${host}\n` + - `- Datasources (${datasources.length}): ${datasources.join(", ")}` - ); + intro("Delete ALL analytics data from Tinybird"); + log.warn( + `This will TRUNCATE all datasources in the Tinybird workspace:\n` + + `- Workspace: ${workspaceLabel}\n` + + `- Host: ${host}\n` + + `- Datasources (${datasources.length}): ${datasources.join(", ")}`, + ); - const first = await text({ - message: `Type the workspace name or ID to confirm (${workspaceLabel})`, - placeholder: workspaceLabel, - defaultValue: "", - validate: (value) => { - if (!value) return "Required"; - if (value !== workspaceName && value !== workspaceId && value !== workspaceLabel) { - return "Value does not match the workspace name or ID"; - } - }, - }); - if (isCancel(first)) { - outro("Cancelled."); - process.exit(0); - } + const first = await text({ + message: `Type the workspace name or ID to confirm (${workspaceLabel})`, + placeholder: workspaceLabel, + defaultValue: "", + validate: (value) => { + if (!value) return "Required"; + if ( + value !== workspaceName && + value !== workspaceId && + value !== workspaceLabel + ) { + return "Value does not match the workspace name or ID"; + } + }, + }); + if (isCancel(first)) { + outro("Cancelled."); + process.exit(0); + } - const second = await text({ - message: 'Final confirmation: type "DELETE ALL" to proceed', - placeholder: "DELETE ALL", - defaultValue: "", - validate: (value) => (value === "DELETE ALL" ? undefined : 'You must type "DELETE ALL"'), - }); - if (isCancel(second)) { - outro("Cancelled."); - process.exit(0); - } + const second = await text({ + message: 'Final confirmation: type "DELETE ALL" to proceed', + placeholder: "DELETE ALL", + defaultValue: "", + validate: (value) => + value === "DELETE ALL" ? undefined : 'You must type "DELETE ALL"', + }); + if (isCancel(second)) { + outro("Cancelled."); + process.exit(0); + } } async function deleteAllData() { - const auth = resolveTinybirdAuth(); - const client = createTinybirdClient(auth); + const auth = resolveTinybirdAuth(); + const client = createTinybirdClient(auth); - const datasources = await getAllDatasources(client); + const datasources = await getAllDatasources(client); - await doubleConfirm({ - workspaceName: client.workspaceName, - workspaceId: client.workspaceId, - host: client.host, - datasources, - }); + await doubleConfirm({ + workspaceName: client.workspaceName, + workspaceId: client.workspaceId, + host: client.host, + datasources, + }); - console.log("\nDeleting all data from Tinybird datasources...\n"); + console.log("\nDeleting all data from Tinybird datasources...\n"); - const successes = []; - const failures = []; - for (const datasource of datasources) { - try { - console.log(`Deleting data from ${datasource}...`); - let ok = false; - try { - await client.request(`/v0/datasources/${encodeURIComponent(datasource)}/truncate`, { - method: "POST", - }); - ok = true; - } catch (e1) { - try { - await client.request(`/v0/datasources/${encodeURIComponent(datasource)}/data`, { - method: "DELETE", - }); - ok = true; - } catch (e2) { - await client.request(`/v0/datasources/${encodeURIComponent(datasource)}`, { - method: "DELETE", - }); - ok = true; - } - } - if (ok) { - console.log(`✅ Deleted data from ${datasource}`); - successes.push(datasource); - } else { - failures.push(datasource); - } - } catch (error) { - console.error(`❌ Failed to delete data from ${datasource}:`, error.message); - if (error.payload) { - console.error(" Details:", JSON.stringify(error.payload, null, 2)); - } - failures.push(datasource); - } - } + const successes = []; + const failures = []; + for (const datasource of datasources) { + try { + console.log(`Deleting data from ${datasource}...`); + let ok = false; + try { + await client.request( + `/v0/datasources/${encodeURIComponent(datasource)}/truncate`, + { + method: "POST", + }, + ); + ok = true; + } catch (e1) { + try { + await client.request( + `/v0/datasources/${encodeURIComponent(datasource)}/data`, + { + method: "DELETE", + }, + ); + ok = true; + } catch (e2) { + await client.request( + `/v0/datasources/${encodeURIComponent(datasource)}`, + { + method: "DELETE", + }, + ); + ok = true; + } + } + if (ok) { + console.log(`✅ Deleted data from ${datasource}`); + successes.push(datasource); + } else { + failures.push(datasource); + } + } catch (error) { + console.error( + `❌ Failed to delete data from ${datasource}:`, + error.message, + ); + if (error.payload) { + console.error(" Details:", JSON.stringify(error.payload, null, 2)); + } + failures.push(datasource); + } + } - if (failures.length === 0) { - console.log("\n✅ Finished deleting all data"); - } else { - console.log( - `\n⚠️ Finished with errors. Deleted: ${successes.length}. Failed: ${failures.length} -> ${failures.join(", ")}`, - ); - process.exitCode = 1; - } + if (failures.length === 0) { + console.log("\n✅ Finished deleting all data"); + } else { + console.log( + `\n⚠️ Finished with errors. Deleted: ${successes.length}. Failed: ${failures.length} -> ${failures.join(", ")}`, + ); + process.exitCode = 1; + } } deleteAllData().catch((error) => { - console.error("❌ Failed to delete data:", error.message); - process.exit(1); + console.error("❌ Failed to delete data:", error.message); + process.exit(1); }); - diff --git a/scripts/analytics/migrate-dub-to-tinybird.js b/scripts/analytics/migrate-dub-to-tinybird.js index 220830b8e3..5e412292e1 100644 --- a/scripts/analytics/migrate-dub-to-tinybird.js +++ b/scripts/analytics/migrate-dub-to-tinybird.js @@ -11,7 +11,8 @@ const DUB_API_URL = "https://api.dub.co"; const DEFAULT_DOMAIN = "cap.link"; const DEFAULT_TIMEZONE = "UTC"; const DEFAULT_INTERVAL = "30d"; -const DEFAULT_HOST = process.env.TINYBIRD_HOST?.trim() || "https://api.tinybird.co"; +const DEFAULT_HOST = + process.env.TINYBIRD_HOST?.trim() || "https://api.tinybird.co"; const TB_DATASOURCE = "analytics_events"; const MAX_CITY_COUNT = 25; const INGEST_CHUNK_SIZE = 5000; @@ -21,5407 +22,5718 @@ const DEFAULT_INGEST_CONCURRENCY = Number(process.env.INGEST_CONCURRENCY || 4); const DEFAULT_INGEST_RATE_LIMIT = Number(process.env.INGEST_RATE_LIMIT || 10); export const REGIONS = { - "AF-BDS": "Badakhshān", - "AF-BDG": "Bādghīs", - "AF-BGL": "Baghlān", - "AF-BAL": "Balkh", - "AF-BAM": "Bāmyān", - "AF-DAY": "Dāykundī", - "AF-FRA": "Farāh", - "AF-FYB": "Fāryāb", - "AF-GHA": "Ghaznī", - "AF-GHO": "Ghōr", - "AF-HEL": "Helmand", - "AF-HER": "Herāt", - "AF-JOW": "Jowzjān", - "AF-KAB": "Kābul", - "AF-KAN": "Kandahār", - "AF-KAP": "Kāpīsā", - "AF-KHO": "Khōst", - "AF-KNR": "Kunaṟ", - "AF-KDZ": "Kunduz", - "AF-LAG": "Laghmān", - "AF-LOG": "Lōgar", - "AF-NAN": "Nangarhār", - "AF-NIM": "Nīmrōz", - "AF-NUR": "Nūristān", - "AF-PKA": "Paktīkā", - "AF-PIA": "Paktiyā", - "AF-PAN": "Panjshayr", - "AF-PAR": "Parwān", - "AF-SAM": "Samangān", - "AF-SAR": "Sar-e Pul", - "AF-TAK": "Takhār", - "AF-URU": "Uruzgān", - "AF-WAR": "Wardak", - "AF-ZAB": "Zābul", - "AL-01": "Berat", - "AL-09": "Dibër", - "AL-02": "Durrës", - "AL-03": "Elbasan", - "AL-04": "Fier", - "AL-05": "Gjirokastër", - "AL-06": "Korçë", - "AL-07": "Kukës", - "AL-08": "Lezhë", - "AL-10": "Shkodër", - "AL-11": "Tiranë", - "AL-12": "Vlorë", - "DZ-01": "Adrar", - "DZ-44": "Aïn Defla", - "DZ-46": "Aïn Témouchent", - "DZ-16": "Alger", - "DZ-23": "Annaba", - "DZ-05": "Batna", - "DZ-08": "Béchar", - "DZ-06": "Béjaïa", - "DZ-52": "Béni Abbès", - "DZ-07": "Biskra", - "DZ-09": "Blida", - "DZ-50": "Bordj Badji Mokhtar", - "DZ-34": "Bordj Bou Arréridj", - "DZ-10": "Bouira", - "DZ-35": "Boumerdès", - "DZ-02": "Chlef", - "DZ-25": "Constantine", - "DZ-56": "Djanet", - "DZ-17": "Djelfa", - "DZ-32": "El Bayadh", - "DZ-57": "El Meghaier", - "DZ-58": "El Meniaa", - "DZ-39": "El Oued", - "DZ-36": "El Tarf", - "DZ-47": "Ghardaïa", - "DZ-24": "Guelma", - "DZ-33": "Illizi", - "DZ-54": "In Guezzam", - "DZ-53": "In Salah", - "DZ-18": "Jijel", - "DZ-40": "Khenchela", - "DZ-03": "Laghouat", - "DZ-28": "M'sila", - "DZ-29": "Mascara", - "DZ-26": "Médéa", - "DZ-43": "Mila", - "DZ-27": "Mostaganem", - "DZ-45": "Naama", - "DZ-31": "Oran", - "DZ-30": "Ouargla", - "DZ-51": "Ouled Djellal", - "DZ-04": "Oum el Bouaghi", - "DZ-48": "Relizane", - "DZ-20": "Saïda", - "DZ-19": "Sétif", - "DZ-22": "Sidi Bel Abbès", - "DZ-21": "Skikda", - "DZ-41": "Souk Ahras", - "DZ-11": "Tamanrasset", - "DZ-12": "Tébessa", - "DZ-14": "Tiaret", - "DZ-49": "Timimoun", - "DZ-37": "Tindouf", - "DZ-42": "Tipaza", - "DZ-38": "Tissemsilt", - "DZ-15": "Tizi Ouzou", - "DZ-13": "Tlemcen", - "DZ-55": "Touggourt", - "AD-07": "Andorra la Vella", - "AD-02": "Canillo", - "AD-03": "Encamp", - "AD-08": "Escaldes-Engordany", - "AD-04": "La Massana", - "AD-05": "Ordino", - "AD-06": "Sant Julià de Lòria", - "AO-BGO": "Bengo", - "AO-BGU": "Benguela", - "AO-BIE": "Bié", - "AO-CAB": "Cabinda", - "AO-CCU": "Cuando Cubango", - "AO-CNO": "Cuanza-Norte", - "AO-CUS": "Cuanza-Sul", - "AO-CNN": "Cunene", - "AO-HUA": "Huambo", - "AO-HUI": "Huíla", - "AO-LUA": "Luanda", - "AO-LNO": "Lunda-Norte", - "AO-LSU": "Lunda-Sul", - "AO-MAL": "Malange", - "AO-MOX": "Moxico", - "AO-NAM": "Namibe", - "AO-UIG": "Uíge", - "AO-ZAI": "Zaire", - "AG-03": "Saint George", - "AG-04": "Saint John", - "AG-05": "Saint Mary", - "AG-06": "Saint Paul", - "AG-07": "Saint Peter", - "AG-08": "Saint Philip", - "AG-10": "Barbuda", - "AG-11": "Redonda", - "AR-B": "Buenos Aires", - "AR-K": "Catamarca", - "AR-H": "Chaco", - "AR-U": "Chubut", - "AR-C": "Ciudad Autónoma de Buenos Aires", - "AR-X": "Córdoba", - "AR-W": "Corrientes", - "AR-E": "Entre Ríos", - "AR-P": "Formosa", - "AR-Y": "Jujuy", - "AR-L": "La Pampa", - "AR-F": "La Rioja", - "AR-M": "Mendoza", - "AR-N": "Misiones", - "AR-Q": "Neuquén", - "AR-R": "Río Negro", - "AR-A": "Salta", - "AR-J": "San Juan", - "AR-D": "San Luis", - "AR-Z": "Santa Cruz", - "AR-S": "Santa Fe", - "AR-G": "Santiago del Estero", - "AR-V": "Tierra del Fuego", - "AR-T": "Tucumán", - "AM-AG": "Aragac̣otn", - "AM-AR": "Ararat", - "AM-AV": "Armavir", - "AM-ER": "Erevan", - "AM-GR": "Geġark'unik'", - "AM-KT": "Kotayk'", - "AM-LO": "Loṙi", - "AM-SH": "Širak", - "AM-SU": "Syunik'", - "AM-TV": "Tavuš", - "AM-VD": "Vayoć Jor", - "AU-NSW": "New South Wales", - "AU-QLD": "Queensland", - "AU-SA": "South Australia", - "AU-TAS": "Tasmania", - "AU-VIC": "Victoria", - "AU-WA": "Western Australia", - "AU-ACT": "Australian Capital Territory", - "AU-NT": "Northern Territory", - "AT-1": "Burgenland", - "AT-2": "Kärnten", - "AT-3": "Niederösterreich", - "AT-4": "Oberösterreich", - "AT-5": "Salzburg", - "AT-6": "Steiermark", - "AT-7": "Tirol", - "AT-8": "Vorarlberg", - "AT-9": "Wien", - "AZ-NX": "Naxçıvan", - "BS-AK": "Acklins", - "BS-BY": "Berry Islands", - "BS-BI": "Bimini", - "BS-BP": "Black Point", - "BS-CI": "Cat Island", - "BS-CO": "Central Abaco", - "BS-CS": "Central Andros", - "BS-CE": "Central Eleuthera", - "BS-FP": "City of Freeport", - "BS-CK": "Crooked Island and Long Cay", - "BS-EG": "East Grand Bahama", - "BS-EX": "Exuma", - "BS-GC": "Grand Cay", - "BS-HI": "Harbour Island", - "BS-HT": "Hope Town", - "BS-IN": "Inagua", - "BS-LI": "Long Island", - "BS-MC": "Mangrove Cay", - "BS-MG": "Mayaguana", - "BS-MI": "Moore's Island", - "BS-NP": "New Providence", - "BS-NO": "North Abaco", - "BS-NS": "North Andros", - "BS-NE": "North Eleuthera", - "BS-RI": "Ragged Island", - "BS-RC": "Rum Cay", - "BS-SS": "San Salvador", - "BS-SO": "South Abaco", - "BS-SA": "South Andros", - "BS-SE": "South Eleuthera", - "BS-SW": "Spanish Wells", - "BS-WG": "West Grand Bahama", - "BH-13": "Al ‘Āşimah", - "BH-14": "Al Janūbīyah", - "BH-15": "Al Muḩarraq", - "BH-17": "Ash Shamālīyah", - "BD-A": "Barishal", - "BD-B": "Chattogram", - "BD-C": "Dhaka", - "BD-D": "Khulna", - "BD-H": "Mymensingh", - "BD-E": "Rajshahi", - "BD-F": "Rangpur", - "BD-G": "Sylhet", - "BB-01": "Christ Church", - "BB-02": "Saint Andrew", - "BB-03": "Saint George", - "BB-04": "Saint James", - "BB-05": "Saint John", - "BB-06": "Saint Joseph", - "BB-07": "Saint Lucy", - "BB-08": "Saint Michael", - "BB-09": "Saint Peter", - "BB-10": "Saint Philip", - "BB-11": "Saint Thomas", - "BY-BR": "Brestskaya voblasts'", - "BY-HO": "Homyel'skaya voblasts'", - "BY-HM": "Horad Minsk", - "BY-HR": "Hrodzyenskaya voblasts'", - "BY-MA": "Mahilyowskaya voblasts'", - "BY-MI": "Minskaya voblasts'", - "BY-VI": "Vitsyebskaya voblasts'", - "BE-BRU": "Brussels Hoofdstedelijk Gewest", - "BE-VLG": "Vlaams Gewest", - "BE-WAL": "Waals Gewest[note 2]", - "BZ-BZ": "Belize", - "BZ-CY": "Cayo", - "BZ-CZL": "Corozal", - "BZ-OW": "Orange Walk", - "BZ-SC": "Stann Creek", - "BZ-TOL": "Toledo", - "BJ-AL": "Alibori", - "BJ-AK": "Atacora", - "BJ-AQ": "Atlantique", - "BJ-BO": "Borgou", - "BJ-CO": "Collines", - "BJ-KO": "Couffo", - "BJ-DO": "Donga", - "BJ-LI": "Littoral", - "BJ-MO": "Mono", - "BJ-OU": "Ouémé", - "BJ-PL": "Plateau", - "BJ-ZO": "Zou", - "BT-33": "Bumthang", - "BT-12": "Chhukha", - "BT-22": "Dagana", - "BT-GA": "Gasa", - "BT-13": "Haa", - "BT-44": "Lhuentse", - "BT-42": "Monggar", - "BT-11": "Paro", - "BT-43": "Pema Gatshel", - "BT-23": "Punakha", - "BT-45": "Samdrup Jongkhar", - "BT-14": "Samtse", - "BT-31": "Sarpang", - "BT-15": "Thimphu", - "BT-41": "Trashigang", - "BT-TY": "Trashi Yangtse", - "BT-32": "Trongsa", - "BT-21": "Tsirang", - "BT-24": "Wangdue Phodrang", - "BT-34": "Zhemgang", - "BO-C": "Cochabamba", - "BO-H": "Chuquisaca", - "BO-B": "El Beni", - "BO-L": "La Paz", - "BO-O": "Oruro", - "BO-N": "Pando", - "BO-P": "Potosí", - "BO-S": "Santa Cruz", - "BO-T": "Tarija", - "BA-BIH": "Federacija Bosne i Hercegovine", - "BA-SRP": "Republika Srpska", - "BA-BRC": "Brčko distrikt", - "BW-CE": "Central", - "BW-CH": "Chobe", - "BW-FR": "Francistown", - "BW-GA": "Gaborone", - "BW-GH": "Ghanzi", - "BW-JW": "Jwaneng", - "BW-KG": "Kgalagadi", - "BW-KL": "Kgatleng", - "BW-KW": "Kweneng", - "BW-LO": "Lobatse", - "BW-NE": "North East", - "BW-NW": "North West", - "BW-SP": "Selibe Phikwe", - "BW-SE": "South East", - "BW-SO": "Southern", - "BW-ST": "Sowa Town", - "BR-AC": "Acre", - "BR-AL": "Alagoas", - "BR-AP": "Amapá", - "BR-AM": "Amazonas", - "BR-BA": "Bahia", - "BR-CE": "Ceará", - "BR-DF": "Distrito Federal", - "BR-ES": "Espírito Santo", - "BR-GO": "Goiás", - "BR-MA": "Maranhão", - "BR-MT": "Mato Grosso", - "BR-MS": "Mato Grosso do Sul", - "BR-MG": "Minas Gerais", - "BR-PA": "Pará", - "BR-PB": "Paraíba", - "BR-PR": "Paraná", - "BR-PE": "Pernambuco", - "BR-PI": "Piauí", - "BR-RJ": "Rio de Janeiro", - "BR-RN": "Rio Grande do Norte", - "BR-RS": "Rio Grande do Sul", - "BR-RO": "Rondônia", - "BR-RR": "Roraima", - "BR-SC": "Santa Catarina", - "BR-SP": "São Paulo", - "BR-SE": "Sergipe", - "BR-TO": "Tocantins", - "BN-BE": "Belait", - "BN-BM": "Brunei-Muara", - "BN-TE": "Temburong", - "BN-TU": "Tutong", - "BG-01": "Blagoevgrad", - "BG-02": "Burgas", - "BG-08": "Dobrich", - "BG-07": "Gabrovo", - "BG-26": "Haskovo", - "BG-09": "Kardzhali", - "BG-10": "Kyustendil", - "BG-11": "Lovech", - "BG-12": "Montana", - "BG-13": "Pazardzhik", - "BG-14": "Pernik", - "BG-15": "Pleven", - "BG-16": "Plovdiv", - "BG-17": "Razgrad", - "BG-18": "Ruse", - "BG-27": "Shumen", - "BG-19": "Silistra", - "BG-20": "Sliven", - "BG-21": "Smolyan", - "BG-23": "Sofia", - "BG-22": "Sofia (stolitsa)", - "BG-24": "Stara Zagora", - "BG-25": "Targovishte", - "BG-03": "Varna", - "BG-04": "Veliko Tarnovo", - "BG-05": "Vidin", - "BG-06": "Vratsa", - "BG-28": "Yambol", - "BF-01": "Boucle du Mouhoun", - "BF-02": "Cascades", - "BF-03": "Centre", - "BF-04": "Centre-Est", - "BF-05": "Centre-Nord", - "BF-06": "Centre-Ouest", - "BF-07": "Centre-Sud", - "BF-08": "Est", - "BF-09": "Hauts-Bassins", - "BF-10": "Nord", - "BF-11": "Plateau-Central", - "BF-12": "Sahel", - "BF-13": "Sud-Ouest", - "BI-BB": "Bubanza", - "BI-BM": "Bujumbura Mairie", - "BI-BL": "Bujumbura Rural", - "BI-BR": "Bururi", - "BI-CA": "Cankuzo", - "BI-CI": "Cibitoke", - "BI-GI": "Gitega", - "BI-KR": "Karuzi", - "BI-KY": "Kayanza", - "BI-KI": "Kirundo", - "BI-MA": "Makamba", - "BI-MU": "Muramvya", - "BI-MY": "Muyinga", - "BI-MW": "Mwaro", - "BI-NG": "Ngozi", - "BI-RM": "Rumonge", - "BI-RT": "Rutana", - "BI-RY": "Ruyigi", - "KH-2": "Baat Dambang", - "KH-1": "Banteay Mean Choăy", - "KH-23": "Kaeb", - "KH-3": "Kampong Chaam", - "KH-4": "Kampong Chhnang", - "KH-5": "Kampong Spueu", - "KH-6": "Kampong Thum", - "KH-7": "Kampot", - "KH-8": "Kandaal", - "KH-9": "Kaoh Kong", - "KH-10": "Kracheh", - "KH-11": "Mondol Kiri", - "KH-22": "Otdar Mean Chey", - "KH-24": "Pailin", - "KH-12": "Phnom Penh", - "KH-15": "Pousaat", - "KH-18": "Preah Sihanouk", - "KH-13": "Preah Vihear", - "KH-14": "Prey Veaeng", - "KH-16": "Rotanak Kiri", - "KH-17": "Siem Reab", - "KH-19": "Stueng Traeng", - "KH-20": "Svaay Rieng", - "KH-21": "Taakaev", - "KH-25": "Tbong Khmum", - "CM-AD": "Adamaoua", - "CM-CE": "Centre", - "CM-ES": "East", - "CM-EN": "Far North", - "CM-LT": "Littoral", - "CM-NO": "North", - "CM-NW": "North-West", - "CM-SU": "South", - "CM-SW": "South-West", - "CM-OU": "West", - "CA-AB": "Alberta", - "CA-BC": "British Columbia", - "CA-MB": "Manitoba", - "CA-NB": "New Brunswick", - "CA-NL": "Newfoundland and Labrador", - "CA-NT": "Northwest Territories", - "CA-NS": "Nova Scotia", - "CA-NU": "Nunavut", - "CA-ON": "Ontario", - "CA-PE": "Prince Edward Island", - "CA-QC": "Quebec", - "CA-SK": "Saskatchewan", - "CA-YT": "Yukon", - "CV-B": "Ilhas de Barlavento", - "CV-S": "Ilhas de Sotavento", - "CF-BB": "Bamingui-Bangoran", - "CF-BGF": "Bangui", - "CF-BK": "Basse-Kotto", - "CF-KB": "Gribingui", - "CF-HM": "Haut-Mbomou", - "CF-HK": "Haute-Kotto", - "CF-HS": "Haute-Sangha / Mambéré-Kadéï", - "CF-KG": "Kémo-Gribingui", - "CF-LB": "Lobaye", - "CF-MB": "Mbomou", - "CF-NM": "Nana-Mambéré", - "CF-MP": "Ombella-Mpoko", - "CF-UK": "Ouaka", - "CF-AC": "Ouham", - "CF-OP": "Ouham-Pendé", - "CF-SE": "Sangha", - "CF-VK": "Vakaga", - "TD-BG": "Baḩr al Ghazāl", - "TD-BA": "Al Baţḩā’", - "TD-BO": "Būrkū", - "TD-CB": "Shārī Bāqirmī", - "TD-EE": "Inīdī ash Sharqī", - "TD-EO": "Inīdī al Gharbī", - "TD-GR": "Qīrā", - "TD-HL": "Ḩajjar Lamīs", - "TD-KA": "Kānim", - "TD-LC": "Al Buḩayrah", - "TD-LO": "Lūghūn al Gharbī", - "TD-LR": "Lūghūn ash Sharqī", - "TD-MA": "Māndūl", - "TD-ME": "Māyū Kībbī ash Sharqī", - "TD-MO": "Māyū Kībbī al Gharbī", - "TD-MC": "Shārī al Awsaţ", - "TD-OD": "Waddāy", - "TD-SA": "Salāmāt", - "TD-SI": "Sīlā", - "TD-TA": "Tānjīlī", - "TD-TI": "Tibastī", - "TD-ND": "Madīnat Injamīnā", - "TD-WF": "Wādī Fīrā’", - "CL-AI": "Aisén del General Carlos Ibañez del Campo", - "CL-AN": "Antofagasta", - "CL-AP": "Arica y Parinacota", - "CL-AT": "Atacama", - "CL-BI": "Biobío", - "CL-CO": "Coquimbo", - "CL-AR": "La Araucanía", - "CL-LI": "Libertador General Bernardo O'Higgins", - "CL-LL": "Los Lagos", - "CL-LR": "Los Ríos", - "CL-MA": "Magallanes", - "CL-ML": "Maule", - "CL-NB": "Ñuble", - "CL-RM": "Región Metropolitana de Santiago", - "CL-TA": "Tarapacá", - "CL-VS": "Valparaíso", - "CN-AH": "Anhui Sheng", - "CN-BJ": "Beijing Shi", - "CN-CQ": "Chongqing Shi", - "CN-FJ": "Fujian Sheng", - "CN-GS": "Gansu Sheng", - "CN-GD": "Guangdong Sheng", - "CN-GX": "Guangxi Zhuangzu Zizhiqu", - "CN-GZ": "Guizhou Sheng", - "CN-HI": "Hainan Sheng", - "CN-HE": "Hebei Sheng", - "CN-HL": "Heilongjiang Sheng", - "CN-HA": "Henan Sheng", - "CN-HK": "Hong Kong SARen", - "CN-HB": "Hubei Sheng", - "CN-HN": "Hunan Sheng", - "CN-JS": "Jiangsu Sheng", - "CN-JX": "Jiangxi Sheng", - "CN-JL": "Jilin Sheng", - "CN-LN": "Liaoning Sheng", - "CN-MO": "Macao SARpt", - "CN-NM": "Nei Mongol Zizhiqu", - "CN-NX": "Ningxia Huizu Zizhiqu", - "CN-QH": "Qinghai Sheng", - "CN-SN": "Shaanxi Sheng", - "CN-SD": "Shandong Sheng", - "CN-SH": "Shanghai Shi", - "CN-SX": "Shanxi Sheng", - "CN-SC": "Sichuan Sheng", - "CN-TW": "Taiwan Sheng", - "CN-TJ": "Tianjin Shi", - "CN-XJ": "Xinjiang Uygur Zizhiqu", - "CN-XZ": "Xizang Zizhiqu", - "CN-YN": "Yunnan Sheng", - "CN-ZJ": "Zhejiang Sheng", - "CO-AMA": "Amazonas", - "CO-ANT": "Antioquia", - "CO-ARA": "Arauca", - "CO-ATL": "Atlántico", - "CO-BOL": "Bolívar", - "CO-BOY": "Boyacá", - "CO-CAL": "Caldas", - "CO-CAQ": "Caquetá", - "CO-CAS": "Casanare", - "CO-CAU": "Cauca", - "CO-CES": "Cesar", - "CO-COR": "Córdoba", - "CO-CUN": "Cundinamarca", - "CO-CHO": "Chocó", - "CO-DC": "Distrito Capital de Bogotá", - "CO-GUA": "Guainía", - "CO-GUV": "Guaviare", - "CO-HUI": "Huila", - "CO-LAG": "La Guajira", - "CO-MAG": "Magdalena", - "CO-MET": "Meta", - "CO-NAR": "Nariño", - "CO-NSA": "Norte de Santander", - "CO-PUT": "Putumayo", - "CO-QUI": "Quindío", - "CO-RIS": "Risaralda", - "CO-SAP": "San Andrés", - "CO-SAN": "Santander", - "CO-SUC": "Sucre", - "CO-TOL": "Tolima", - "CO-VAC": "Valle del Cauca", - "CO-VAU": "Vaupés", - "CO-VID": "Vichada", - "KM-G": "Grande Comore", - "KM-A": "Anjouan", - "KM-M": "Mohéli", - "CG-11": "Bouenza", - "CG-BZV": "Brazzaville", - "CG-8": "Cuvette", - "CG-15": "Cuvette-Ouest", - "CG-5": "Kouilou", - "CG-2": "Lékoumou", - "CG-7": "Likouala", - "CG-9": "Niari", - "CG-14": "Plateaux", - "CG-16": "Pointe-Noire", - "CG-12": "Pool", - "CG-13": "Sangha", - "CD-BU": "Bas-Uélé", - "CD-EQ": "Équateur", - "CD-HK": "Haut-Katanga", - "CD-HL": "Haut-Lomami", - "CD-HU": "Haut-Uélé", - "CD-IT": "Ituri", - "CD-KS": "Kasaï", - "CD-KC": "Kasaï Central", - "CD-KE": "Kasaï Oriental", - "CD-KN": "Kinshasa", - "CD-BC": "Kongo Central", - "CD-KG": "Kwango", - "CD-KL": "Kwilu", - "CD-LO": "Lomami", - "CD-LU": "Lualaba", - "CD-MN": "Mai-Ndombe", - "CD-MA": "Maniema", - "CD-MO": "Mongala", - "CD-NK": "Nord-Kivu", - "CD-NU": "Nord-Ubangi", - "CD-SA": "Sankuru", - "CD-SK": "Sud-Kivu", - "CD-SU": "Sud-Ubangi", - "CD-TA": "Tanganyika", - "CD-TO": "Tshopo", - "CD-TU": "Tshuapa", - "CR-A": "Alajuela", - "CR-C": "Cartago", - "CR-G": "Guanacaste", - "CR-H": "Heredia", - "CR-L": "Limón", - "CR-P": "Puntarenas", - "CR-SJ": "San José", - "CI-AB": "Abidjan", - "CI-BS": "Bas-Sassandra", - "CI-CM": "Comoé", - "CI-DN": "Denguélé", - "CI-GD": "Gôh-Djiboua", - "CI-LC": "Lacs", - "CI-LG": "Lagunes", - "CI-MG": "Montagnes", - "CI-SM": "Sassandra-Marahoué", - "CI-SV": "Savanes", - "CI-VB": "Vallée du Bandama", - "CI-WR": "Woroba", - "CI-YM": "Yamoussoukro", - "CI-ZZ": "Zanzan", - "HR-07": "Bjelovarsko-bilogorska županija", - "HR-12": "Brodsko-posavska županija", - "HR-19": "Dubrovačko-neretvanska županija", - "HR-21": "Grad Zagreb", - "HR-18": "Istarska županija", - "HR-04": "Karlovačka županija", - "HR-06": "Koprivničko-križevačka županija", - "HR-02": "Krapinsko-zagorska županija", - "HR-09": "Ličko-senjska županija", - "HR-20": "Međimurska županija", - "HR-14": "Osječko-baranjska županija", - "HR-11": "Požeško-slavonska županija", - "HR-08": "Primorsko-goranska županija", - "HR-03": "Sisačko-moslavačka županija", - "HR-17": "Splitsko-dalmatinska županija", - "HR-15": "Šibensko-kninska županija", - "HR-05": "Varaždinska županija", - "HR-10": "Virovitičko-podravska županija", - "HR-16": "Vukovarsko-srijemska županija", - "HR-13": "Zadarska županija", - "HR-01": "Zagrebačka županija", - "CU-15": "Artemisa", - "CU-09": "Camagüey", - "CU-08": "Ciego de Ávila", - "CU-06": "Cienfuegos", - "CU-12": "Granma", - "CU-14": "Guantánamo", - "CU-11": "Holguín", - "CU-03": "La Habana", - "CU-10": "Las Tunas", - "CU-04": "Matanzas", - "CU-16": "Mayabeque", - "CU-01": "Pinar del Río", - "CU-07": "Sancti Spíritus", - "CU-13": "Santiago de Cuba", - "CU-05": "Villa Clara", - "CU-99": "Isla de la Juventud", - "CY-04": "Ammochostos", - "CY-06": "Keryneia", - "CY-03": "Larnaka", - "CY-01": "Lefkosia", - "CY-02": "Lemesos", - "CY-05": "Pafos", - "CZ-31": "Jihočeský kraj", - "CZ-64": "Jihomoravský kraj", - "CZ-41": "Karlovarský kraj", - "CZ-52": "Královéhradecký kraj", - "CZ-51": "Liberecký kraj", - "CZ-80": "Moravskoslezský kraj", - "CZ-71": "Olomoucký kraj", - "CZ-53": "Pardubický kraj", - "CZ-32": "Plzeňský kraj", - "CZ-10": "Praha", - "CZ-20": "Středočeský kraj", - "CZ-42": "Ústecký kraj", - "CZ-63": "Kraj Vysočina", - "CZ-72": "Zlínský kraj", - "DK-84": "Region Hovedstaden", - "DK-82": "Region Midjylland", - "DK-81": "Region Nordjylland", - "DK-85": "Region Sjælland", - "DK-83": "Region Syddanmark", - "DJ-AS": "Ali Sabieh", - "DJ-AR": "Arta", - "DJ-DI": "Dikhil", - "DJ-DJ": "Djibouti", - "DJ-OB": "Obock", - "DJ-TA": "Tadjourah", - "DM-02": "Saint Andrew", - "DM-03": "Saint David", - "DM-04": "Saint George", - "DM-05": "Saint John", - "DM-06": "Saint Joseph", - "DM-07": "Saint Luke", - "DM-08": "Saint Mark", - "DM-09": "Saint Patrick", - "DM-10": "Saint Paul", - "DM-11": "Saint Peter", - "DO-33": "Cibao Nordeste", - "DO-34": "Cibao Noroeste", - "DO-35": "Cibao Norte", - "DO-36": "Cibao Sur", - "DO-37": "El Valle", - "DO-38": "Enriquillo", - "DO-39": "Higuamo", - "DO-40": "Ozama", - "DO-41": "Valdesia", - "DO-42": "Yuma", - "EC-A": "Azuay", - "EC-B": "Bolívar", - "EC-F": "Cañar", - "EC-C": "Carchi", - "EC-H": "Chimborazo", - "EC-X": "Cotopaxi", - "EC-O": "El Oro", - "EC-E": "Esmeraldas", - "EC-W": "Galápagos", - "EC-G": "Guayas", - "EC-I": "Imbabura", - "EC-L": "Loja", - "EC-R": "Los Ríos", - "EC-M": "Manabí", - "EC-S": "Morona Santiago", - "EC-N": "Napo", - "EC-D": "Orellana", - "EC-Y": "Pastaza", - "EC-P": "Pichincha", - "EC-SE": "Santa Elena", - "EC-SD": "Santo Domingo de los Tsáchilas", - "EC-U": "Sucumbíos", - "EC-T": "Tungurahua", - "EC-Z": "Zamora Chinchipe", - "EG-DK": "Ad Daqahlīyah", - "EG-BA": "Al Baḩr al Aḩmar", - "EG-BH": "Al Buḩayrah", - "EG-FYM": "Al Fayyūm", - "EG-GH": "Al Gharbīyah", - "EG-ALX": "Al Iskandarīyah", - "EG-IS": "Al Ismā'īlīyah", - "EG-GZ": "Al Jīzah", - "EG-MNF": "Al Minūfīyah", - "EG-MN": "Al Minyā", - "EG-C": "Al Qāhirah", - "EG-KB": "Al Qalyūbīyah", - "EG-LX": "Al Uqşur", - "EG-WAD": "Al Wādī al Jadīd", - "EG-SUZ": "As Suways", - "EG-SHR": "Ash Sharqīyah", - "EG-ASN": "Aswān", - "EG-AST": "Asyūţ", - "EG-BNS": "Banī Suwayf", - "EG-PTS": "Būr Sa‘īd", - "EG-DT": "Dumyāţ", - "EG-JS": "Janūb Sīnā'", - "EG-KFS": "Kafr ash Shaykh", - "EG-MT": "Maţrūḩ", - "EG-KN": "Qinā", - "EG-SIN": "Shamāl Sīnā'", - "EG-SHG": "Sūhāj", - "SV-AH": "Ahuachapán", - "SV-CA": "Cabañas", - "SV-CH": "Chalatenango", - "SV-CU": "Cuscatlán", - "SV-LI": "La Libertad", - "SV-PA": "La Paz", - "SV-UN": "La Unión", - "SV-MO": "Morazán", - "SV-SM": "San Miguel", - "SV-SS": "San Salvador", - "SV-SV": "San Vicente", - "SV-SA": "Santa Ana", - "SV-SO": "Sonsonate", - "SV-US": "Usulután", - "GQ-C": "Región Continental", - "GQ-I": "Región Insular", - "ER-MA": "Al Awsaţ", - "ER-DU": "Al Janūbī", - "ER-AN": "Ansabā", - "ER-DK": "Janūbī al Baḩrī al Aḩmar", - "ER-GB": "Qāsh-Barkah", - "ER-SK": "Shimālī al Baḩrī al Aḩmar", - "EE-37": "Harjumaa", - "EE-39": "Hiiumaa", - "EE-45": "Ida-Virumaa", - "EE-50": "Jõgevamaa", - "EE-52": "Järvamaa", - "EE-60": "Lääne-Virumaa", - "EE-56": "Läänemaa", - "EE-64": "Põlvamaa", - "EE-68": "Pärnumaa", - "EE-71": "Raplamaa", - "EE-74": "Saaremaa", - "EE-79": "Tartumaa", - "EE-81": "Valgamaa", - "EE-84": "Viljandimaa", - "EE-87": "Võrumaa", - "ET-AA": "Ādīs Ābeba", - "ET-AF": "Āfar", - "ET-AM": "Āmara", - "ET-BE": "Bīnshangul Gumuz", - "ET-DD": "Dirē Dawa", - "ET-GA": "Gambēla Hizboch", - "ET-HA": "Hārerī Hizb", - "ET-OR": "Oromīya", - "ET-SI": "Sīdama", - "ET-SO": "Sumalē", - "ET-TI": "Tigray", - "ET-SN": "YeDebub Bihēroch Bihēreseboch na Hizboch", - "ET-SW": "YeDebub M‘irab Ītyop’iya Hizboch", - "FJ-C": "Central", - "FJ-E": "Eastern", - "FJ-N": "Northern", - "FJ-W": "Western", - "FJ-R": "Rotuma", - "FI-01": "Ahvenanmaan maakunta", - "FI-02": "Etelä-Karjala", - "FI-03": "Etelä-Pohjanmaa", - "FI-04": "Etelä-Savo", - "FI-05": "Kainuu", - "FI-06": "Kanta-Häme", - "FI-07": "Keski-Pohjanmaa", - "FI-08": "Keski-Suomi", - "FI-09": "Kymenlaakso", - "FI-10": "Lappi", - "FI-11": "Pirkanmaa", - "FI-12": "Pohjanmaa", - "FI-13": "Pohjois-Karjala", - "FI-14": "Pohjois-Pohjanmaa", - "FI-15": "Pohjois-Savo", - "FI-16": "Päijät-Häme", - "FI-17": "Satakunta", - "FI-18": "Uusimaa", - "FI-19": "Varsinais-Suomi", - "FR-ARA": "Auvergne-Rhône-Alpes", - "FR-BFC": "Bourgogne-Franche-Comté", - "FR-BRE": "Bretagne", - "FR-CVL": "Centre-Val de Loire", - "FR-20R": "Corse", - "FR-GES": "Grand Est", - "FR-HDF": "Hauts-de-France", - "FR-IDF": "Île-de-France", - "FR-NOR": "Normandie", - "FR-NAQ": "Nouvelle-Aquitaine", - "FR-OCC": "Occitanie", - "FR-PDL": "Pays-de-la-Loire", - "FR-PAC": "Provence-Alpes-Côte-d’Azur", - "GA-1": "Estuaire", - "GA-2": "Haut-Ogooué", - "GA-3": "Moyen-Ogooué", - "GA-4": "Ngounié", - "GA-5": "Nyanga", - "GA-6": "Ogooué-Ivindo", - "GA-7": "Ogooué-Lolo", - "GA-8": "Ogooué-Maritime", - "GA-9": "Woleu-Ntem", - "GM-B": "Banjul", - "GM-M": "Central River", - "GM-L": "Lower River", - "GM-N": "North Bank", - "GM-U": "Upper River", - "GM-W": "Western", - "GE-AB": "Abkhazia", - "GE-AJ": "Ajaria", - "GE-GU": "Guria", - "GE-IM": "Imereti", - "GE-KA": "K'akheti", - "GE-KK": "Kvemo Kartli", - "GE-MM": "Mtskheta-Mtianeti", - "GE-RL": "Rach'a-Lechkhumi-Kvemo Svaneti", - "GE-SZ": "Samegrelo-Zemo Svaneti", - "GE-SJ": "Samtskhe-Javakheti", - "GE-SK": "Shida Kartli", - "GE-TB": "Tbilisi", - "DE-BW": "Baden-Württemberg", - "DE-BY": "Bayern", - "DE-BE": "Berlin", - "DE-BB": "Brandenburg", - "DE-HB": "Bremen", - "DE-HH": "Hamburg", - "DE-HE": "Hessen", - "DE-MV": "Mecklenburg-Vorpommern", - "DE-NI": "Niedersachsen", - "DE-NW": "Nordrhein-Westfalen", - "DE-RP": "Rheinland-Pfalz", - "DE-SL": "Saarland", - "DE-SN": "Sachsen", - "DE-ST": "Sachsen-Anhalt", - "DE-SH": "Schleswig-Holstein", - "DE-TH": "Thüringen", - "GH-AF": "Ahafo", - "GH-AH": "Ashanti", - "GH-BO": "Bono", - "GH-BE": "Bono East", - "GH-CP": "Central", - "GH-EP": "Eastern", - "GH-AA": "Greater Accra", - "GH-NE": "North East", - "GH-NP": "Northern", - "GH-OT": "Oti", - "GH-SV": "Savannah", - "GH-UE": "Upper East", - "GH-UW": "Upper West", - "GH-TV": "Volta", - "GH-WP": "Western", - "GH-WN": "Western North", - "GR-69": "Ágion Óros", - "GR-A": "Anatolikí Makedonía kaiThráki", - "GR-I": "Attikí", - "GR-G": "Dytikí Elláda", - "GR-C": "Dytikí Makedonía", - "GR-F": "Ionía Nísia", - "GR-D": "Ípeiros", - "GR-B": "Kentrikí Makedonía", - "GR-M": "Kríti", - "GR-L": "Nótio Aigaío", - "GR-J": "Pelopónnisos", - "GR-H": "Stereá Elláda", - "GR-E": "Thessalía", - "GR-K": "Vóreio Aigaío", - "GL-AV": "Avannaata Kommunia", - "GL-KU": "Kommune Kujalleq", - "GL-QT": "Kommune Qeqertalik", - "GL-SM": "Kommuneqarfik Sermersooq", - "GL-QE": "Qeqqata Kommunia", - "GD-01": "Saint Andrew", - "GD-02": "Saint David", - "GD-03": "Saint George", - "GD-04": "Saint John", - "GD-05": "Saint Mark", - "GD-06": "Saint Patrick", - "GD-10": "Southern Grenadine Islands", - "GT-16": "Alta Verapaz", - "GT-15": "Baja Verapaz", - "GT-04": "Chimaltenango", - "GT-20": "Chiquimula", - "GT-02": "El Progreso", - "GT-05": "Escuintla", - "GT-01": "Guatemala", - "GT-13": "Huehuetenango", - "GT-18": "Izabal", - "GT-21": "Jalapa", - "GT-22": "Jutiapa", - "GT-17": "Petén", - "GT-09": "Quetzaltenango", - "GT-14": "Quiché", - "GT-11": "Retalhuleu", - "GT-03": "Sacatepéquez", - "GT-12": "San Marcos", - "GT-06": "Santa Rosa", - "GT-07": "Sololá", - "GT-10": "Suchitepéquez", - "GT-08": "Totonicapán", - "GT-19": "Zacapa", - "GN-B": "Boké", - "GN-F": "Faranah", - "GN-K": "Kankan", - "GN-D": "Kindia", - "GN-L": "Labé", - "GN-M": "Mamou", - "GN-N": "Nzérékoré", - "GN-C": "Conakry", - "GW-L": "Leste", - "GW-N": "Norte", - "GW-S": "Sul", - "GY-BA": "Barima-Waini", - "GY-CU": "Cuyuni-Mazaruni", - "GY-DE": "Demerara-Mahaica", - "GY-EB": "East Berbice-Corentyne", - "GY-ES": "Essequibo Islands-West Demerara", - "GY-MA": "Mahaica-Berbice", - "GY-PM": "Pomeroon-Supenaam", - "GY-PT": "Potaro-Siparuni", - "GY-UD": "Upper Demerara-Berbice", - "GY-UT": "Upper Takutu-Upper Essequibo", - "HT-AR": "Artibonite", - "HT-CE": "Centre", - "HT-GA": "Grande’Anse", - "HT-NI": "Nippes", - "HT-ND": "Nord", - "HT-NE": "Nord-Est", - "HT-NO": "Nord-Ouest", - "HT-OU": "Ouest", - "HT-SD": "Sud", - "HT-SE": "Sud-Est", - "HN-AT": "Atlántida", - "HN-CH": "Choluteca", - "HN-CL": "Colón", - "HN-CM": "Comayagua", - "HN-CP": "Copán", - "HN-CR": "Cortés", - "HN-EP": "El Paraíso", - "HN-FM": "Francisco Morazán", - "HN-GD": "Gracias a Dios", - "HN-IN": "Intibucá", - "HN-IB": "Islas de la Bahía", - "HN-LP": "La Paz", - "HN-LE": "Lempira", - "HN-OC": "Ocotepeque", - "HN-OL": "Olancho", - "HN-SB": "Santa Bárbara", - "HN-VA": "Valle", - "HN-YO": "Yoro", - "HU-BK": "Bács-Kiskun", - "HU-BA": "Baranya", - "HU-BE": "Békés", - "HU-BC": "Békéscsaba", - "HU-BZ": "Borsod-Abaúj-Zemplén", - "HU-BU": "Budapest", - "HU-CS": "Csongrád-Csanád", - "HU-DE": "Debrecen", - "HU-DU": "Dunaújváros", - "HU-EG": "Eger", - "HU-ER": "Érd", - "HU-FE": "Fejér", - "HU-GY": "Győr", - "HU-GS": "Győr-Moson-Sopron", - "HU-HB": "Hajdú-Bihar", - "HU-HE": "Heves", - "HU-HV": "Hódmezővásárhely", - "HU-JN": "Jász-Nagykun-Szolnok", - "HU-KV": "Kaposvár", - "HU-KM": "Kecskemét", - "HU-KE": "Komárom-Esztergom", - "HU-MI": "Miskolc", - "HU-NK": "Nagykanizsa", - "HU-NO": "Nógrád", - "HU-NY": "Nyíregyháza", - "HU-PS": "Pécs", - "HU-PE": "Pest", - "HU-ST": "Salgótarján", - "HU-SO": "Somogy", - "HU-SN": "Sopron", - "HU-SZ": "Szabolcs-Szatmár-Bereg", - "HU-SD": "Szeged", - "HU-SF": "Székesfehérvár", - "HU-SS": "Szekszárd", - "HU-SK": "Szolnok", - "HU-SH": "Szombathely", - "HU-TB": "Tatabánya", - "HU-TO": "Tolna", - "HU-VA": "Vas", - "HU-VM": "Veszprém", - "HU-VE": "Veszprém", - "HU-ZA": "Zala", - "HU-ZE": "Zalaegerszeg", - "IS-7": "Austurland", - "IS-1": "Höfuðborgarsvæði", - "IS-6": "Norðurland eystra", - "IS-5": "Norðurland vestra", - "IS-8": "Suðurland", - "IS-2": "Suðurnes", - "IS-4": "Vestfirðir", - "IS-3": "Vesturland", - "IN-AN": "Andaman and Nicobar Islands", - "IN-AP": "Andhra Pradesh", - "IN-AR": "Arunāchal Pradesh", - "IN-AS": "Assam", - "IN-BR": "Bihār", - "IN-CH": "Chandīgarh", - "IN-CG": "Chhattīsgarh", - "IN-DH": "Dādra and Nagar Haveli and Damān and Diu[1]", - "IN-DL": "Delhi", - "IN-GA": "Goa", - "IN-GJ": "Gujarāt", - "IN-HR": "Haryāna", - "IN-HP": "Himāchal Pradesh", - "IN-JK": "Jammu and Kashmīr", - "IN-JH": "Jhārkhand", - "IN-KA": "Karnātaka", - "IN-KL": "Kerala", - "IN-LA": "Ladākh", - "IN-LD": "Lakshadweep", - "IN-MP": "Madhya Pradesh", - "IN-MH": "Mahārāshtra", - "IN-MN": "Manipur", - "IN-ML": "Meghālaya", - "IN-MZ": "Mizoram", - "IN-NL": "Nāgāland", - "IN-OD": "Odisha", - "IN-PY": "Puducherry", - "IN-PB": "Punjab", - "IN-RJ": "Rājasthān", - "IN-SK": "Sikkim", - "IN-TN": "Tamil Nādu", - "IN-TS": "Telangāna[2]", - "IN-TR": "Tripura", - "IN-UP": "Uttar Pradesh", - "IN-UK": "Uttarākhand", - "IN-WB": "West Bengal", - "ID-JW": "Jawa", - "ID-KA": "Kalimantan", - "ID-ML": "Maluku", - "ID-NU": "Nusa Tenggara", - "ID-PP": "Papua", - "ID-SL": "Sulawesi", - "ID-SM": "Sumatera", - "IR-30": "Alborz", - "IR-24": "Ardabīl", - "IR-04": "Āz̄ārbāyjān-e Ghārbī", - "IR-03": "Āz̄ārbāyjān-e Shārqī", - "IR-18": "Būshehr", - "IR-14": "Chahār Maḩāl va Bakhtīārī", - "IR-10": "Eşfahān", - "IR-07": "Fārs", - "IR-01": "Gīlān", - "IR-27": "Golestān", - "IR-13": "Hamadān", - "IR-22": "Hormozgān", - "IR-16": "Īlām", - "IR-08": "Kermān", - "IR-05": "Kermānshāh", - "IR-29": "Khorāsān-e Jonūbī", - "IR-09": "Khorāsān-e Raẕavī", - "IR-28": "Khorāsān-e Shomālī", - "IR-06": "Khūzestān", - "IR-17": "Kohgīlūyeh va Bowyer Aḩmad", - "IR-12": "Kordestān", - "IR-15": "Lorestān", - "IR-00": "Markazī", - "IR-02": "Māzandarān", - "IR-26": "Qazvīn", - "IR-25": "Qom", - "IR-20": "Semnān", - "IR-11": "Sīstān va Balūchestān", - "IR-23": "Tehrān", - "IR-21": "Yazd", - "IR-19": "Zanjān", - "IQ-AN": "Al Anbār", - "IQ-BA": "Al Başrah", - "IQ-MU": "Al Muthanná", - "IQ-QA": "Al Qādisīyah", - "IQ-NA": "An Najaf", - "IQ-AR": "Arbīl", - "IQ-SU": "As Sulaymānīyah", - "IQ-BB": "Bābil", - "IQ-BG": "Baghdād", - "IQ-DA": "Dahūk", - "IQ-DQ": "Dhī Qār", - "IQ-DI": "Diyālá", - "IQ-KR": "Iqlīm Kūrdistān", - "IQ-KA": "Karbalā’", - "IQ-KI": "Kirkūk", - "IQ-MA": "Maysān", - "IQ-NI": "Nīnawá", - "IQ-SD": "Şalāḩ ad Dīn", - "IQ-WA": "Wāsiţ", - "IE-C": "Connaught", - "IE-L": "Leinster", - "IE-M": "Munster", - "IE-U": "Ulster[a]", - "IL-D": "HaDarom", - "IL-M": "HaMerkaz", - "IL-Z": "HaTsafon", - "IL-HA": "H̱efa", - "IL-TA": "Tel Aviv", - "IL-JM": "Yerushalayim", - "IT-65": "Abruzzo", - "IT-77": "Basilicata", - "IT-78": "Calabria", - "IT-72": "Campania", - "IT-45": "Emilia-Romagna", - "IT-36": "Friuli Venezia Giulia", - "IT-62": "Lazio", - "IT-42": "Liguria", - "IT-25": "Lombardia", - "IT-57": "Marche", - "IT-67": "Molise", - "IT-21": "Piemonte", - "IT-75": "Puglia", - "IT-88": "Sardegna", - "IT-82": "Sicilia", - "IT-52": "Toscana", - "IT-32": "Trentino-Alto Adigede", - "IT-55": "Umbria", - "IT-23": "Valle d'Aostafr", - "IT-34": "Veneto", - "JM-13": "Clarendon", - "JM-09": "Hanover", - "JM-01": "Kingston", - "JM-12": "Manchester", - "JM-04": "Portland", - "JM-02": "Saint Andrew", - "JM-06": "Saint Ann", - "JM-14": "Saint Catherine", - "JM-11": "Saint Elizabeth", - "JM-08": "Saint James", - "JM-05": "Saint Mary", - "JM-03": "Saint Thomas", - "JM-07": "Trelawny", - "JM-10": "Westmoreland", - "JP-23": "Aiti", - "JP-05": "Akita", - "JP-02": "Aomori", - "JP-38": "Ehime", - "JP-21": "Gihu", - "JP-10": "Gunma", - "JP-34": "Hirosima", - "JP-01": "Hokkaidô", - "JP-18": "Hukui", - "JP-40": "Hukuoka", - "JP-07": "Hukusima", - "JP-28": "Hyôgo", - "JP-08": "Ibaraki", - "JP-17": "Isikawa", - "JP-03": "Iwate", - "JP-37": "Kagawa", - "JP-46": "Kagosima", - "JP-14": "Kanagawa", - "JP-39": "Kôti", - "JP-43": "Kumamoto", - "JP-26": "Kyôto", - "JP-24": "Mie", - "JP-04": "Miyagi", - "JP-45": "Miyazaki", - "JP-20": "Nagano", - "JP-42": "Nagasaki", - "JP-29": "Nara", - "JP-15": "Niigata", - "JP-44": "Ôita", - "JP-33": "Okayama", - "JP-47": "Okinawa", - "JP-27": "Ôsaka", - "JP-41": "Saga", - "JP-11": "Saitama", - "JP-25": "Siga", - "JP-32": "Simane", - "JP-22": "Sizuoka", - "JP-12": "Tiba", - "JP-36": "Tokusima", - "JP-13": "Tôkyô", - "JP-09": "Totigi", - "JP-31": "Tottori", - "JP-16": "Toyama", - "JP-30": "Wakayama", - "JP-06": "Yamagata", - "JP-35": "Yamaguti", - "JP-19": "Yamanasi", - "JO-AJ": "‘Ajlūn", - "JO-AQ": "Al ‘Aqabah", - "JO-AM": "Al ‘A̅şimah", - "JO-BA": "Al Balqā’", - "JO-KA": "Al Karak", - "JO-MA": "Al Mafraq", - "JO-AT": "Aţ Ţafīlah", - "JO-AZ": "Az Zarqā’", - "JO-IR": "Irbid", - "JO-JA": "Jarash", - "JO-MN": "Ma‘ān", - "JO-MD": "Mādabā", - "KZ-10": "Abayoblysy", - "KZ-75": "Almaty", - "KZ-19": "Almatyoblysy", - "KZ-11": "Aqmola oblysy", - "KZ-15": "Aqtöbe oblysy", - "KZ-71": "Astana", - "KZ-23": "Atyraūoblysy", - "KZ-27": "Batys Qazaqstan oblysy", - "KZ-47": "Mangghystaū oblysy", - "KZ-55": "Pavlodar oblysy", - "KZ-35": "Qaraghandy oblysy", - "KZ-39": "Qostanay oblysy", - "KZ-43": "Qyzylorda oblysy", - "KZ-63": "Shyghys Qazaqstan oblysy", - "KZ-79": "Shymkent", - "KZ-59": "Soltüstik Qazaqstan oblysy", - "KZ-61": "Türkistan oblysy", - "KZ-62": "Ulytaūoblysy", - "KZ-31": "Zhambyl oblysy", - "KZ-33": "Zhetisū oblysy", - "KE-01": "Baringo", - "KE-02": "Bomet", - "KE-03": "Bungoma", - "KE-04": "Busia", - "KE-05": "Elgeyo/Marakwet", - "KE-06": "Embu", - "KE-07": "Garissa", - "KE-08": "Homa Bay", - "KE-09": "Isiolo", - "KE-10": "Kajiado", - "KE-11": "Kakamega", - "KE-12": "Kericho", - "KE-13": "Kiambu", - "KE-14": "Kilifi", - "KE-15": "Kirinyaga", - "KE-16": "Kisii", - "KE-17": "Kisumu", - "KE-18": "Kitui", - "KE-19": "Kwale", - "KE-20": "Laikipia", - "KE-21": "Lamu", - "KE-22": "Machakos", - "KE-23": "Makueni", - "KE-24": "Mandera", - "KE-25": "Marsabit", - "KE-26": "Meru", - "KE-27": "Migori", - "KE-28": "Mombasa", - "KE-29": "Murang'a", - "KE-30": "Nairobi City", - "KE-31": "Nakuru", - "KE-32": "Nandi", - "KE-33": "Narok", - "KE-34": "Nyamira", - "KE-35": "Nyandarua", - "KE-36": "Nyeri", - "KE-37": "Samburu", - "KE-38": "Siaya", - "KE-39": "Taita/Taveta", - "KE-40": "Tana River", - "KE-41": "Tharaka-Nithi", - "KE-42": "Trans Nzoia", - "KE-43": "Turkana", - "KE-44": "Uasin Gishu", - "KE-45": "Vihiga", - "KE-46": "Wajir", - "KE-47": "West Pokot", - "KI-G": "Gilbert Islands", - "KI-L": "Line Islands", - "KI-P": "Phoenix Islands", - "KP-04": "Chagang-do", - "KP-09": "Hamgyǒng-bukto", - "KP-08": "Hamgyǒng-namdo", - "KP-06": "Hwanghae-bukto", - "KP-05": "Hwanghae-namdo", - "KP-15": "Kaesŏng", - "KP-07": "Kangwǒn-do", - "KP-14": "Namp’o", - "KP-03": "P'yǒngan-bukto", - "KP-02": "P'yǒngan-namdo", - "KP-01": "P'yǒngyang", - "KP-13": "Rasǒn", - "KP-10": "Ryanggang-do", - "KR-26": "Busan-gwangyeoksi", - "KR-43": "Chungcheongbuk-do", - "KR-44": "Chungcheongnam-do", - "KR-27": "Daegu-gwangyeoksi", - "KR-30": "Daejeon-gwangyeoksi", - "KR-42": "Gangwon-teukbyeoljachido", - "KR-29": "Gwangju-gwangyeoksi", - "KR-41": "Gyeonggi-do", - "KR-47": "Gyeongsangbuk-do", - "KR-48": "Gyeongsangnam-do", - "KR-28": "Incheon-gwangyeoksi", - "KR-49": "Jeju-teukbyeoljachido", - "KR-45": "Jeollabuk-do", - "KR-46": "Jeollanam-do", - "KR-50": "Sejong", - "KR-11": "Seoul-teukbyeolsi", - "KR-31": "Ulsan-gwangyeoksi", - "KW-AH": "Al Aḩmadī", - "KW-FA": "Al Farwānīyah", - "KW-JA": "Al Jahrā’", - "KW-KU": "Al ‘Āşimah", - "KW-HA": "Ḩawallī", - "KW-MU": "Mubārak al Kabīr", - "KG-B": "Batken", - "KG-GB": "Bishkek Shaary", - "KG-C": "Chüy", - "KG-J": "Jalal-Abad", - "KG-N": "Naryn", - "KG-O": "Osh", - "KG-GO": "Osh Shaary", - "KG-T": "Talas", - "KG-Y": "Ysyk-Köl", - "LA-AT": "Attapu", - "LA-BK": "Bokèo", - "LA-BL": "Bolikhamxai", - "LA-CH": "Champasak", - "LA-HO": "Houaphan", - "LA-KH": "Khammouan", - "LA-LM": "Louang Namtha", - "LA-LP": "Louangphabang", - "LA-OU": "Oudômxai", - "LA-PH": "Phôngsali", - "LA-SL": "Salavan", - "LA-SV": "Savannakhét", - "LA-VI": "Viangchan", - "LA-VT": "Viangchan", - "LA-XA": "Xaignabouli", - "LA-XS": "Xaisômboun", - "LA-XE": "Xékong", - "LA-XI": "Xiangkhouang", - "LV-002": "Aizkraukles novads", - "LV-007": "Alūksnes novads", - "LV-111": "Augšdaugavas novads", - "LV-011": "Ādažu novads", - "LV-015": "Balvu novads", - "LV-016": "Bauskas novads", - "LV-022": "Cēsu novads", - "LV-DGV": "Daugavpils", - "LV-112": "Dienvidkurzemes Novads", - "LV-026": "Dobeles novads", - "LV-033": "Gulbenes novads", - "LV-JEL": "Jelgava", - "LV-041": "Jelgavas novads", - "LV-042": "Jēkabpils novads", - "LV-JUR": "Jūrmala", - "LV-047": "Krāslavas novads", - "LV-050": "Kuldīgas novads", - "LV-052": "Ķekavas novads", - "LV-LPX": "Liepāja", - "LV-054": "Limbažu novads", - "LV-056": "Līvānu novads", - "LV-058": "Ludzas novads", - "LV-059": "Madonas novads", - "LV-062": "Mārupes novads", - "LV-067": "Ogres novads", - "LV-068": "Olaines novads", - "LV-073": "Preiļu novads", - "LV-REZ": "Rēzekne", - "LV-077": "Rēzeknes novads", - "LV-RIX": "Rīga", - "LV-080": "Ropažu novads", - "LV-087": "Salaspils novads", - "LV-088": "Saldus novads", - "LV-089": "Saulkrastu novads", - "LV-091": "Siguldas novads", - "LV-094": "Smiltenes novads", - "LV-097": "Talsu novads", - "LV-099": "Tukuma novads", - "LV-101": "Valkas novads", - "LV-113": "Valmieras Novads", - "LV-102": "Varakļānu novads", - "LV-VEN": "Ventspils", - "LV-106": "Ventspils novads", - "LB-AK": "Aakkâr", - "LB-BH": "Baalbek-Hermel", - "LB-BI": "Béqaa", - "LB-BA": "Beyrouth", - "LB-AS": "Liban-Nord", - "LB-JA": "Liban-Sud", - "LB-JL": "Mont-Liban", - "LB-NA": "Nabatîyé", - "LS-D": "Berea", - "LS-B": "Botha-Bothe", - "LS-C": "Leribe", - "LS-E": "Mafeteng", - "LS-A": "Maseru", - "LS-F": "Mohale's Hoek", - "LS-J": "Mokhotlong", - "LS-H": "Qacha's Nek", - "LS-G": "Quthing", - "LS-K": "Thaba-Tseka", - "LR-BM": "Bomi", - "LR-BG": "Bong", - "LR-GP": "Gbarpolu", - "LR-GB": "Grand Bassa", - "LR-CM": "Grand Cape Mount", - "LR-GG": "Grand Gedeh", - "LR-GK": "Grand Kru", - "LR-LO": "Lofa", - "LR-MG": "Margibi", - "LR-MY": "Maryland", - "LR-MO": "Montserrado", - "LR-NI": "Nimba", - "LR-RI": "River Cess", - "LR-RG": "River Gee", - "LR-SI": "Sinoe", - "LY-BU": "Al Buţnān", - "LY-JA": "Al Jabal al Akhḑar", - "LY-JG": "Al Jabal al Gharbī", - "LY-JI": "Al Jafārah", - "LY-JU": "Al Jufrah", - "LY-KF": "Al Kufrah", - "LY-MJ": "Al Marj", - "LY-MB": "Al Marqab", - "LY-WA": "Al Wāḩāt", - "LY-NQ": "An Nuqāţ al Khams", - "LY-ZA": "Az Zāwiyah", - "LY-BA": "Banghāzī", - "LY-DR": "Darnah", - "LY-GT": "Ghāt", - "LY-MI": "Mişrātah", - "LY-MQ": "Murzuq", - "LY-NL": "Nālūt", - "LY-SB": "Sabhā", - "LY-SR": "Surt", - "LY-TB": "Ţarābulus", - "LY-WD": "Wādī al Ḩayāt", - "LY-WS": "Wādī ash Shāţi’", - "LI-01": "Balzers", - "LI-02": "Eschen", - "LI-03": "Gamprin", - "LI-04": "Mauren", - "LI-05": "Planken", - "LI-06": "Ruggell", - "LI-07": "Schaan", - "LI-08": "Schellenberg", - "LI-09": "Triesen", - "LI-10": "Triesenberg", - "LI-11": "Vaduz", - "LT-AL": "Alytaus apskritis", - "LT-KU": "Kauno apskritis", - "LT-KL": "Klaipėdos apskritis", - "LT-MR": "Marijampolės apskritis", - "LT-PN": "Panevėžio apskritis", - "LT-SA": "Šiaulių apskritis", - "LT-TA": "Tauragės apskritis", - "LT-TE": "Telšių apskritis", - "LT-UT": "Utenos apskritis", - "LT-VL": "Vilniaus apskritis", - "LU-CA": "Capellen", - "LU-CL": "Clervaux", - "LU-DI": "Diekirch", - "LU-EC": "Echternach", - "LU-ES": "Esch-sur-Alzette", - "LU-GR": "Grevenmacher", - "LU-LU": "Luxembourg", - "LU-ME": "Mersch", - "LU-RD": "Redange", - "LU-RM": "Remich", - "LU-VD": "Vianden", - "LU-WI": "Wiltz", - "MG-T": "Antananarivo", - "MG-D": "Antsiranana", - "MG-F": "Fianarantsoa", - "MG-M": "Mahajanga", - "MG-A": "Toamasina", - "MG-U": "Toliara", - "MW-N": "Chakumpoto", - "MW-S": "Chakumwera", - "MW-C": "Chapakati", - "MY-01": "Johor", - "MY-02": "Kedah", - "MY-03": "Kelantan", - "MY-04": "Melaka", - "MY-05": "Negeri Sembilan", - "MY-06": "Pahang", - "MY-08": "Perak", - "MY-09": "Perlis", - "MY-07": "Pulau Pinang", - "MY-12": "Sabah", - "MY-13": "Sarawak", - "MY-10": "Selangor", - "MY-11": "Terengganu", - "MY-14": "Wilayah Persekutuan Kuala Lumpur", - "MY-15": "Wilayah Persekutuan Labuan", - "MY-16": "Wilayah Persekutuan Putrajaya", - "MV-01": "Addu", - "MV-00": "Ariatholhu Dhekunuburi", - "MV-02": "Ariatholhu Uthuruburi", - "MV-03": "Faadhippolhu", - "MV-04": "Felidheatholhu", - "MV-29": "Fuvammulah", - "MV-05": "Hahdhunmathi", - "MV-28": "Huvadhuatholhu Dhekunuburi", - "MV-27": "Huvadhuatholhu Uthuruburi", - "MV-08": "Kolhumadulu", - "MV-MLE": "Maale", - "MV-26": "Maaleatholhu", - "MV-20": "Maalhosmadulu Dhekunuburi", - "MV-13": "Maalhosmadulu Uthuruburi", - "MV-25": "Miladhunmadulu Dhekunuburi", - "MV-24": "Miladhunmadulu Uthuruburi", - "MV-12": "Mulakatholhu", - "MV-17": "Nilandheatholhu Dhekunuburi", - "MV-14": "Nilandheatholhu Uthuruburi", - "MV-23": "Thiladhunmathee Dhekunuburi", - "MV-07": "Thiladhunmathee Uthuruburi", - "ML-BKO": "Bamako", - "ML-7": "Gao", - "ML-1": "Kayes", - "ML-8": "Kidal", - "ML-2": "Koulikoro", - "ML-9": "Ménaka", - "ML-5": "Mopti", - "ML-4": "Ségou", - "ML-3": "Sikasso", - "ML-10": "Taoudénit", - "ML-6": "Tombouctou", - "MT-01": "Attard", - "MT-02": "Balzan", - "MT-03": "Birgu", - "MT-04": "Birkirkara", - "MT-05": "Birżebbuġa", - "MT-06": "Bormla", - "MT-07": "Dingli", - "MT-08": "Fgura", - "MT-09": "Floriana", - "MT-10": "Fontana", - "MT-11": "Gudja", - "MT-12": "Gżira", - "MT-13": "Għajnsielem", - "MT-14": "Għarb", - "MT-15": "Għargħur", - "MT-16": "Għasri", - "MT-17": "Għaxaq", - "MT-18": "Ħamrun", - "MT-19": "Iklin", - "MT-20": "Isla", - "MT-21": "Kalkara", - "MT-22": "Kerċem", - "MT-23": "Kirkop", - "MT-24": "Lija", - "MT-25": "Luqa", - "MT-26": "Marsa", - "MT-27": "Marsaskala", - "MT-28": "Marsaxlokk", - "MT-29": "Mdina", - "MT-30": "Mellieħa", - "MT-31": "Mġarr", - "MT-32": "Mosta", - "MT-33": "Mqabba", - "MT-34": "Msida", - "MT-35": "Mtarfa", - "MT-36": "Munxar", - "MT-37": "Nadur", - "MT-38": "Naxxar", - "MT-39": "Paola", - "MT-40": "Pembroke", - "MT-41": "Pietà", - "MT-42": "Qala", - "MT-43": "Qormi", - "MT-44": "Qrendi", - "MT-45": "Rabat Għawdex", - "MT-46": "Rabat Malta", - "MT-47": "Safi", - "MT-48": "San Ġiljan", - "MT-49": "San Ġwann", - "MT-50": "San Lawrenz", - "MT-51": "San Pawl il-Baħar", - "MT-52": "Sannat", - "MT-53": "Santa Luċija", - "MT-54": "Santa Venera", - "MT-55": "Siġġiewi", - "MT-56": "Sliema", - "MT-57": "Swieqi", - "MT-58": "Ta' Xbiex", - "MT-59": "Tarxien", - "MT-60": "Valletta", - "MT-61": "Xagħra", - "MT-62": "Xewkija", - "MT-63": "Xgħajra", - "MT-64": "Żabbar", - "MT-65": "Żebbuġ Għawdex", - "MT-66": "Żebbuġ Malta", - "MT-67": "Żejtun", - "MT-68": "Żurrieq", - "MH-L": "Ralik chain", - "MH-T": "Ratak chain", - "MR-07": "Adrar", - "MR-03": "Assaba", - "MR-05": "Brakna", - "MR-08": "Dakhlet Nouâdhibou", - "MR-04": "Gorgol", - "MR-10": "Guidimaka", - "MR-01": "Hodh ech Chargui", - "MR-02": "Hodh el Gharbi", - "MR-12": "Inchiri", - "MR-09": "Tagant", - "MR-11": "Tiris Zemmour", - "MR-06": "Trarza", - "MU-AG": "Agalega Islands", - "MU-BL": "Black River", - "MU-CC": "Cargados Carajos Shoals", - "MU-FL": "Flacq", - "MU-GP": "Grand Port", - "MU-MO": "Moka", - "MU-PA": "Pamplemousses", - "MU-PW": "Plaines Wilhems", - "MU-PL": "Port Louis", - "MU-RR": "Rivière du Rempart", - "MU-RO": "Rodrigues Island", - "MU-SA": "Savanne", - "MX-AGU": "Aguascalientes", - "MX-BCN": "Baja California", - "MX-BCS": "Baja California Sur", - "MX-CAM": "Campeche", - "MX-CMX": "Ciudad de México", - "MX-COA": "Coahuila de Zaragoza", - "MX-COL": "Colima", - "MX-CHP": "Chiapas", - "MX-CHH": "Chihuahua", - "MX-DUR": "Durango", - "MX-GUA": "Guanajuato", - "MX-GRO": "Guerrero", - "MX-HID": "Hidalgo", - "MX-JAL": "Jalisco", - "MX-MEX": "México", - "MX-MIC": "Michoacán de Ocampo", - "MX-MOR": "Morelos", - "MX-NAY": "Nayarit", - "MX-NLE": "Nuevo León", - "MX-OAX": "Oaxaca", - "MX-PUE": "Puebla", - "MX-QUE": "Querétaro", - "MX-ROO": "Quintana Roo", - "MX-SLP": "San Luis Potosí", - "MX-SIN": "Sinaloa", - "MX-SON": "Sonora", - "MX-TAB": "Tabasco", - "MX-TAM": "Tamaulipas", - "MX-TLA": "Tlaxcala", - "MX-VER": "Veracruz de Ignacio de la Llave", - "MX-YUC": "Yucatán", - "MX-ZAC": "Zacatecas", - "FM-TRK": "Chuuk", - "FM-KSA": "Kosrae", - "FM-PNI": "Pohnpei", - "FM-YAP": "Yap", - "MD-AN": "Anenii Noi", - "MD-BS": "Basarabeasca", - "MD-BA": "Bălți", - "MD-BD": "Bender", - "MD-BR": "Briceni", - "MD-CA": "Cahul", - "MD-CT": "Cantemir", - "MD-CL": "Călărași", - "MD-CS": "Căușeni", - "MD-CU": "Chișinău", - "MD-CM": "Cimișlia", - "MD-CR": "Criuleni", - "MD-DO": "Dondușeni", - "MD-DR": "Drochia", - "MD-DU": "Dubăsari", - "MD-ED": "Edineț", - "MD-FA": "Fălești", - "MD-FL": "Florești", - "MD-GA": "Găgăuzia", - "MD-GL": "Glodeni", - "MD-HI": "Hîncești", - "MD-IA": "Ialoveni", - "MD-LE": "Leova", - "MD-NI": "Nisporeni", - "MD-OC": "Ocnița", - "MD-OR": "Orhei", - "MD-RE": "Rezina", - "MD-RI": "Rîșcani", - "MD-SI": "Sîngerei", - "MD-SO": "Soroca", - "MD-SN": "Stînga Nistrului", - "MD-ST": "Strășeni", - "MD-SD": "Șoldănești", - "MD-SV": "Ștefan Vodă", - "MD-TA": "Taraclia", - "MD-TE": "Telenești", - "MD-UN": "Ungheni", - "MC-FO": "Fontvieille", - "MC-JE": "Jardin Exotique", - "MC-CL": "La Colle", - "MC-CO": "La Condamine", - "MC-GA": "La Gare", - "MC-SO": "La Source", - "MC-LA": "Larvotto", - "MC-MA": "Malbousquet", - "MC-MO": "Monaco-Ville", - "MC-MG": "Moneghetti", - "MC-MC": "Monte-Carlo", - "MC-MU": "Moulins", - "MC-PH": "Port-Hercule", - "MC-SR": "Saint-Roman", - "MC-SD": "Sainte-Dévote", - "MC-SP": "Spélugues", - "MC-VR": "Vallon de la Rousse", - "MN-073": "Arhangay", - "MN-069": "Bayanhongor", - "MN-071": "Bayan-Ölgiy", - "MN-067": "Bulgan", - "MN-037": "Darhan uul", - "MN-061": "Dornod", - "MN-063": "Dornogovĭ", - "MN-059": "Dundgovĭ", - "MN-057": "Dzavhan", - "MN-065": "Govĭ-Altay", - "MN-064": "Govĭ-Sümber", - "MN-039": "Hentiy", - "MN-043": "Hovd", - "MN-041": "Hövsgöl", - "MN-053": "Ömnögovĭ", - "MN-035": "Orhon", - "MN-055": "Övörhangay", - "MN-049": "Selenge", - "MN-051": "Sühbaatar", - "MN-047": "Töv", - "MN-1": "Ulaanbaatar", - "MN-046": "Uvs", - "MA-05": "Béni Mellal-Khénifra", - "MA-06": "Casablanca-Settat", - "MA-12": "Dakhla-Oued Ed-Dahab", - "MA-08": "Drâa-Tafilalet", - "MA-03": "Fès-Meknès", - "MA-10": "Guelmim-Oued Noun", - "MA-02": "L'Oriental", - "MA-11": "Laâyoune-Sakia El Hamra", - "MA-07": "Marrakech-Safi", - "MA-04": "Rabat-Salé-Kénitra", - "MA-09": "Souss-Massa", - "MA-01": "Tanger-Tétouan-Al Hoceïma", - "MZ-P": "Cabo Delgado", - "MZ-G": "Gaza", - "MZ-I": "Inhambane", - "MZ-B": "Manica", - "MZ-MPM": "Maputo", - "MZ-L": "Maputo", - "MZ-N": "Nampula", - "MZ-A": "Niassa", - "MZ-S": "Sofala", - "MZ-T": "Tete", - "MZ-Q": "Zambézia", - "MM-07": "Ayeyarwady", - "MM-02": "Bago", - "MM-14": "Chin", - "MM-11": "Kachin", - "MM-12": "Kayah", - "MM-13": "Kayin", - "MM-03": "Magway", - "MM-04": "Mandalay", - "MM-15": "Mon", - "MM-18": "Nay Pyi Taw", - "MM-16": "Rakhine", - "MM-01": "Sagaing", - "MM-17": "Shan", - "MM-05": "Tanintharyi", - "MM-06": "Yangon", - "NA-ER": "Erongo", - "NA-HA": "Hardap", - "NA-KA": "//Karas", - "NA-KE": "Kavango East", - "NA-KW": "Kavango West", - "NA-KH": "Khomas", - "NA-KU": "Kunene", - "NA-OW": "Ohangwena", - "NA-OH": "Omaheke", - "NA-OS": "Omusati", - "NA-ON": "Oshana", - "NA-OT": "Oshikoto", - "NA-OD": "Otjozondjupa", - "NA-CA": "Zambezi", - "NR-01": "Aiwo", - "NR-02": "Anabar", - "NR-03": "Anetan", - "NR-04": "Anibare", - "NR-05": "Baitsi", - "NR-06": "Boe", - "NR-07": "Buada", - "NR-08": "Denigomodu", - "NR-09": "Ewa", - "NR-10": "Ijuw", - "NR-11": "Meneng", - "NR-12": "Nibok", - "NR-13": "Uaboe", - "NR-14": "Yaren", - "NP-P3": "Bāgmatī", - "NP-P4": "Gaṇḍakī", - "NP-P6": "Karṇālī", - "NP-P1": "Koshī", - "NP-P5": "Lumbinī", - "NP-P2": "Madhesh", - "NP-P7": "Sudūrpashchim", - "NL-DR": "Drenthe", - "NL-FL": "Flevoland", - "NL-FR": "Fryslânfy", - "NL-GE": "Gelderland", - "NL-GR": "Groningen", - "NL-LI": "Limburg", - "NL-NB": "Noord-Brabant", - "NL-NH": "Noord-Holland", - "NL-OV": "Overijssel", - "NL-UT": "Utrecht", - "NL-ZE": "Zeeland", - "NL-ZH": "Zuid-Holland", - "NZ-AUK": "Auckland", - "NZ-BOP": "Bay of Plenty", - "NZ-CAN": "Canterbury", - "NZ-CIT": "Chatham Islands Territory", - "NZ-GIS": "Gisborne", - "NZ-WGN": "Greater Wellington", - "NZ-HKB": "Hawke's Bay", - "NZ-MWT": "Manawatū-Whanganui", - "NZ-MBH": "Marlborough", - "NZ-NSN": "Nelson", - "NZ-NTL": "Northland", - "NZ-OTA": "Otago", - "NZ-STL": "Southland", - "NZ-TKI": "Taranaki", - "NZ-TAS": "Tasman", - "NZ-WKO": "Waikato", - "NZ-WTC": "West Coast", - "NI-BO": "Boaco", - "NI-CA": "Carazo", - "NI-CI": "Chinandega", - "NI-CO": "Chontales", - "NI-AN": "Costa Caribe Norte", - "NI-AS": "Costa Caribe Sur", - "NI-ES": "Estelí", - "NI-GR": "Granada", - "NI-JI": "Jinotega", - "NI-LE": "León", - "NI-MD": "Madriz", - "NI-MN": "Managua", - "NI-MS": "Masaya", - "NI-MT": "Matagalpa", - "NI-NS": "Nueva Segovia", - "NI-SJ": "Río San Juan", - "NI-RI": "Rivas", - "NE-1": "Agadez", - "NE-2": "Diffa", - "NE-3": "Dosso", - "NE-4": "Maradi", - "NE-8": "Niamey", - "NE-5": "Tahoua", - "NE-6": "Tillabéri", - "NE-7": "Zinder", - "NG-AB": "Abia", - "NG-FC": "Abuja Federal Capital Territory", - "NG-AD": "Adamawa", - "NG-AK": "Akwa Ibom", - "NG-AN": "Anambra", - "NG-BA": "Bauchi", - "NG-BY": "Bayelsa", - "NG-BE": "Benue", - "NG-BO": "Borno", - "NG-CR": "Cross River", - "NG-DE": "Delta", - "NG-EB": "Ebonyi", - "NG-ED": "Edo", - "NG-EK": "Ekiti", - "NG-EN": "Enugu", - "NG-GO": "Gombe", - "NG-IM": "Imo", - "NG-JI": "Jigawa", - "NG-KD": "Kaduna", - "NG-KN": "Kano", - "NG-KT": "Katsina", - "NG-KE": "Kebbi", - "NG-KO": "Kogi", - "NG-KW": "Kwara", - "NG-LA": "Lagos", - "NG-NA": "Nasarawa", - "NG-NI": "Niger", - "NG-OG": "Ogun", - "NG-ON": "Ondo", - "NG-OS": "Osun", - "NG-OY": "Oyo", - "NG-PL": "Plateau", - "NG-RI": "Rivers", - "NG-SO": "Sokoto", - "NG-TA": "Taraba", - "NG-YO": "Yobe", - "NG-ZA": "Zamfara", - "MK-801": "Aerodrom", - "MK-802": "Aračinovo", - "MK-201": "Berovo", - "MK-501": "Bitola", - "MK-401": "Bogdanci", - "MK-601": "Bogovinje", - "MK-402": "Bosilovo", - "MK-602": "Brvenica", - "MK-803": "Butel", - "MK-814": "Centar", - "MK-313": "Centar Župa", - "MK-815": "Čair", - "MK-109": "Čaška", - "MK-210": "Češinovo-Obleševo", - "MK-816": "Čučer-Sandevo", - "MK-303": "Debar", - "MK-304": "Debrca", - "MK-203": "Delčevo", - "MK-502": "Demir Hisar", - "MK-103": "Demir Kapija", - "MK-406": "Dojran", - "MK-503": "Dolneni", - "MK-804": "Gazi Baba", - "MK-405": "Gevgelija", - "MK-805": "Gjorče Petrov", - "MK-604": "Gostivar", - "MK-102": "Gradsko", - "MK-807": "Ilinden", - "MK-606": "Jegunovce", - "MK-205": "Karbinci", - "MK-808": "Karpoš", - "MK-104": "Kavadarci", - "MK-307": "Kičevo", - "MK-809": "Kisela Voda", - "MK-206": "Kočani", - "MK-407": "Konče", - "MK-701": "Kratovo", - "MK-702": "Kriva Palanka", - "MK-504": "Krivogaštani", - "MK-505": "Kruševo", - "MK-703": "Kumanovo", - "MK-704": "Lipkovo", - "MK-105": "Lozovo", - "MK-207": "Makedonska Kamenica", - "MK-308": "Makedonski Brod", - "MK-607": "Mavrovo i Rostuše", - "MK-506": "Mogila", - "MK-106": "Negotino", - "MK-507": "Novaci", - "MK-408": "Novo Selo", - "MK-310": "Ohrid", - "MK-208": "Pehčevo", - "MK-810": "Petrovec", - "MK-311": "Plasnica", - "MK-508": "Prilep", - "MK-209": "Probištip", - "MK-409": "Radoviš", - "MK-705": "Rankovce", - "MK-509": "Resen", - "MK-107": "Rosoman", - "MK-811": "Saraj", - "MK-812": "Sopište", - "MK-706": "Staro Nagoričane", - "MK-312": "Struga", - "MK-410": "Strumica", - "MK-813": "Studeničani", - "MK-108": "Sveti Nikole", - "MK-211": "Štip", - "MK-817": "Šuto Orizari", - "MK-608": "Tearce", - "MK-609": "Tetovo", - "MK-403": "Valandovo", - "MK-404": "Vasilevo", - "MK-101": "Veles", - "MK-301": "Vevčani", - "MK-202": "Vinica", - "MK-603": "Vrapčište", - "MK-806": "Zelenikovo", - "MK-204": "Zrnovci", - "MK-605": "Želino", - "NO-42": "Agder", - "NO-34": "Innlandet", - "NO-22": "Jan Mayen", - "NO-15": "Møre og Romsdal", - "NO-18": "Nordland", - "NO-03": "Oslo", - "NO-11": "Rogaland", - "NO-21": "Svalbard", - "NO-54": "Troms og Finnmarksefkv", - "NO-50": "Trøndelagsma", - "NO-38": "Vestfold og Telemark", - "NO-46": "Vestland", - "NO-30": "Viken", - "OM-DA": "Ad Dākhilīyah", - "OM-BU": "Al Buraymī", - "OM-WU": "Al Wusţá", - "OM-ZA": "Az̧ Z̧āhirah", - "OM-BJ": "Janūb al Bāţinah", - "OM-SJ": "Janūb ash Sharqīyah", - "OM-MA": "Masqaţ", - "OM-MU": "Musandam", - "OM-BS": "Shamāl al Bāţinah", - "OM-SS": "Shamāl ash Sharqīyah", - "OM-ZU": "Z̧ufār", - "PK-JK": "Āzād Jammūñ o Kashmīr", - "PK-BA": "Balōchistān", - "PK-GB": "Gilgit-Baltistān", - "PK-IS": "Islāmābād", - "PK-KP": "Khaībar Pakhtūnkhwā", - "PK-PB": "Panjāb", - "PK-SD": "Sindh", - "PW-002": "Aimeliik", - "PW-004": "Airai", - "PW-010": "Angaur", - "PW-050": "Hatohobei", - "PW-100": "Kayangel", - "PW-150": "Koror", - "PW-212": "Melekeok", - "PW-214": "Ngaraard", - "PW-218": "Ngarchelong", - "PW-222": "Ngardmau", - "PW-224": "Ngatpang", - "PW-226": "Ngchesar", - "PW-227": "Ngeremlengui", - "PW-228": "Ngiwal", - "PW-350": "Peleliu", - "PW-370": "Sonsorol", - "PS-BTH": "Bayt Laḩm", - "PS-DEB": "Dayr al Balaḩ", - "PS-GZA": "Ghazzah", - "PS-HBN": "Al Khalīl", - "PS-JEN": "Janīn", - "PS-JRH": "Arīḩā wal Aghwār", - "PS-JEM": "Al Quds", - "PS-KYS": "Khān Yūnis", - "PS-NBS": "Nāblus", - "PS-NGZ": "Shamāl Ghazzah", - "PS-QQA": "Qalqīlyah", - "PS-RFH": "Rafaḩ", - "PS-RBH": "Rām Allāh wal Bīrah", - "PS-SLT": "Salfīt", - "PS-TBS": "Ţūbās", - "PS-TKM": "Ţūlkarm", - "PA-1": "Bocas del Toro", - "PA-4": "Chiriquí", - "PA-2": "Coclé", - "PA-3": "Colón", - "PA-5": "Darién", - "PA-EM": "Emberá", - "PA-KY": "Guna Yala", - "PA-6": "Herrera", - "PA-7": "Los Santos", - "PA-NT": "Naso Tjër Di", - "PA-NB": "Ngäbe-Buglé", - "PA-8": "Panamá", - "PA-10": "Panamá Oeste", - "PA-9": "Veraguas", - "PG-NSB": "Bougainville", - "PG-CPM": "Central", - "PG-CPK": "Chimbu", - "PG-EBR": "East New Britain", - "PG-ESW": "East Sepik", - "PG-EHG": "Eastern Highlands", - "PG-EPW": "Enga", - "PG-GPK": "Gulf", - "PG-HLA": "Hela", - "PG-JWK": "Jiwaka", - "PG-MPM": "Madang", - "PG-MRL": "Manus", - "PG-MBA": "Milne Bay", - "PG-MPL": "Morobe", - "PG-NCD": "National Capital District", - "PG-NIK": "New Ireland", - "PG-NPP": "Northern", - "PG-SHM": "Southern Highlands", - "PG-WBK": "West New Britain", - "PG-SAN": "West Sepik", - "PG-WPD": "Western", - "PG-WHM": "Western Highlands", - "PY-16": "Alto Paraguay", - "PY-10": "Alto Paraná", - "PY-13": "Amambay", - "PY-ASU": "Asunción", - "PY-19": "Boquerón", - "PY-5": "Caaguazú", - "PY-6": "Caazapá", - "PY-14": "Canindeyú", - "PY-11": "Central", - "PY-1": "Concepción", - "PY-3": "Cordillera", - "PY-4": "Guairá", - "PY-7": "Itapúa", - "PY-8": "Misiones", - "PY-12": "Ñeembucú", - "PY-9": "Paraguarí", - "PY-15": "Presidente Hayes", - "PY-2": "San Pedro", - "PE-AMA": "Amazonas", - "PE-ANC": "Ancash", - "PE-APU": "Apurímac", - "PE-ARE": "Arequipa", - "PE-AYA": "Ayacucho", - "PE-CAJ": "Cajamarca", - "PE-CUS": "Cusco", - "PE-CAL": "El Callao", - "PE-HUV": "Huancavelica", - "PE-HUC": "Huánuco", - "PE-ICA": "Ica", - "PE-JUN": "Junín", - "PE-LAL": "La Libertad", - "PE-LAM": "Lambayeque", - "PE-LIM": "Lima", - "PE-LOR": "Loreto", - "PE-MDD": "Madre de Dios", - "PE-MOQ": "Moquegua", - "PE-LMA": "Municipalidad Metropolitana de Lima", - "PE-PAS": "Pasco", - "PE-PIU": "Piura", - "PE-PUN": "Puno", - "PE-SAM": "San Martín", - "PE-TAC": "Tacna", - "PE-TUM": "Tumbes", - "PE-UCA": "Ucayali", - "PH-14": "Autonomous Region in Muslim Mindanao[b]", - "PH-05": "Bicol", - "PH-02": "Cagayan Valley", - "PH-40": "Calabarzon", - "PH-13": "Caraga", - "PH-03": "Central Luzon", - "PH-07": "Central Visayas", - "PH-15": "Cordillera Administrative Region", - "PH-11": "Davao", - "PH-08": "Eastern Visayas", - "PH-01": "Ilocos", - "PH-41": "Mimaropa", - "PH-00": "National Capital Region", - "PH-10": "Northern Mindanao", - "PH-12": "Soccsksargen", - "PH-06": "Western Visayas", - "PH-09": "Zamboanga Peninsula", - "PL-02": "Dolnośląskie", - "PL-04": "Kujawsko-Pomorskie", - "PL-06": "Lubelskie", - "PL-08": "Lubuskie", - "PL-10": "Łódzkie", - "PL-12": "Małopolskie", - "PL-14": "Mazowieckie", - "PL-16": "Opolskie", - "PL-18": "Podkarpackie", - "PL-20": "Podlaskie", - "PL-22": "Pomorskie", - "PL-24": "Śląskie", - "PL-26": "Świętokrzyskie", - "PL-28": "Warmińsko-Mazurskie", - "PL-30": "Wielkopolskie", - "PL-32": "Zachodniopomorskie", - "PT-01": "Aveiro", - "PT-02": "Beja", - "PT-03": "Braga", - "PT-04": "Bragança", - "PT-05": "Castelo Branco", - "PT-06": "Coimbra", - "PT-07": "Évora", - "PT-08": "Faro", - "PT-09": "Guarda", - "PT-10": "Leiria", - "PT-11": "Lisboa", - "PT-12": "Portalegre", - "PT-13": "Porto", - "PT-30": "Região Autónoma da Madeira", - "PT-20": "Região Autónoma dos Açores", - "PT-14": "Santarém", - "PT-15": "Setúbal", - "PT-16": "Viana do Castelo", - "PT-17": "Vila Real", - "PT-18": "Viseu", - "QA-DA": "Ad Dawḩah", - "QA-KH": "Al Khawr wa adh Dhakhīrah", - "QA-WA": "Al Wakrah", - "QA-RA": "Ar Rayyān", - "QA-MS": "Ash Shamāl", - "QA-SH": "Ash Shīḩānīyah", - "QA-ZA": "Az̧ Z̧a‘āyin", - "QA-US": "Umm Şalāl", - "RO-AB": "Alba", - "RO-AR": "Arad", - "RO-AG": "Argeș", - "RO-BC": "Bacău", - "RO-BH": "Bihor", - "RO-BN": "Bistrița-Năsăud", - "RO-BT": "Botoșani", - "RO-BV": "Brașov", - "RO-BR": "Brăila", - "RO-B": "București", - "RO-BZ": "Buzău", - "RO-CS": "Caraș-Severin", - "RO-CL": "Călărași", - "RO-CJ": "Cluj", - "RO-CT": "Constanța", - "RO-CV": "Covasna", - "RO-DB": "Dâmbovița", - "RO-DJ": "Dolj", - "RO-GL": "Galați", - "RO-GR": "Giurgiu", - "RO-GJ": "Gorj", - "RO-HR": "Harghita", - "RO-HD": "Hunedoara", - "RO-IL": "Ialomița", - "RO-IS": "Iași", - "RO-IF": "Ilfov", - "RO-MM": "Maramureș", - "RO-MH": "Mehedinți", - "RO-MS": "Mureș", - "RO-NT": "Neamț", - "RO-OT": "Olt", - "RO-PH": "Prahova", - "RO-SM": "Satu Mare", - "RO-SJ": "Sălaj", - "RO-SB": "Sibiu", - "RO-SV": "Suceava", - "RO-TR": "Teleorman", - "RO-TM": "Timiș", - "RO-TL": "Tulcea", - "RO-VS": "Vaslui", - "RO-VL": "Vâlcea", - "RO-VN": "Vrancea", - "RU-AD": "Adygeya", - "RU-AL": "Altay", - "RU-ALT": "Altayskiy kray", - "RU-AMU": "Amurskaya oblast'", - "RU-ARK": "Arkhangel'skaya oblast'", - "RU-AST": "Astrakhanskaya oblast'", - "RU-BA": "Bashkortostan", - "RU-BEL": "Belgorodskaya oblast'", - "RU-BRY": "Bryanskaya oblast'", - "RU-BU": "Buryatiya", - "RU-CE": "Chechenskaya Respublika", - "RU-CHE": "Chelyabinskaya oblast'", - "RU-CHU": "Chukotskiy avtonomnyy okrug", - "RU-CU": "Chuvashskaya Respublika", - "RU-DA": "Dagestan", - "RU-IN": "Ingushetiya", - "RU-IRK": "Irkutskaya oblast'", - "RU-IVA": "Ivanovskaya oblast'", - "RU-KB": "Kabardino-BalkarskayaRespublika", - "RU-KGD": "Kaliningradskaya oblast'", - "RU-KL": "Kalmykiya", - "RU-KLU": "Kaluzhskaya oblast'", - "RU-KAM": "Kamchatskiy kray", - "RU-KC": "Karachayevo-CherkesskayaRespublika", - "RU-KR": "Kareliya", - "RU-KEM": "Kemerovskaya oblast'", - "RU-KHA": "Khabarovskiy kray", - "RU-KK": "Khakasiya", - "RU-KHM": "Khanty-Mansiyskiyavtonomnyy okrug", - "RU-KIR": "Kirovskaya oblast'", - "RU-KO": "Komi", - "RU-KOS": "Kostromskaya oblast'", - "RU-KDA": "Krasnodarskiy kray", - "RU-KYA": "Krasnoyarskiy kray", - "RU-KGN": "Kurganskaya oblast'", - "RU-KRS": "Kurskaya oblast'", - "RU-LEN": "Leningradskayaoblast'", - "RU-LIP": "Lipetskaya oblast'", - "RU-MAG": "Magadanskaya oblast'", - "RU-ME": "Mariy El", - "RU-MO": "Mordoviya", - "RU-MOS": "Moskovskaya oblast'", - "RU-MOW": "Moskva", - "RU-MUR": "Murmanskaya oblast'", - "RU-NEN": "Nenetskiyavtonomnyy okrug", - "RU-NIZ": "Nizhegorodskaya oblast'", - "RU-NGR": "Novgorodskaya oblast'", - "RU-NVS": "Novosibirskayaoblast'", - "RU-OMS": "Omskaya oblast'", - "RU-ORE": "Orenburgskaya oblast'", - "RU-ORL": "Orlovskaya oblast'", - "RU-PNZ": "Penzenskaya oblast'", - "RU-PER": "Permskiy kray", - "RU-PRI": "Primorskiy kray", - "RU-PSK": "Pskovskaya oblast'", - "RU-ROS": "Rostovskaya oblast'", - "RU-RYA": "Ryazanskaya oblast'", - "RU-SA": "Saha", - "RU-SAK": "Sakhalinskaya oblast'", - "RU-SAM": "Samarskaya oblast'", - "RU-SPE": "Sankt-Peterburg", - "RU-SAR": "Saratovskaya oblast'", - "RU-SE": "Severnaya Osetiya", - "RU-SMO": "Smolenskaya oblast'", - "RU-STA": "Stavropol'skiy kray", - "RU-SVE": "Sverdlovskaya oblast'", - "RU-TAM": "Tambovskaya oblast'", - "RU-TA": "Tatarstan", - "RU-TOM": "Tomskaya oblast'", - "RU-TUL": "Tul'skaya oblast'", - "RU-TVE": "Tverskaya oblast'", - "RU-TYU": "Tyumenskaya oblast'", - "RU-TY": "Tyva", - "RU-UD": "Udmurtskaya Respublika", - "RU-ULY": "Ul'yanovskaya oblast'", - "RU-VLA": "Vladimirskayaoblast'", - "RU-VGG": "Volgogradskayaoblast'", - "RU-VLG": "Vologodskaya oblast'", - "RU-VOR": "Voronezhskaya oblast'", - "RU-YAN": "Yamalo-Nenetskiyavtonomnyy okrug", - "RU-YAR": "Yaroslavskaya oblast'", - "RU-YEV": "Yevreyskaya avtonomnaya oblast'", - "RU-ZAB": "Zabaykal'skiy kray", - "RW-01": "City of Kigali", - "RW-02": "Eastern", - "RW-03": "Northern", - "RW-05": "Southern", - "RW-04": "Western", - "SH-AC": "Ascension", - "SH-HL": "Saint Helena", - "SH-TA": "Tristan da Cunha", - "KN-K": "Saint Kitts", - "KN-N": "Nevis", - "LC-01": "Anse la Raye", - "LC-12": "Canaries", - "LC-02": "Castries", - "LC-03": "Choiseul", - "LC-05": "Dennery", - "LC-06": "Gros Islet", - "LC-07": "Laborie", - "LC-08": "Micoud", - "LC-10": "Soufrière", - "LC-11": "Vieux Fort", - "VC-01": "Charlotte", - "VC-06": "Grenadines", - "VC-02": "Saint Andrew", - "VC-03": "Saint David", - "VC-04": "Saint George", - "VC-05": "Saint Patrick", - "WS-AA": "A'ana", - "WS-AL": "Aiga-i-le-Tai", - "WS-AT": "Atua", - "WS-FA": "Fa'asaleleaga", - "WS-GE": "Gaga'emauga", - "WS-GI": "Gagaifomauga", - "WS-PA": "Palauli", - "WS-SA": "Satupa'itea", - "WS-TU": "Tuamasaga", - "WS-VF": "Va'a-o-Fonoti", - "WS-VS": "Vaisigano", - "SM-01": "Acquaviva", - "SM-06": "Borgo Maggiore", - "SM-02": "Chiesanuova", - "SM-07": "Città di San Marino", - "SM-03": "Domagnano", - "SM-04": "Faetano", - "SM-05": "Fiorentino", - "SM-08": "Montegiardino", - "SM-09": "Serravalle", - "ST-01": "Água Grande", - "ST-02": "Cantagalo", - "ST-03": "Caué", - "ST-04": "Lembá", - "ST-05": "Lobata", - "ST-06": "Mé-Zóchi", - "ST-P": "Príncipe", - "SA-14": "'Asīr", - "SA-11": "Al Bāḩah", - "SA-08": "Al Ḩudūd ash Shamālīyah", - "SA-12": "Al Jawf", - "SA-03": "Al Madīnah al Munawwarah", - "SA-05": "Al Qaşīm", - "SA-01": "Ar Riyāḑ", - "SA-04": "Ash Sharqīyah", - "SA-06": "Ḩā'il", - "SA-09": "Jāzān", - "SA-02": "Makkah al Mukarramah", - "SA-10": "Najrān", - "SA-07": "Tabūk", - "SN-DK": "Dakar", - "SN-DB": "Diourbel", - "SN-FK": "Fatick", - "SN-KA": "Kaffrine", - "SN-KL": "Kaolack", - "SN-KE": "Kédougou", - "SN-KD": "Kolda", - "SN-LG": "Louga", - "SN-MT": "Matam", - "SN-SL": "Saint-Louis", - "SN-SE": "Sédhiou", - "SN-TC": "Tambacounda", - "SN-TH": "Thiès", - "SN-ZG": "Ziguinchor", - "SC-01": "Anse aux Pins", - "SC-02": "Anse Boileau", - "SC-03": "Anse Etoile", - "SC-05": "Anse Royale", - "SC-04": "Au Cap", - "SC-06": "Baie Lazare", - "SC-07": "Baie Sainte Anne", - "SC-08": "Beau Vallon", - "SC-09": "Bel Air", - "SC-10": "Bel Ombre", - "SC-11": "Cascade", - "SC-16": "English River", - "SC-12": "Glacis", - "SC-13": "Grand Anse Mahe", - "SC-14": "Grand Anse Praslin", - "SC-26": "Ile Perseverance I", - "SC-27": "Ile Perseverance II", - "SC-15": "La Digue", - "SC-24": "Les Mamelles", - "SC-17": "Mont Buxton", - "SC-18": "Mont Fleuri", - "SC-19": "Plaisance", - "SC-20": "Pointe Larue", - "SC-21": "Port Glaud", - "SC-25": "Roche Caiman", - "SC-22": "Saint Louis", - "SC-23": "Takamaka", - "SL-E": "Eastern", - "SL-NW": "North Western", - "SL-N": "Northern", - "SL-S": "Southern", - "SL-W": "Western Area", - "SG-01": "Central Singapore", - "SG-02": "North East", - "SG-03": "North West", - "SG-04": "South East", - "SG-05": "South West", - "SK-BC": "Banskobystrický kraj", - "SK-BL": "Bratislavský kraj", - "SK-KI": "Košický kraj", - "SK-NI": "Nitriansky kraj", - "SK-PV": "Prešovský kraj", - "SK-TC": "Trenčiansky kraj", - "SK-TA": "Trnavský kraj", - "SK-ZI": "Žilinský kraj", - "SI-001": "Ajdovščina", - "SI-213": "Ankaran", - "SI-195": "Apače", - "SI-002": "Beltinci", - "SI-148": "Benedikt", - "SI-149": "Bistrica ob Sotli", - "SI-003": "Bled", - "SI-150": "Bloke", - "SI-004": "Bohinj", - "SI-005": "Borovnica", - "SI-006": "Bovec", - "SI-151": "Braslovče", - "SI-007": "Brda", - "SI-008": "Brezovica", - "SI-009": "Brežice", - "SI-152": "Cankova", - "SI-011": "Celje", - "SI-012": "Cerklje na Gorenjskem", - "SI-013": "Cerknica", - "SI-014": "Cerkno", - "SI-153": "Cerkvenjak", - "SI-196": "Cirkulane", - "SI-015": "Črenšovci", - "SI-016": "Črna na Koroškem", - "SI-017": "Črnomelj", - "SI-018": "Destrnik", - "SI-019": "Divača", - "SI-154": "Dobje", - "SI-020": "Dobrepolje", - "SI-155": "Dobrna", - "SI-021": "Dobrova-Polhov Gradec", - "SI-156": "Dobrovnik", - "SI-022": "Dol pri Ljubljani", - "SI-157": "Dolenjske Toplice", - "SI-023": "Domžale", - "SI-024": "Dornava", - "SI-025": "Dravograd", - "SI-026": "Duplek", - "SI-027": "Gorenja vas-Poljane", - "SI-028": "Gorišnica", - "SI-207": "Gorje", - "SI-029": "Gornja Radgona", - "SI-030": "Gornji Grad", - "SI-031": "Gornji Petrovci", - "SI-158": "Grad", - "SI-032": "Grosuplje", - "SI-159": "Hajdina", - "SI-160": "Hoče-Slivnica", - "SI-161": "Hodoš", - "SI-162": "Horjul", - "SI-034": "Hrastnik", - "SI-035": "Hrpelje-Kozina", - "SI-036": "Idrija", - "SI-037": "Ig", - "SI-038": "Ilirska Bistrica", - "SI-039": "Ivančna Gorica", - "SI-040": "Izola", - "SI-041": "Jesenice", - "SI-163": "Jezersko", - "SI-042": "Juršinci", - "SI-043": "Kamnik", - "SI-044": "Kanal ob Soči", - "SI-045": "Kidričevo", - "SI-046": "Kobarid", - "SI-047": "Kobilje", - "SI-048": "Kočevje", - "SI-049": "Komen", - "SI-164": "Komenda", - "SI-050": "Koper", - "SI-197": "Kostanjevica na Krki", - "SI-165": "Kostel", - "SI-051": "Kozje", - "SI-052": "Kranj", - "SI-053": "Kranjska Gora", - "SI-166": "Križevci", - "SI-054": "Krško", - "SI-055": "Kungota", - "SI-056": "Kuzma", - "SI-057": "Laško", - "SI-058": "Lenart", - "SI-059": "Lendava", - "SI-060": "Litija", - "SI-061": "Ljubljana", - "SI-062": "Ljubno", - "SI-063": "Ljutomer", - "SI-208": "Log-Dragomer", - "SI-064": "Logatec", - "SI-065": "Loška dolina", - "SI-066": "Loški Potok", - "SI-167": "Lovrenc na Pohorju", - "SI-067": "Luče", - "SI-068": "Lukovica", - "SI-069": "Majšperk", - "SI-198": "Makole", - "SI-070": "Maribor", - "SI-168": "Markovci", - "SI-071": "Medvode", - "SI-072": "Mengeš", - "SI-073": "Metlika", - "SI-074": "Mežica", - "SI-169": "Miklavž na Dravskem polju", - "SI-075": "Miren-Kostanjevica", - "SI-212": "Mirna", - "SI-170": "Mirna Peč", - "SI-076": "Mislinja", - "SI-199": "Mokronog-Trebelno", - "SI-077": "Moravče", - "SI-078": "Moravske Toplice", - "SI-079": "Mozirje", - "SI-080": "Murska Sobota", - "SI-081": "Muta", - "SI-082": "Naklo", - "SI-083": "Nazarje", - "SI-084": "Nova Gorica", - "SI-085": "Novo Mesto", - "SI-086": "Odranci", - "SI-171": "Oplotnica", - "SI-087": "Ormož", - "SI-088": "Osilnica", - "SI-089": "Pesnica", - "SI-090": "Piran", - "SI-091": "Pivka", - "SI-092": "Podčetrtek", - "SI-172": "Podlehnik", - "SI-093": "Podvelka", - "SI-200": "Poljčane", - "SI-173": "Polzela", - "SI-094": "Postojna", - "SI-174": "Prebold", - "SI-095": "Preddvor", - "SI-175": "Prevalje", - "SI-096": "Ptuj", - "SI-097": "Puconci", - "SI-098": "Rače-Fram", - "SI-099": "Radeče", - "SI-100": "Radenci", - "SI-101": "Radlje ob Dravi", - "SI-102": "Radovljica", - "SI-103": "Ravne na Koroškem", - "SI-176": "Razkrižje", - "SI-209": "Rečica ob Savinji", - "SI-201": "Renče-Vogrsko", - "SI-104": "Ribnica", - "SI-177": "Ribnica na Pohorju", - "SI-106": "Rogaška Slatina", - "SI-105": "Rogašovci", - "SI-107": "Rogatec", - "SI-108": "Ruše", - "SI-178": "Selnica ob Dravi", - "SI-109": "Semič", - "SI-110": "Sevnica", - "SI-111": "Sežana", - "SI-112": "Slovenj Gradec", - "SI-113": "Slovenska Bistrica", - "SI-114": "Slovenske Konjice", - "SI-179": "Sodražica", - "SI-180": "Solčava", - "SI-202": "Središče ob Dravi", - "SI-115": "Starše", - "SI-203": "Straža", - "SI-181": "Sveta Ana", - "SI-204": "Sveta Trojica v Slovenskih goricah", - "SI-182": "Sveti Andraž v Slovenskih goricah", - "SI-116": "Sveti Jurij ob Ščavnici", - "SI-210": "Sveti Jurij v Slovenskih goricah", - "SI-205": "Sveti Tomaž", - "SI-033": "Šalovci", - "SI-183": "Šempeter-Vrtojba", - "SI-117": "Šenčur", - "SI-118": "Šentilj", - "SI-119": "Šentjernej", - "SI-120": "Šentjur", - "SI-211": "Šentrupert", - "SI-121": "Škocjan", - "SI-122": "Škofja Loka", - "SI-123": "Škofljica", - "SI-124": "Šmarje pri Jelšah", - "SI-206": "Šmarješke Toplice", - "SI-125": "Šmartno ob Paki", - "SI-194": "Šmartno pri Litiji", - "SI-126": "Šoštanj", - "SI-127": "Štore", - "SI-184": "Tabor", - "SI-010": "Tišina", - "SI-128": "Tolmin", - "SI-129": "Trbovlje", - "SI-130": "Trebnje", - "SI-185": "Trnovska Vas", - "SI-186": "Trzin", - "SI-131": "Tržič", - "SI-132": "Turnišče", - "SI-133": "Velenje", - "SI-187": "Velika Polana", - "SI-134": "Velike Lašče", - "SI-188": "Veržej", - "SI-135": "Videm", - "SI-136": "Vipava", - "SI-137": "Vitanje", - "SI-138": "Vodice", - "SI-139": "Vojnik", - "SI-189": "Vransko", - "SI-140": "Vrhnika", - "SI-141": "Vuzenica", - "SI-142": "Zagorje ob Savi", - "SI-143": "Zavrč", - "SI-144": "Zreče", - "SI-190": "Žalec", - "SI-146": "Železniki", - "SI-191": "Žetale", - "SI-147": "Žiri", - "SI-192": "Žirovnica", - "SI-193": "Žužemberk", - "SB-CT": "Capital Territory", - "SB-CE": "Central", - "SB-CH": "Choiseul", - "SB-GU": "Guadalcanal", - "SB-IS": "Isabel", - "SB-MK": "Makira-Ulawa", - "SB-ML": "Malaita", - "SB-RB": "Rennell and Bellona", - "SB-TE": "Temotu", - "SB-WE": "Western", - "SO-AW": "Awdal", - "SO-BK": "Bakool", - "SO-BN": "Banaadir", - "SO-BR": "Bari", - "SO-BY": "Bay", - "SO-GA": "Galguduud", - "SO-GE": "Gedo", - "SO-HI": "Hiiraan", - "SO-JD": "Jubbada Dhexe", - "SO-JH": "Jubbada Hoose", - "SO-MU": "Mudug", - "SO-NU": "Nugaal", - "SO-SA": "Sanaag", - "SO-SD": "Shabeellaha Dhexe", - "SO-SH": "Shabeellaha Hoose", - "SO-SO": "Sool", - "SO-TO": "Togdheer", - "SO-WO": "Woqooyi Galbeed", - "ZA-EC": "Eastern Cape", - "ZA-FS": "Free State", - "ZA-GP": "Gauteng", - "ZA-KZN": "Kwazulu-Natal", - "ZA-LP": "Limpopo", - "ZA-MP": "Mpumalanga", - "ZA-NW": "North-West", - "ZA-NC": "Northern Cape", - "ZA-WC": "Western Cape", - "ES-AN": "Andalucía", - "ES-AR": "Aragón", - "ES-AS": "Asturias", - "ES-CN": "Canarias", - "ES-CB": "Cantabria", - "ES-CL": "Castilla y León", - "ES-CM": "Castilla-La Mancha", - "ES-CT": "Catalunya", - "ES-CE": "Ceuta", - "ES-EX": "Extremadura", - "ES-GA": "Galicia", - "ES-IB": "Illes Balears", - "ES-RI": "La Rioja", - "ES-MD": "Madrid", - "ES-ML": "Melilla", - "ES-MC": "Murcia", - "ES-NC": "Navarra", - "ES-PV": "País Vasco", - "ES-VC": "Valenciana", - "LK-1": "Basnāhira paḷāta", - "LK-3": "Dakuṇu paḷāta", - "LK-2": "Madhyama paḷāta", - "LK-5": "Næ̆gĕnahira paḷāta", - "LK-9": "Sabaragamuva paḷāta", - "LK-4": "Uturu paḷāta", - "LK-7": "Uturumæ̆da paḷāta", - "LK-8": "Ūva paḷāta", - "LK-6": "Vayamba paḷāta", - "SD-RS": "Al Baḩr al Aḩmar", - "SD-GZ": "Al Jazīrah", - "SD-KH": "Al Kharţūm", - "SD-GD": "Al Qaḑārif", - "SD-NW": "An Nīl al Abyaḑ", - "SD-NB": "An Nīl al Azraq", - "SD-NO": "Ash Shamālīyah", - "SD-DW": "Gharb Dārfūr", - "SD-GK": "Gharb Kurdufān", - "SD-DS": "Janūb Dārfūr", - "SD-KS": "Janūb Kurdufān", - "SD-KA": "Kassalā", - "SD-NR": "Nahr an Nīl", - "SD-DN": "Shamāl Dārfūr", - "SD-KN": "Shamāl Kurdufān", - "SD-DE": "Sharq Dārfūr", - "SD-SI": "Sinnār", - "SD-DC": "Wasaţ Dārfūr", - "SR-BR": "Brokopondo", - "SR-CM": "Commewijne", - "SR-CR": "Coronie", - "SR-MA": "Marowijne", - "SR-NI": "Nickerie", - "SR-PR": "Para", - "SR-PM": "Paramaribo", - "SR-SA": "Saramacca", - "SR-SI": "Sipaliwini", - "SR-WA": "Wanica", - "SZ-HH": "Hhohho", - "SZ-LU": "Lubombo", - "SZ-MA": "Manzini", - "SZ-SH": "Shiselweni", - "SE-K": "Blekinge län", - "SE-W": "Dalarnas län", - "SE-I": "Gotlands län", - "SE-X": "Gävleborgs län", - "SE-N": "Hallands län", - "SE-Z": "Jämtlands län", - "SE-F": "Jönköpings län", - "SE-H": "Kalmar län", - "SE-G": "Kronobergs län", - "SE-BD": "Norrbottens län", - "SE-M": "Skåne län", - "SE-AB": "Stockholms län", - "SE-D": "Södermanlands län", - "SE-C": "Uppsala län", - "SE-S": "Värmlands län", - "SE-AC": "Västerbottens län", - "SE-Y": "Västernorrlands län", - "SE-U": "Västmanlands län", - "SE-O": "Västra Götalands län", - "SE-T": "Örebro län", - "SE-E": "Östergötlands län", - "CH-AG": "Aargau", - "CH-AR": "Appenzell Ausserrhoden", - "CH-AI": "Appenzell Innerrhoden", - "CH-BL": "Basel-Landschaft", - "CH-BS": "Basel-Stadt", - "CH-BE": "Bern", - "CH-FR": "Fribourg", - "CH-GE": "Genève", - "CH-GL": "Glarus", - "CH-GR": "Graubünden", - "CH-JU": "Jura", - "CH-LU": "Luzern", - "CH-NE": "Neuchâtel", - "CH-NW": "Nidwalden", - "CH-OW": "Obwalden", - "CH-SG": "Sankt Gallen", - "CH-SH": "Schaffhausen", - "CH-SZ": "Schwyz", - "CH-SO": "Solothurn", - "CH-TG": "Thurgau", - "CH-TI": "Ticino", - "CH-UR": "Uri", - "CH-VS": "Valais", - "CH-VD": "Vaud", - "CH-ZG": "Zug", - "CH-ZH": "Zürich", - "SY-HA": "Al Ḩasakah", - "SY-LA": "Al Lādhiqīyah", - "SY-QU": "Al Qunayţirah", - "SY-RA": "Ar Raqqah", - "SY-SU": "As Suwaydā'", - "SY-DR": "Dar'ā", - "SY-DY": "Dayr az Zawr", - "SY-DI": "Dimashq", - "SY-HL": "Ḩalab", - "SY-HM": "Ḩamāh", - "SY-HI": "Ḩimş", - "SY-ID": "Idlib", - "SY-RD": "Rīf Dimashq", - "SY-TA": "Ţarţūs", - "TW-CHA": "Changhua", - "TW-CYI": "Chiayi", - "TW-CYQ": "Chiayi", - "TW-HSZ": "Hsinchu", - "TW-HSQ": "Hsinchu", - "TW-HUA": "Hualien", - "TW-KHH": "Kaohsiung", - "TW-KEE": "Keelung", - "TW-KIN": "Kinmen", - "TW-LIE": "Lienchiang", - "TW-MIA": "Miaoli", - "TW-NAN": "Nantou", - "TW-NWT": "New Taipei", - "TW-PEN": "Penghu", - "TW-PIF": "Pingtung", - "TW-TXG": "Taichung", - "TW-TNN": "Tainan", - "TW-TPE": "Taipei", - "TW-TTT": "Taitung", - "TW-TAO": "Taoyuan", - "TW-ILA": "Yilan", - "TW-YUN": "Yunlin", - "TJ-DU": "Dushanbe", - "TJ-KT": "Khatlon", - "TJ-GB": "Kŭhistoni Badakhshon", - "TJ-RA": "nohiyahoi tobei jumhurí", - "TJ-SU": "Sughd", - "TZ-01": "Arusha", - "TZ-02": "Dar es Salaam", - "TZ-03": "Dodoma", - "TZ-27": "Geita", - "TZ-04": "Iringa", - "TZ-05": "Kagera", - "TZ-06": "Kaskazini Pemba", - "TZ-07": "Kaskazini Unguja", - "TZ-28": "Katavi", - "TZ-08": "Kigoma", - "TZ-09": "Kilimanjaro", - "TZ-10": "Kusini Pemba", - "TZ-11": "Kusini Unguja", - "TZ-12": "Lindi", - "TZ-26": "Manyara", - "TZ-13": "Mara", - "TZ-14": "Mbeya", - "TZ-15": "Mjini Magharibi", - "TZ-16": "Morogoro", - "TZ-17": "Mtwara", - "TZ-18": "Mwanza", - "TZ-29": "Njombe", - "TZ-19": "Pwani", - "TZ-20": "Rukwa", - "TZ-21": "Ruvuma", - "TZ-22": "Shinyanga", - "TZ-30": "Simiyu", - "TZ-23": "Singida", - "TZ-31": "Songwe", - "TZ-24": "Tabora", - "TZ-25": "Tanga", - "TH-37": "Amnat Charoen", - "TH-15": "Ang Thong", - "TH-38": "Bueng Kan", - "TH-31": "Buri Ram", - "TH-24": "Chachoengsao", - "TH-18": "Chai Nat", - "TH-36": "Chaiyaphum", - "TH-22": "Chanthaburi", - "TH-50": "Chiang Mai", - "TH-57": "Chiang Rai", - "TH-20": "Chon Buri", - "TH-86": "Chumphon", - "TH-46": "Kalasin", - "TH-62": "Kamphaeng Phet", - "TH-71": "Kanchanaburi", - "TH-40": "Khon Kaen", - "TH-81": "Krabi", - "TH-10": "Bangkok", - "TH-52": "Lampang", - "TH-51": "Lamphun", - "TH-42": "Loei", - "TH-16": "Lop Buri", - "TH-58": "Mae Hong Son", - "TH-44": "Maha Sarakham", - "TH-49": "Mukdahan", - "TH-26": "Nakhon Nayok", - "TH-73": "Nakhon Pathom", - "TH-48": "Nakhon Phanom", - "TH-30": "Nakhon Ratchasima", - "TH-60": "Nakhon Sawan", - "TH-80": "Nakhon Si Thammarat", - "TH-55": "Nan", - "TH-96": "Narathiwat", - "TH-39": "Nong Bua Lam Phu", - "TH-43": "Nong Khai", - "TH-12": "Nonthaburi", - "TH-13": "Pathum Thani", - "TH-94": "Pattani", - "TH-82": "Phangnga", - "TH-93": "Phatthalung", - "TH-S": "Phatthaya", - "TH-56": "Phayao", - "TH-67": "Phetchabun", - "TH-76": "Phetchaburi", - "TH-66": "Phichit", - "TH-65": "Phitsanulok", - "TH-14": "Phra Nakhon Si Ayutthaya", - "TH-54": "Phrae", - "TH-83": "Phuket", - "TH-25": "Prachin Buri", - "TH-77": "Prachuap Khiri Khan", - "TH-85": "Ranong", - "TH-70": "Ratchaburi", - "TH-21": "Rayong", - "TH-45": "Roi Et", - "TH-27": "Sa Kaeo", - "TH-47": "Sakon Nakhon", - "TH-11": "Samut Prakan", - "TH-74": "Samut Sakhon", - "TH-75": "Samut Songkhram", - "TH-19": "Saraburi", - "TH-91": "Satun", - "TH-33": "Si Sa Ket", - "TH-17": "Sing Buri", - "TH-90": "Songkhla", - "TH-64": "Sukhothai", - "TH-72": "Suphan Buri", - "TH-84": "Surat Thani", - "TH-32": "Surin", - "TH-63": "Tak", - "TH-92": "Trang", - "TH-23": "Trat", - "TH-34": "Ubon Ratchathani", - "TH-41": "Udon Thani", - "TH-61": "Uthai Thani", - "TH-53": "Uttaradit", - "TH-95": "Yala", - "TH-35": "Yasothon", - "TL-AL": "Aileu", - "TL-AN": "Ainaro", - "TL-BA": "Baucau", - "TL-BO": "Bobonaro", - "TL-CO": "Cova Lima", - "TL-DI": "Díli", - "TL-ER": "Ermera", - "TL-LA": "Lautém", - "TL-LI": "Liquiça", - "TL-MT": "Manatuto", - "TL-MF": "Manufahi", - "TL-OE": "Oé-Cusse Ambeno", - "TL-VI": "Viqueque", - "TG-C": "Centrale", - "TG-K": "Kara", - "TG-M": "Maritime", - "TG-P": "Plateaux", - "TG-S": "Savanes", - "TO-01": "'Eua", - "TO-02": "Ha'apai", - "TO-03": "Niuas", - "TO-04": "Tongatapu", - "TO-05": "Vava'u", - "TT-ARI": "Arima", - "TT-CHA": "Chaguanas", - "TT-CTT": "Couva-Tabaquite-Talparo", - "TT-DMN": "Diego Martin", - "TT-MRC": "Mayaro-Rio Claro", - "TT-PED": "Penal-Debe", - "TT-POS": "Port of Spain", - "TT-PRT": "Princes Town", - "TT-PTF": "Point Fortin", - "TT-SFO": "San Fernando", - "TT-SGE": "Sangre Grande", - "TT-SIP": "Siparia", - "TT-SJL": "San Juan-Laventille", - "TT-TOB": "Tobago", - "TT-TUP": "Tunapuna-Piarco", - "TN-31": "Béja", - "TN-13": "Ben Arous", - "TN-23": "Bizerte", - "TN-81": "Gabès", - "TN-71": "Gafsa", - "TN-32": "Jendouba", - "TN-41": "Kairouan", - "TN-42": "Kasserine", - "TN-73": "Kébili", - "TN-12": "L'Ariana", - "TN-14": "La Manouba", - "TN-33": "Le Kef", - "TN-53": "Mahdia", - "TN-82": "Médenine", - "TN-52": "Monastir", - "TN-21": "Nabeul", - "TN-61": "Sfax", - "TN-43": "Sidi Bouzid", - "TN-34": "Siliana", - "TN-51": "Sousse", - "TN-83": "Tataouine", - "TN-72": "Tozeur", - "TN-11": "Tunis", - "TN-22": "Zaghouan", - "TR-01": "Adana", - "TR-02": "Adıyaman", - "TR-03": "Afyonkarahisar", - "TR-04": "Ağrı", - "TR-68": "Aksaray", - "TR-05": "Amasya", - "TR-06": "Ankara", - "TR-07": "Antalya", - "TR-75": "Ardahan", - "TR-08": "Artvin", - "TR-09": "Aydın", - "TR-10": "Balıkesir", - "TR-74": "Bartın", - "TR-72": "Batman", - "TR-69": "Bayburt", - "TR-11": "Bilecik", - "TR-12": "Bingöl", - "TR-13": "Bitlis", - "TR-14": "Bolu", - "TR-15": "Burdur", - "TR-16": "Bursa", - "TR-17": "Çanakkale", - "TR-18": "Çankırı", - "TR-19": "Çorum", - "TR-20": "Denizli", - "TR-21": "Diyarbakır", - "TR-81": "Düzce", - "TR-22": "Edirne", - "TR-23": "Elazığ", - "TR-24": "Erzincan", - "TR-25": "Erzurum", - "TR-26": "Eskişehir", - "TR-27": "Gaziantep", - "TR-28": "Giresun", - "TR-29": "Gümüşhane", - "TR-30": "Hakkâri", - "TR-31": "Hatay", - "TR-76": "Iğdır", - "TR-32": "Isparta", - "TR-34": "İstanbul", - "TR-35": "İzmir", - "TR-46": "Kahramanmaraş", - "TR-78": "Karabük", - "TR-70": "Karaman", - "TR-36": "Kars", - "TR-37": "Kastamonu", - "TR-38": "Kayseri", - "TR-71": "Kırıkkale", - "TR-39": "Kırklareli", - "TR-40": "Kırşehir", - "TR-79": "Kilis", - "TR-41": "Kocaeli", - "TR-42": "Konya", - "TR-43": "Kütahya", - "TR-44": "Malatya", - "TR-45": "Manisa", - "TR-47": "Mardin", - "TR-33": "Mersin", - "TR-48": "Muğla", - "TR-49": "Muş", - "TR-50": "Nevşehir", - "TR-51": "Niğde", - "TR-52": "Ordu", - "TR-80": "Osmaniye", - "TR-53": "Rize", - "TR-54": "Sakarya", - "TR-55": "Samsun", - "TR-56": "Siirt", - "TR-57": "Sinop", - "TR-58": "Sivas", - "TR-63": "Şanlıurfa", - "TR-73": "Şırnak", - "TR-59": "Tekirdağ", - "TR-60": "Tokat", - "TR-61": "Trabzon", - "TR-62": "Tunceli", - "TR-64": "Uşak", - "TR-65": "Van", - "TR-77": "Yalova", - "TR-66": "Yozgat", - "TR-67": "Zonguldak", - "TM-A": "Ahal", - "TM-S": "Aşgabat", - "TM-B": "Balkan", - "TM-D": "Daşoguz", - "TM-L": "Lebap", - "TM-M": "Mary", - "TV-FUN": "Funafuti", - "TV-NMG": "Nanumaga", - "TV-NMA": "Nanumea", - "TV-NIT": "Niutao", - "TV-NUI": "Nui", - "TV-NKF": "Nukufetau", - "TV-NKL": "Nukulaelae", - "TV-VAI": "Vaitupu", - "UG-C": "Central", - "UG-E": "Eastern", - "UG-N": "Northern", - "UG-W": "Western", - "UA-43": "Avtonomna Respublika Krym", - "UA-71": "Cherkaska oblast", - "UA-74": "Chernihivska oblast", - "UA-77": "Chernivetska oblast", - "UA-12": "Dnipropetrovska oblast", - "UA-14": "Donetska oblast", - "UA-26": "Ivano-Frankivska oblast", - "UA-63": "Kharkivska oblast", - "UA-65": "Khersonska oblast", - "UA-68": "Khmelnytska oblast", - "UA-35": "Kirovohradska oblast", - "UA-30": "Kyiv", - "UA-32": "Kyivska oblast", - "UA-09": "Luhanska oblast", - "UA-46": "Lvivska oblast", - "UA-48": "Mykolaivska oblast", - "UA-51": "Odeska oblast", - "UA-53": "Poltavska oblast", - "UA-56": "Rivnenska oblast", - "UA-40": "Sevastopol", - "UA-59": "Sumska oblast", - "UA-61": "Ternopilska oblast", - "UA-05": "Vinnytska oblast", - "UA-07": "Volynska oblast", - "UA-21": "Zakarpatska oblast", - "UA-23": "Zaporizka oblast", - "UA-18": "Zhytomyrska oblast", - "AE-AJ": "‘Ajmān", - "AE-AZ": "Abū Z̧aby", - "AE-FU": "Al Fujayrah", - "AE-SH": "Ash Shāriqah", - "AE-DU": "Dubayy", - "AE-RK": "Ra’s al Khaymah", - "AE-UQ": "Umm al Qaywayn", - "GB-ENG": "England", - "GB-NIR": "Northern Ireland", - "GB-SCT": "Scotland", - "GB-WLS": "Wales", - "US-AL": "Alabama", - "US-AK": "Alaska", - "US-AZ": "Arizona", - "US-AR": "Arkansas", - "US-CA": "California", - "US-CO": "Colorado", - "US-CT": "Connecticut", - "US-DE": "Delaware", - "US-FL": "Florida", - "US-GA": "Georgia", - "US-HI": "Hawaii", - "US-ID": "Idaho", - "US-IL": "Illinois", - "US-IN": "Indiana", - "US-IA": "Iowa", - "US-KS": "Kansas", - "US-KY": "Kentucky", - "US-LA": "Louisiana", - "US-ME": "Maine", - "US-MD": "Maryland", - "US-MA": "Massachusetts", - "US-MI": "Michigan", - "US-MN": "Minnesota", - "US-MS": "Mississippi", - "US-MO": "Missouri", - "US-MT": "Montana", - "US-NE": "Nebraska", - "US-NV": "Nevada", - "US-NH": "New Hampshire", - "US-NJ": "New Jersey", - "US-NM": "New Mexico", - "US-NY": "New York", - "US-NC": "North Carolina", - "US-ND": "North Dakota", - "US-OH": "Ohio", - "US-OK": "Oklahoma", - "US-OR": "Oregon", - "US-PA": "Pennsylvania", - "US-RI": "Rhode Island", - "US-SC": "South Carolina", - "US-SD": "South Dakota", - "US-TN": "Tennessee", - "US-TX": "Texas", - "US-UT": "Utah", - "US-VT": "Vermont", - "US-VA": "Virginia", - "US-WA": "Washington", - "US-WV": "West Virginia", - "US-WI": "Wisconsin", - "US-WY": "Wyoming", - "US-DC": "District of Columbia", - "US-AS": "American Samoa", - "US-GU": "Guam", - "US-MP": "Northern Mariana Islands", - "US-PR": "Puerto Rico", - "US-UM": "United States Minor Outlying Islands", - "US-VI": "Virgin Islands", - "UM-81": "Baker Island", - "UM-84": "Howland Island", - "UM-86": "Jarvis Island", - "UM-67": "Johnston Atoll", - "UM-89": "Kingman Reef", - "UM-71": "Midway Islands", - "UM-76": "Navassa Island", - "UM-95": "Palmyra Atoll", - "UM-79": "Wake Island", - "UY-AR": "Artigas", - "UY-CA": "Canelones", - "UY-CL": "Cerro Largo", - "UY-CO": "Colonia", - "UY-DU": "Durazno", - "UY-FS": "Flores", - "UY-FD": "Florida", - "UY-LA": "Lavalleja", - "UY-MA": "Maldonado", - "UY-MO": "Montevideo", - "UY-PA": "Paysandú", - "UY-RN": "Río Negro", - "UY-RV": "Rivera", - "UY-RO": "Rocha", - "UY-SA": "Salto", - "UY-SJ": "San José", - "UY-SO": "Soriano", - "UY-TA": "Tacuarembó", - "UY-TT": "Treinta y Tres", - "UZ-AN": "Andijon", - "UZ-BU": "Buxoro", - "UZ-FA": "Farg‘ona", - "UZ-JI": "Jizzax", - "UZ-NG": "Namangan", - "UZ-NW": "Navoiy", - "UZ-QA": "Qashqadaryo", - "UZ-QR": "Qoraqalpog‘iston Respublikasi", - "UZ-SA": "Samarqand", - "UZ-SI": "Sirdaryo", - "UZ-SU": "Surxondaryo", - "UZ-TK": "Toshkent", - "UZ-TO": "Toshkent", - "UZ-XO": "Xorazm", - "VU-MAP": "Malampa", - "VU-PAM": "Pénama", - "VU-SAM": "Sanma", - "VU-SEE": "Shéfa", - "VU-TAE": "Taféa", - "VU-TOB": "Torba", - "VE-Z": "Amazonas", - "VE-B": "Anzoátegui", - "VE-C": "Apure", - "VE-D": "Aragua", - "VE-E": "Barinas", - "VE-F": "Bolívar", - "VE-G": "Carabobo", - "VE-H": "Cojedes", - "VE-Y": "Delta Amacuro", - "VE-W": "Dependencias Federales", - "VE-A": "Distrito Capital", - "VE-I": "Falcón", - "VE-J": "Guárico", - "VE-X": "La Guaira", - "VE-K": "Lara", - "VE-L": "Mérida", - "VE-M": "Miranda", - "VE-N": "Monagas", - "VE-O": "Nueva Esparta", - "VE-P": "Portuguesa", - "VE-R": "Sucre", - "VE-S": "Táchira", - "VE-T": "Trujillo", - "VE-U": "Yaracuy", - "VE-V": "Zulia", - "VN-44": "An Giang", - "VN-43": "Bà Rịa - Vũng Tàu", - "VN-54": "Bắc Giang", - "VN-53": "Bắc Kạn", - "VN-55": "Bạc Liêu", - "VN-56": "Bắc Ninh", - "VN-50": "Bến Tre", - "VN-31": "Bình Định", - "VN-57": "Bình Dương", - "VN-58": "Bình Phước", - "VN-40": "Bình Thuận", - "VN-59": "Cà Mau", - "VN-CT": "Cần Thơ", - "VN-04": "Cao Bằng", - "VN-DN": "Đà Nẵng", - "VN-33": "Đắk Lắk", - "VN-72": "Đắk Nông", - "VN-71": "Điện Biên", - "VN-39": "Đồng Nai", - "VN-45": "Đồng Tháp", - "VN-30": "Gia Lai", - "VN-03": "Hà Giang", - "VN-63": "Hà Nam", - "VN-HN": "Hà Nội", - "VN-23": "Hà Tĩnh", - "VN-61": "Hải Dương", - "VN-HP": "Hải Phòng", - "VN-73": "Hậu Giang", - "VN-SG": "Hồ Chí Minh", - "VN-14": "Hòa Bình", - "VN-66": "Hưng Yên", - "VN-34": "Khánh Hòa", - "VN-47": "Kiến Giang", - "VN-28": "Kon Tum", - "VN-01": "Lai Châu", - "VN-35": "Lâm Đồng", - "VN-09": "Lạng Sơn", - "VN-02": "Lào Cai", - "VN-41": "Long An", - "VN-67": "Nam Định", - "VN-22": "Nghệ An", - "VN-18": "Ninh Bình", - "VN-36": "Ninh Thuận", - "VN-68": "Phú Thọ", - "VN-32": "Phú Yên", - "VN-24": "Quảng Bình", - "VN-27": "Quảng Nam", - "VN-29": "Quảng Ngãi", - "VN-13": "Quảng Ninh", - "VN-25": "Quảng Trị", - "VN-52": "Sóc Trăng", - "VN-05": "Sơn La", - "VN-37": "Tây Ninh", - "VN-20": "Thái Bình", - "VN-69": "Thái Nguyên", - "VN-21": "Thanh Hóa", - "VN-26": "Thừa Thiên-Huế", - "VN-46": "Tiền Giang", - "VN-51": "Trà Vinh", - "VN-07": "Tuyên Quang", - "VN-49": "Vĩnh Long", - "VN-70": "Vĩnh Phúc", - "VN-06": "Yên Bái", - "WF-AL": "Alo", - "WF-SG": "Sigave", - "WF-UV": "Uvea", - "YE-AD": "‘Adan", - "YE-AM": "‘Amrān", - "YE-AB": "Abyan", - "YE-DA": "Aḑ Ḑāli‘", - "YE-BA": "Al Bayḑā’", - "YE-HU": "Al Ḩudaydah", - "YE-JA": "Al Jawf", - "YE-MR": "Al Mahrah", - "YE-MW": "Al Maḩwīt", - "YE-SA": "Amānat al ‘Āşimah", - "YE-SU": "Arkhabīl Suquţrá", - "YE-DH": "Dhamār", - "YE-HD": "Ḩaḑramawt", - "YE-HJ": "Ḩajjah", - "YE-IB": "Ibb", - "YE-LA": "Laḩij", - "YE-MA": "Ma’rib", - "YE-RA": "Raymah", - "YE-SD": "Şāʻdah", - "YE-SN": "Şanʻā’", - "YE-SH": "Shabwah", - "YE-TA": "Tāʻizz", - "ZM-02": "Central", - "ZM-08": "Copperbelt", - "ZM-03": "Eastern", - "ZM-04": "Luapula", - "ZM-09": "Lusaka", - "ZM-10": "Muchinga", - "ZM-06": "North-Western", - "ZM-05": "Northern", - "ZM-07": "Southern", - "ZM-01": "Western", - "ZW-BU": "Bulawayo", - "ZW-HA": "Harare", - "ZW-MA": "Manicaland", - "ZW-MC": "Mashonaland Central", - "ZW-ME": "Mashonaland East", - "ZW-MW": "Mashonaland West", - "ZW-MV": "Masvingo", - "ZW-MN": "Matabeleland North", - "ZW-MS": "Matabeleland South", - "ZW-MI": "Midlands", - "BQ-BO": "Bonaire", - "BQ-SA": "Saba", - "BQ-SE": "Sint Eustatius", - "ME-01": "Andrijevica", - "ME-02": "Bar", - "ME-03": "Berane", - "ME-04": "Bijelo Polje", - "ME-05": "Budva", - "ME-06": "Cetinje", - "ME-07": "Danilovgrad", - "ME-22": "Gusinje", - "ME-08": "Herceg-Novi", - "ME-09": "Kolašin", - "ME-10": "Kotor", - "ME-11": "Mojkovac", - "ME-12": "Nikšić", - "ME-23": "Petnjica", - "ME-13": "Plav", - "ME-14": "Pljevlja", - "ME-15": "Plužine", - "ME-16": "Podgorica", - "ME-17": "Rožaje", - "ME-18": "Šavnik", - "ME-19": "Tivat", - "ME-24": "Tuzi", - "ME-20": "Ulcinj", - "ME-21": "Žabljak", - "ME-25": "Zeta", - "RS-KM": "Kosovo-Metohija[1]", - "RS-VO": "Vojvodina", - "SS-EC": "Central Equatoria", - "SS-EE": "Eastern Equatoria", - "SS-JG": "Jonglei", - "SS-LK": "Lakes", - "SS-BN": "Northern Bahr el Ghazal", - "SS-UY": "Unity", - "SS-NU": "Upper Nile", - "SS-WR": "Warrap", - "SS-BW": "Western Bahr el Ghazal", - "SS-EW": "Western Equatoria", + "AF-BDS": "Badakhshān", + "AF-BDG": "Bādghīs", + "AF-BGL": "Baghlān", + "AF-BAL": "Balkh", + "AF-BAM": "Bāmyān", + "AF-DAY": "Dāykundī", + "AF-FRA": "Farāh", + "AF-FYB": "Fāryāb", + "AF-GHA": "Ghaznī", + "AF-GHO": "Ghōr", + "AF-HEL": "Helmand", + "AF-HER": "Herāt", + "AF-JOW": "Jowzjān", + "AF-KAB": "Kābul", + "AF-KAN": "Kandahār", + "AF-KAP": "Kāpīsā", + "AF-KHO": "Khōst", + "AF-KNR": "Kunaṟ", + "AF-KDZ": "Kunduz", + "AF-LAG": "Laghmān", + "AF-LOG": "Lōgar", + "AF-NAN": "Nangarhār", + "AF-NIM": "Nīmrōz", + "AF-NUR": "Nūristān", + "AF-PKA": "Paktīkā", + "AF-PIA": "Paktiyā", + "AF-PAN": "Panjshayr", + "AF-PAR": "Parwān", + "AF-SAM": "Samangān", + "AF-SAR": "Sar-e Pul", + "AF-TAK": "Takhār", + "AF-URU": "Uruzgān", + "AF-WAR": "Wardak", + "AF-ZAB": "Zābul", + "AL-01": "Berat", + "AL-09": "Dibër", + "AL-02": "Durrës", + "AL-03": "Elbasan", + "AL-04": "Fier", + "AL-05": "Gjirokastër", + "AL-06": "Korçë", + "AL-07": "Kukës", + "AL-08": "Lezhë", + "AL-10": "Shkodër", + "AL-11": "Tiranë", + "AL-12": "Vlorë", + "DZ-01": "Adrar", + "DZ-44": "Aïn Defla", + "DZ-46": "Aïn Témouchent", + "DZ-16": "Alger", + "DZ-23": "Annaba", + "DZ-05": "Batna", + "DZ-08": "Béchar", + "DZ-06": "Béjaïa", + "DZ-52": "Béni Abbès", + "DZ-07": "Biskra", + "DZ-09": "Blida", + "DZ-50": "Bordj Badji Mokhtar", + "DZ-34": "Bordj Bou Arréridj", + "DZ-10": "Bouira", + "DZ-35": "Boumerdès", + "DZ-02": "Chlef", + "DZ-25": "Constantine", + "DZ-56": "Djanet", + "DZ-17": "Djelfa", + "DZ-32": "El Bayadh", + "DZ-57": "El Meghaier", + "DZ-58": "El Meniaa", + "DZ-39": "El Oued", + "DZ-36": "El Tarf", + "DZ-47": "Ghardaïa", + "DZ-24": "Guelma", + "DZ-33": "Illizi", + "DZ-54": "In Guezzam", + "DZ-53": "In Salah", + "DZ-18": "Jijel", + "DZ-40": "Khenchela", + "DZ-03": "Laghouat", + "DZ-28": "M'sila", + "DZ-29": "Mascara", + "DZ-26": "Médéa", + "DZ-43": "Mila", + "DZ-27": "Mostaganem", + "DZ-45": "Naama", + "DZ-31": "Oran", + "DZ-30": "Ouargla", + "DZ-51": "Ouled Djellal", + "DZ-04": "Oum el Bouaghi", + "DZ-48": "Relizane", + "DZ-20": "Saïda", + "DZ-19": "Sétif", + "DZ-22": "Sidi Bel Abbès", + "DZ-21": "Skikda", + "DZ-41": "Souk Ahras", + "DZ-11": "Tamanrasset", + "DZ-12": "Tébessa", + "DZ-14": "Tiaret", + "DZ-49": "Timimoun", + "DZ-37": "Tindouf", + "DZ-42": "Tipaza", + "DZ-38": "Tissemsilt", + "DZ-15": "Tizi Ouzou", + "DZ-13": "Tlemcen", + "DZ-55": "Touggourt", + "AD-07": "Andorra la Vella", + "AD-02": "Canillo", + "AD-03": "Encamp", + "AD-08": "Escaldes-Engordany", + "AD-04": "La Massana", + "AD-05": "Ordino", + "AD-06": "Sant Julià de Lòria", + "AO-BGO": "Bengo", + "AO-BGU": "Benguela", + "AO-BIE": "Bié", + "AO-CAB": "Cabinda", + "AO-CCU": "Cuando Cubango", + "AO-CNO": "Cuanza-Norte", + "AO-CUS": "Cuanza-Sul", + "AO-CNN": "Cunene", + "AO-HUA": "Huambo", + "AO-HUI": "Huíla", + "AO-LUA": "Luanda", + "AO-LNO": "Lunda-Norte", + "AO-LSU": "Lunda-Sul", + "AO-MAL": "Malange", + "AO-MOX": "Moxico", + "AO-NAM": "Namibe", + "AO-UIG": "Uíge", + "AO-ZAI": "Zaire", + "AG-03": "Saint George", + "AG-04": "Saint John", + "AG-05": "Saint Mary", + "AG-06": "Saint Paul", + "AG-07": "Saint Peter", + "AG-08": "Saint Philip", + "AG-10": "Barbuda", + "AG-11": "Redonda", + "AR-B": "Buenos Aires", + "AR-K": "Catamarca", + "AR-H": "Chaco", + "AR-U": "Chubut", + "AR-C": "Ciudad Autónoma de Buenos Aires", + "AR-X": "Córdoba", + "AR-W": "Corrientes", + "AR-E": "Entre Ríos", + "AR-P": "Formosa", + "AR-Y": "Jujuy", + "AR-L": "La Pampa", + "AR-F": "La Rioja", + "AR-M": "Mendoza", + "AR-N": "Misiones", + "AR-Q": "Neuquén", + "AR-R": "Río Negro", + "AR-A": "Salta", + "AR-J": "San Juan", + "AR-D": "San Luis", + "AR-Z": "Santa Cruz", + "AR-S": "Santa Fe", + "AR-G": "Santiago del Estero", + "AR-V": "Tierra del Fuego", + "AR-T": "Tucumán", + "AM-AG": "Aragac̣otn", + "AM-AR": "Ararat", + "AM-AV": "Armavir", + "AM-ER": "Erevan", + "AM-GR": "Geġark'unik'", + "AM-KT": "Kotayk'", + "AM-LO": "Loṙi", + "AM-SH": "Širak", + "AM-SU": "Syunik'", + "AM-TV": "Tavuš", + "AM-VD": "Vayoć Jor", + "AU-NSW": "New South Wales", + "AU-QLD": "Queensland", + "AU-SA": "South Australia", + "AU-TAS": "Tasmania", + "AU-VIC": "Victoria", + "AU-WA": "Western Australia", + "AU-ACT": "Australian Capital Territory", + "AU-NT": "Northern Territory", + "AT-1": "Burgenland", + "AT-2": "Kärnten", + "AT-3": "Niederösterreich", + "AT-4": "Oberösterreich", + "AT-5": "Salzburg", + "AT-6": "Steiermark", + "AT-7": "Tirol", + "AT-8": "Vorarlberg", + "AT-9": "Wien", + "AZ-NX": "Naxçıvan", + "BS-AK": "Acklins", + "BS-BY": "Berry Islands", + "BS-BI": "Bimini", + "BS-BP": "Black Point", + "BS-CI": "Cat Island", + "BS-CO": "Central Abaco", + "BS-CS": "Central Andros", + "BS-CE": "Central Eleuthera", + "BS-FP": "City of Freeport", + "BS-CK": "Crooked Island and Long Cay", + "BS-EG": "East Grand Bahama", + "BS-EX": "Exuma", + "BS-GC": "Grand Cay", + "BS-HI": "Harbour Island", + "BS-HT": "Hope Town", + "BS-IN": "Inagua", + "BS-LI": "Long Island", + "BS-MC": "Mangrove Cay", + "BS-MG": "Mayaguana", + "BS-MI": "Moore's Island", + "BS-NP": "New Providence", + "BS-NO": "North Abaco", + "BS-NS": "North Andros", + "BS-NE": "North Eleuthera", + "BS-RI": "Ragged Island", + "BS-RC": "Rum Cay", + "BS-SS": "San Salvador", + "BS-SO": "South Abaco", + "BS-SA": "South Andros", + "BS-SE": "South Eleuthera", + "BS-SW": "Spanish Wells", + "BS-WG": "West Grand Bahama", + "BH-13": "Al ‘Āşimah", + "BH-14": "Al Janūbīyah", + "BH-15": "Al Muḩarraq", + "BH-17": "Ash Shamālīyah", + "BD-A": "Barishal", + "BD-B": "Chattogram", + "BD-C": "Dhaka", + "BD-D": "Khulna", + "BD-H": "Mymensingh", + "BD-E": "Rajshahi", + "BD-F": "Rangpur", + "BD-G": "Sylhet", + "BB-01": "Christ Church", + "BB-02": "Saint Andrew", + "BB-03": "Saint George", + "BB-04": "Saint James", + "BB-05": "Saint John", + "BB-06": "Saint Joseph", + "BB-07": "Saint Lucy", + "BB-08": "Saint Michael", + "BB-09": "Saint Peter", + "BB-10": "Saint Philip", + "BB-11": "Saint Thomas", + "BY-BR": "Brestskaya voblasts'", + "BY-HO": "Homyel'skaya voblasts'", + "BY-HM": "Horad Minsk", + "BY-HR": "Hrodzyenskaya voblasts'", + "BY-MA": "Mahilyowskaya voblasts'", + "BY-MI": "Minskaya voblasts'", + "BY-VI": "Vitsyebskaya voblasts'", + "BE-BRU": "Brussels Hoofdstedelijk Gewest", + "BE-VLG": "Vlaams Gewest", + "BE-WAL": "Waals Gewest[note 2]", + "BZ-BZ": "Belize", + "BZ-CY": "Cayo", + "BZ-CZL": "Corozal", + "BZ-OW": "Orange Walk", + "BZ-SC": "Stann Creek", + "BZ-TOL": "Toledo", + "BJ-AL": "Alibori", + "BJ-AK": "Atacora", + "BJ-AQ": "Atlantique", + "BJ-BO": "Borgou", + "BJ-CO": "Collines", + "BJ-KO": "Couffo", + "BJ-DO": "Donga", + "BJ-LI": "Littoral", + "BJ-MO": "Mono", + "BJ-OU": "Ouémé", + "BJ-PL": "Plateau", + "BJ-ZO": "Zou", + "BT-33": "Bumthang", + "BT-12": "Chhukha", + "BT-22": "Dagana", + "BT-GA": "Gasa", + "BT-13": "Haa", + "BT-44": "Lhuentse", + "BT-42": "Monggar", + "BT-11": "Paro", + "BT-43": "Pema Gatshel", + "BT-23": "Punakha", + "BT-45": "Samdrup Jongkhar", + "BT-14": "Samtse", + "BT-31": "Sarpang", + "BT-15": "Thimphu", + "BT-41": "Trashigang", + "BT-TY": "Trashi Yangtse", + "BT-32": "Trongsa", + "BT-21": "Tsirang", + "BT-24": "Wangdue Phodrang", + "BT-34": "Zhemgang", + "BO-C": "Cochabamba", + "BO-H": "Chuquisaca", + "BO-B": "El Beni", + "BO-L": "La Paz", + "BO-O": "Oruro", + "BO-N": "Pando", + "BO-P": "Potosí", + "BO-S": "Santa Cruz", + "BO-T": "Tarija", + "BA-BIH": "Federacija Bosne i Hercegovine", + "BA-SRP": "Republika Srpska", + "BA-BRC": "Brčko distrikt", + "BW-CE": "Central", + "BW-CH": "Chobe", + "BW-FR": "Francistown", + "BW-GA": "Gaborone", + "BW-GH": "Ghanzi", + "BW-JW": "Jwaneng", + "BW-KG": "Kgalagadi", + "BW-KL": "Kgatleng", + "BW-KW": "Kweneng", + "BW-LO": "Lobatse", + "BW-NE": "North East", + "BW-NW": "North West", + "BW-SP": "Selibe Phikwe", + "BW-SE": "South East", + "BW-SO": "Southern", + "BW-ST": "Sowa Town", + "BR-AC": "Acre", + "BR-AL": "Alagoas", + "BR-AP": "Amapá", + "BR-AM": "Amazonas", + "BR-BA": "Bahia", + "BR-CE": "Ceará", + "BR-DF": "Distrito Federal", + "BR-ES": "Espírito Santo", + "BR-GO": "Goiás", + "BR-MA": "Maranhão", + "BR-MT": "Mato Grosso", + "BR-MS": "Mato Grosso do Sul", + "BR-MG": "Minas Gerais", + "BR-PA": "Pará", + "BR-PB": "Paraíba", + "BR-PR": "Paraná", + "BR-PE": "Pernambuco", + "BR-PI": "Piauí", + "BR-RJ": "Rio de Janeiro", + "BR-RN": "Rio Grande do Norte", + "BR-RS": "Rio Grande do Sul", + "BR-RO": "Rondônia", + "BR-RR": "Roraima", + "BR-SC": "Santa Catarina", + "BR-SP": "São Paulo", + "BR-SE": "Sergipe", + "BR-TO": "Tocantins", + "BN-BE": "Belait", + "BN-BM": "Brunei-Muara", + "BN-TE": "Temburong", + "BN-TU": "Tutong", + "BG-01": "Blagoevgrad", + "BG-02": "Burgas", + "BG-08": "Dobrich", + "BG-07": "Gabrovo", + "BG-26": "Haskovo", + "BG-09": "Kardzhali", + "BG-10": "Kyustendil", + "BG-11": "Lovech", + "BG-12": "Montana", + "BG-13": "Pazardzhik", + "BG-14": "Pernik", + "BG-15": "Pleven", + "BG-16": "Plovdiv", + "BG-17": "Razgrad", + "BG-18": "Ruse", + "BG-27": "Shumen", + "BG-19": "Silistra", + "BG-20": "Sliven", + "BG-21": "Smolyan", + "BG-23": "Sofia", + "BG-22": "Sofia (stolitsa)", + "BG-24": "Stara Zagora", + "BG-25": "Targovishte", + "BG-03": "Varna", + "BG-04": "Veliko Tarnovo", + "BG-05": "Vidin", + "BG-06": "Vratsa", + "BG-28": "Yambol", + "BF-01": "Boucle du Mouhoun", + "BF-02": "Cascades", + "BF-03": "Centre", + "BF-04": "Centre-Est", + "BF-05": "Centre-Nord", + "BF-06": "Centre-Ouest", + "BF-07": "Centre-Sud", + "BF-08": "Est", + "BF-09": "Hauts-Bassins", + "BF-10": "Nord", + "BF-11": "Plateau-Central", + "BF-12": "Sahel", + "BF-13": "Sud-Ouest", + "BI-BB": "Bubanza", + "BI-BM": "Bujumbura Mairie", + "BI-BL": "Bujumbura Rural", + "BI-BR": "Bururi", + "BI-CA": "Cankuzo", + "BI-CI": "Cibitoke", + "BI-GI": "Gitega", + "BI-KR": "Karuzi", + "BI-KY": "Kayanza", + "BI-KI": "Kirundo", + "BI-MA": "Makamba", + "BI-MU": "Muramvya", + "BI-MY": "Muyinga", + "BI-MW": "Mwaro", + "BI-NG": "Ngozi", + "BI-RM": "Rumonge", + "BI-RT": "Rutana", + "BI-RY": "Ruyigi", + "KH-2": "Baat Dambang", + "KH-1": "Banteay Mean Choăy", + "KH-23": "Kaeb", + "KH-3": "Kampong Chaam", + "KH-4": "Kampong Chhnang", + "KH-5": "Kampong Spueu", + "KH-6": "Kampong Thum", + "KH-7": "Kampot", + "KH-8": "Kandaal", + "KH-9": "Kaoh Kong", + "KH-10": "Kracheh", + "KH-11": "Mondol Kiri", + "KH-22": "Otdar Mean Chey", + "KH-24": "Pailin", + "KH-12": "Phnom Penh", + "KH-15": "Pousaat", + "KH-18": "Preah Sihanouk", + "KH-13": "Preah Vihear", + "KH-14": "Prey Veaeng", + "KH-16": "Rotanak Kiri", + "KH-17": "Siem Reab", + "KH-19": "Stueng Traeng", + "KH-20": "Svaay Rieng", + "KH-21": "Taakaev", + "KH-25": "Tbong Khmum", + "CM-AD": "Adamaoua", + "CM-CE": "Centre", + "CM-ES": "East", + "CM-EN": "Far North", + "CM-LT": "Littoral", + "CM-NO": "North", + "CM-NW": "North-West", + "CM-SU": "South", + "CM-SW": "South-West", + "CM-OU": "West", + "CA-AB": "Alberta", + "CA-BC": "British Columbia", + "CA-MB": "Manitoba", + "CA-NB": "New Brunswick", + "CA-NL": "Newfoundland and Labrador", + "CA-NT": "Northwest Territories", + "CA-NS": "Nova Scotia", + "CA-NU": "Nunavut", + "CA-ON": "Ontario", + "CA-PE": "Prince Edward Island", + "CA-QC": "Quebec", + "CA-SK": "Saskatchewan", + "CA-YT": "Yukon", + "CV-B": "Ilhas de Barlavento", + "CV-S": "Ilhas de Sotavento", + "CF-BB": "Bamingui-Bangoran", + "CF-BGF": "Bangui", + "CF-BK": "Basse-Kotto", + "CF-KB": "Gribingui", + "CF-HM": "Haut-Mbomou", + "CF-HK": "Haute-Kotto", + "CF-HS": "Haute-Sangha / Mambéré-Kadéï", + "CF-KG": "Kémo-Gribingui", + "CF-LB": "Lobaye", + "CF-MB": "Mbomou", + "CF-NM": "Nana-Mambéré", + "CF-MP": "Ombella-Mpoko", + "CF-UK": "Ouaka", + "CF-AC": "Ouham", + "CF-OP": "Ouham-Pendé", + "CF-SE": "Sangha", + "CF-VK": "Vakaga", + "TD-BG": "Baḩr al Ghazāl", + "TD-BA": "Al Baţḩā’", + "TD-BO": "Būrkū", + "TD-CB": "Shārī Bāqirmī", + "TD-EE": "Inīdī ash Sharqī", + "TD-EO": "Inīdī al Gharbī", + "TD-GR": "Qīrā", + "TD-HL": "Ḩajjar Lamīs", + "TD-KA": "Kānim", + "TD-LC": "Al Buḩayrah", + "TD-LO": "Lūghūn al Gharbī", + "TD-LR": "Lūghūn ash Sharqī", + "TD-MA": "Māndūl", + "TD-ME": "Māyū Kībbī ash Sharqī", + "TD-MO": "Māyū Kībbī al Gharbī", + "TD-MC": "Shārī al Awsaţ", + "TD-OD": "Waddāy", + "TD-SA": "Salāmāt", + "TD-SI": "Sīlā", + "TD-TA": "Tānjīlī", + "TD-TI": "Tibastī", + "TD-ND": "Madīnat Injamīnā", + "TD-WF": "Wādī Fīrā’", + "CL-AI": "Aisén del General Carlos Ibañez del Campo", + "CL-AN": "Antofagasta", + "CL-AP": "Arica y Parinacota", + "CL-AT": "Atacama", + "CL-BI": "Biobío", + "CL-CO": "Coquimbo", + "CL-AR": "La Araucanía", + "CL-LI": "Libertador General Bernardo O'Higgins", + "CL-LL": "Los Lagos", + "CL-LR": "Los Ríos", + "CL-MA": "Magallanes", + "CL-ML": "Maule", + "CL-NB": "Ñuble", + "CL-RM": "Región Metropolitana de Santiago", + "CL-TA": "Tarapacá", + "CL-VS": "Valparaíso", + "CN-AH": "Anhui Sheng", + "CN-BJ": "Beijing Shi", + "CN-CQ": "Chongqing Shi", + "CN-FJ": "Fujian Sheng", + "CN-GS": "Gansu Sheng", + "CN-GD": "Guangdong Sheng", + "CN-GX": "Guangxi Zhuangzu Zizhiqu", + "CN-GZ": "Guizhou Sheng", + "CN-HI": "Hainan Sheng", + "CN-HE": "Hebei Sheng", + "CN-HL": "Heilongjiang Sheng", + "CN-HA": "Henan Sheng", + "CN-HK": "Hong Kong SARen", + "CN-HB": "Hubei Sheng", + "CN-HN": "Hunan Sheng", + "CN-JS": "Jiangsu Sheng", + "CN-JX": "Jiangxi Sheng", + "CN-JL": "Jilin Sheng", + "CN-LN": "Liaoning Sheng", + "CN-MO": "Macao SARpt", + "CN-NM": "Nei Mongol Zizhiqu", + "CN-NX": "Ningxia Huizu Zizhiqu", + "CN-QH": "Qinghai Sheng", + "CN-SN": "Shaanxi Sheng", + "CN-SD": "Shandong Sheng", + "CN-SH": "Shanghai Shi", + "CN-SX": "Shanxi Sheng", + "CN-SC": "Sichuan Sheng", + "CN-TW": "Taiwan Sheng", + "CN-TJ": "Tianjin Shi", + "CN-XJ": "Xinjiang Uygur Zizhiqu", + "CN-XZ": "Xizang Zizhiqu", + "CN-YN": "Yunnan Sheng", + "CN-ZJ": "Zhejiang Sheng", + "CO-AMA": "Amazonas", + "CO-ANT": "Antioquia", + "CO-ARA": "Arauca", + "CO-ATL": "Atlántico", + "CO-BOL": "Bolívar", + "CO-BOY": "Boyacá", + "CO-CAL": "Caldas", + "CO-CAQ": "Caquetá", + "CO-CAS": "Casanare", + "CO-CAU": "Cauca", + "CO-CES": "Cesar", + "CO-COR": "Córdoba", + "CO-CUN": "Cundinamarca", + "CO-CHO": "Chocó", + "CO-DC": "Distrito Capital de Bogotá", + "CO-GUA": "Guainía", + "CO-GUV": "Guaviare", + "CO-HUI": "Huila", + "CO-LAG": "La Guajira", + "CO-MAG": "Magdalena", + "CO-MET": "Meta", + "CO-NAR": "Nariño", + "CO-NSA": "Norte de Santander", + "CO-PUT": "Putumayo", + "CO-QUI": "Quindío", + "CO-RIS": "Risaralda", + "CO-SAP": "San Andrés", + "CO-SAN": "Santander", + "CO-SUC": "Sucre", + "CO-TOL": "Tolima", + "CO-VAC": "Valle del Cauca", + "CO-VAU": "Vaupés", + "CO-VID": "Vichada", + "KM-G": "Grande Comore", + "KM-A": "Anjouan", + "KM-M": "Mohéli", + "CG-11": "Bouenza", + "CG-BZV": "Brazzaville", + "CG-8": "Cuvette", + "CG-15": "Cuvette-Ouest", + "CG-5": "Kouilou", + "CG-2": "Lékoumou", + "CG-7": "Likouala", + "CG-9": "Niari", + "CG-14": "Plateaux", + "CG-16": "Pointe-Noire", + "CG-12": "Pool", + "CG-13": "Sangha", + "CD-BU": "Bas-Uélé", + "CD-EQ": "Équateur", + "CD-HK": "Haut-Katanga", + "CD-HL": "Haut-Lomami", + "CD-HU": "Haut-Uélé", + "CD-IT": "Ituri", + "CD-KS": "Kasaï", + "CD-KC": "Kasaï Central", + "CD-KE": "Kasaï Oriental", + "CD-KN": "Kinshasa", + "CD-BC": "Kongo Central", + "CD-KG": "Kwango", + "CD-KL": "Kwilu", + "CD-LO": "Lomami", + "CD-LU": "Lualaba", + "CD-MN": "Mai-Ndombe", + "CD-MA": "Maniema", + "CD-MO": "Mongala", + "CD-NK": "Nord-Kivu", + "CD-NU": "Nord-Ubangi", + "CD-SA": "Sankuru", + "CD-SK": "Sud-Kivu", + "CD-SU": "Sud-Ubangi", + "CD-TA": "Tanganyika", + "CD-TO": "Tshopo", + "CD-TU": "Tshuapa", + "CR-A": "Alajuela", + "CR-C": "Cartago", + "CR-G": "Guanacaste", + "CR-H": "Heredia", + "CR-L": "Limón", + "CR-P": "Puntarenas", + "CR-SJ": "San José", + "CI-AB": "Abidjan", + "CI-BS": "Bas-Sassandra", + "CI-CM": "Comoé", + "CI-DN": "Denguélé", + "CI-GD": "Gôh-Djiboua", + "CI-LC": "Lacs", + "CI-LG": "Lagunes", + "CI-MG": "Montagnes", + "CI-SM": "Sassandra-Marahoué", + "CI-SV": "Savanes", + "CI-VB": "Vallée du Bandama", + "CI-WR": "Woroba", + "CI-YM": "Yamoussoukro", + "CI-ZZ": "Zanzan", + "HR-07": "Bjelovarsko-bilogorska županija", + "HR-12": "Brodsko-posavska županija", + "HR-19": "Dubrovačko-neretvanska županija", + "HR-21": "Grad Zagreb", + "HR-18": "Istarska županija", + "HR-04": "Karlovačka županija", + "HR-06": "Koprivničko-križevačka županija", + "HR-02": "Krapinsko-zagorska županija", + "HR-09": "Ličko-senjska županija", + "HR-20": "Međimurska županija", + "HR-14": "Osječko-baranjska županija", + "HR-11": "Požeško-slavonska županija", + "HR-08": "Primorsko-goranska županija", + "HR-03": "Sisačko-moslavačka županija", + "HR-17": "Splitsko-dalmatinska županija", + "HR-15": "Šibensko-kninska županija", + "HR-05": "Varaždinska županija", + "HR-10": "Virovitičko-podravska županija", + "HR-16": "Vukovarsko-srijemska županija", + "HR-13": "Zadarska županija", + "HR-01": "Zagrebačka županija", + "CU-15": "Artemisa", + "CU-09": "Camagüey", + "CU-08": "Ciego de Ávila", + "CU-06": "Cienfuegos", + "CU-12": "Granma", + "CU-14": "Guantánamo", + "CU-11": "Holguín", + "CU-03": "La Habana", + "CU-10": "Las Tunas", + "CU-04": "Matanzas", + "CU-16": "Mayabeque", + "CU-01": "Pinar del Río", + "CU-07": "Sancti Spíritus", + "CU-13": "Santiago de Cuba", + "CU-05": "Villa Clara", + "CU-99": "Isla de la Juventud", + "CY-04": "Ammochostos", + "CY-06": "Keryneia", + "CY-03": "Larnaka", + "CY-01": "Lefkosia", + "CY-02": "Lemesos", + "CY-05": "Pafos", + "CZ-31": "Jihočeský kraj", + "CZ-64": "Jihomoravský kraj", + "CZ-41": "Karlovarský kraj", + "CZ-52": "Královéhradecký kraj", + "CZ-51": "Liberecký kraj", + "CZ-80": "Moravskoslezský kraj", + "CZ-71": "Olomoucký kraj", + "CZ-53": "Pardubický kraj", + "CZ-32": "Plzeňský kraj", + "CZ-10": "Praha", + "CZ-20": "Středočeský kraj", + "CZ-42": "Ústecký kraj", + "CZ-63": "Kraj Vysočina", + "CZ-72": "Zlínský kraj", + "DK-84": "Region Hovedstaden", + "DK-82": "Region Midjylland", + "DK-81": "Region Nordjylland", + "DK-85": "Region Sjælland", + "DK-83": "Region Syddanmark", + "DJ-AS": "Ali Sabieh", + "DJ-AR": "Arta", + "DJ-DI": "Dikhil", + "DJ-DJ": "Djibouti", + "DJ-OB": "Obock", + "DJ-TA": "Tadjourah", + "DM-02": "Saint Andrew", + "DM-03": "Saint David", + "DM-04": "Saint George", + "DM-05": "Saint John", + "DM-06": "Saint Joseph", + "DM-07": "Saint Luke", + "DM-08": "Saint Mark", + "DM-09": "Saint Patrick", + "DM-10": "Saint Paul", + "DM-11": "Saint Peter", + "DO-33": "Cibao Nordeste", + "DO-34": "Cibao Noroeste", + "DO-35": "Cibao Norte", + "DO-36": "Cibao Sur", + "DO-37": "El Valle", + "DO-38": "Enriquillo", + "DO-39": "Higuamo", + "DO-40": "Ozama", + "DO-41": "Valdesia", + "DO-42": "Yuma", + "EC-A": "Azuay", + "EC-B": "Bolívar", + "EC-F": "Cañar", + "EC-C": "Carchi", + "EC-H": "Chimborazo", + "EC-X": "Cotopaxi", + "EC-O": "El Oro", + "EC-E": "Esmeraldas", + "EC-W": "Galápagos", + "EC-G": "Guayas", + "EC-I": "Imbabura", + "EC-L": "Loja", + "EC-R": "Los Ríos", + "EC-M": "Manabí", + "EC-S": "Morona Santiago", + "EC-N": "Napo", + "EC-D": "Orellana", + "EC-Y": "Pastaza", + "EC-P": "Pichincha", + "EC-SE": "Santa Elena", + "EC-SD": "Santo Domingo de los Tsáchilas", + "EC-U": "Sucumbíos", + "EC-T": "Tungurahua", + "EC-Z": "Zamora Chinchipe", + "EG-DK": "Ad Daqahlīyah", + "EG-BA": "Al Baḩr al Aḩmar", + "EG-BH": "Al Buḩayrah", + "EG-FYM": "Al Fayyūm", + "EG-GH": "Al Gharbīyah", + "EG-ALX": "Al Iskandarīyah", + "EG-IS": "Al Ismā'īlīyah", + "EG-GZ": "Al Jīzah", + "EG-MNF": "Al Minūfīyah", + "EG-MN": "Al Minyā", + "EG-C": "Al Qāhirah", + "EG-KB": "Al Qalyūbīyah", + "EG-LX": "Al Uqşur", + "EG-WAD": "Al Wādī al Jadīd", + "EG-SUZ": "As Suways", + "EG-SHR": "Ash Sharqīyah", + "EG-ASN": "Aswān", + "EG-AST": "Asyūţ", + "EG-BNS": "Banī Suwayf", + "EG-PTS": "Būr Sa‘īd", + "EG-DT": "Dumyāţ", + "EG-JS": "Janūb Sīnā'", + "EG-KFS": "Kafr ash Shaykh", + "EG-MT": "Maţrūḩ", + "EG-KN": "Qinā", + "EG-SIN": "Shamāl Sīnā'", + "EG-SHG": "Sūhāj", + "SV-AH": "Ahuachapán", + "SV-CA": "Cabañas", + "SV-CH": "Chalatenango", + "SV-CU": "Cuscatlán", + "SV-LI": "La Libertad", + "SV-PA": "La Paz", + "SV-UN": "La Unión", + "SV-MO": "Morazán", + "SV-SM": "San Miguel", + "SV-SS": "San Salvador", + "SV-SV": "San Vicente", + "SV-SA": "Santa Ana", + "SV-SO": "Sonsonate", + "SV-US": "Usulután", + "GQ-C": "Región Continental", + "GQ-I": "Región Insular", + "ER-MA": "Al Awsaţ", + "ER-DU": "Al Janūbī", + "ER-AN": "Ansabā", + "ER-DK": "Janūbī al Baḩrī al Aḩmar", + "ER-GB": "Qāsh-Barkah", + "ER-SK": "Shimālī al Baḩrī al Aḩmar", + "EE-37": "Harjumaa", + "EE-39": "Hiiumaa", + "EE-45": "Ida-Virumaa", + "EE-50": "Jõgevamaa", + "EE-52": "Järvamaa", + "EE-60": "Lääne-Virumaa", + "EE-56": "Läänemaa", + "EE-64": "Põlvamaa", + "EE-68": "Pärnumaa", + "EE-71": "Raplamaa", + "EE-74": "Saaremaa", + "EE-79": "Tartumaa", + "EE-81": "Valgamaa", + "EE-84": "Viljandimaa", + "EE-87": "Võrumaa", + "ET-AA": "Ādīs Ābeba", + "ET-AF": "Āfar", + "ET-AM": "Āmara", + "ET-BE": "Bīnshangul Gumuz", + "ET-DD": "Dirē Dawa", + "ET-GA": "Gambēla Hizboch", + "ET-HA": "Hārerī Hizb", + "ET-OR": "Oromīya", + "ET-SI": "Sīdama", + "ET-SO": "Sumalē", + "ET-TI": "Tigray", + "ET-SN": "YeDebub Bihēroch Bihēreseboch na Hizboch", + "ET-SW": "YeDebub M‘irab Ītyop’iya Hizboch", + "FJ-C": "Central", + "FJ-E": "Eastern", + "FJ-N": "Northern", + "FJ-W": "Western", + "FJ-R": "Rotuma", + "FI-01": "Ahvenanmaan maakunta", + "FI-02": "Etelä-Karjala", + "FI-03": "Etelä-Pohjanmaa", + "FI-04": "Etelä-Savo", + "FI-05": "Kainuu", + "FI-06": "Kanta-Häme", + "FI-07": "Keski-Pohjanmaa", + "FI-08": "Keski-Suomi", + "FI-09": "Kymenlaakso", + "FI-10": "Lappi", + "FI-11": "Pirkanmaa", + "FI-12": "Pohjanmaa", + "FI-13": "Pohjois-Karjala", + "FI-14": "Pohjois-Pohjanmaa", + "FI-15": "Pohjois-Savo", + "FI-16": "Päijät-Häme", + "FI-17": "Satakunta", + "FI-18": "Uusimaa", + "FI-19": "Varsinais-Suomi", + "FR-ARA": "Auvergne-Rhône-Alpes", + "FR-BFC": "Bourgogne-Franche-Comté", + "FR-BRE": "Bretagne", + "FR-CVL": "Centre-Val de Loire", + "FR-20R": "Corse", + "FR-GES": "Grand Est", + "FR-HDF": "Hauts-de-France", + "FR-IDF": "Île-de-France", + "FR-NOR": "Normandie", + "FR-NAQ": "Nouvelle-Aquitaine", + "FR-OCC": "Occitanie", + "FR-PDL": "Pays-de-la-Loire", + "FR-PAC": "Provence-Alpes-Côte-d’Azur", + "GA-1": "Estuaire", + "GA-2": "Haut-Ogooué", + "GA-3": "Moyen-Ogooué", + "GA-4": "Ngounié", + "GA-5": "Nyanga", + "GA-6": "Ogooué-Ivindo", + "GA-7": "Ogooué-Lolo", + "GA-8": "Ogooué-Maritime", + "GA-9": "Woleu-Ntem", + "GM-B": "Banjul", + "GM-M": "Central River", + "GM-L": "Lower River", + "GM-N": "North Bank", + "GM-U": "Upper River", + "GM-W": "Western", + "GE-AB": "Abkhazia", + "GE-AJ": "Ajaria", + "GE-GU": "Guria", + "GE-IM": "Imereti", + "GE-KA": "K'akheti", + "GE-KK": "Kvemo Kartli", + "GE-MM": "Mtskheta-Mtianeti", + "GE-RL": "Rach'a-Lechkhumi-Kvemo Svaneti", + "GE-SZ": "Samegrelo-Zemo Svaneti", + "GE-SJ": "Samtskhe-Javakheti", + "GE-SK": "Shida Kartli", + "GE-TB": "Tbilisi", + "DE-BW": "Baden-Württemberg", + "DE-BY": "Bayern", + "DE-BE": "Berlin", + "DE-BB": "Brandenburg", + "DE-HB": "Bremen", + "DE-HH": "Hamburg", + "DE-HE": "Hessen", + "DE-MV": "Mecklenburg-Vorpommern", + "DE-NI": "Niedersachsen", + "DE-NW": "Nordrhein-Westfalen", + "DE-RP": "Rheinland-Pfalz", + "DE-SL": "Saarland", + "DE-SN": "Sachsen", + "DE-ST": "Sachsen-Anhalt", + "DE-SH": "Schleswig-Holstein", + "DE-TH": "Thüringen", + "GH-AF": "Ahafo", + "GH-AH": "Ashanti", + "GH-BO": "Bono", + "GH-BE": "Bono East", + "GH-CP": "Central", + "GH-EP": "Eastern", + "GH-AA": "Greater Accra", + "GH-NE": "North East", + "GH-NP": "Northern", + "GH-OT": "Oti", + "GH-SV": "Savannah", + "GH-UE": "Upper East", + "GH-UW": "Upper West", + "GH-TV": "Volta", + "GH-WP": "Western", + "GH-WN": "Western North", + "GR-69": "Ágion Óros", + "GR-A": "Anatolikí Makedonía kaiThráki", + "GR-I": "Attikí", + "GR-G": "Dytikí Elláda", + "GR-C": "Dytikí Makedonía", + "GR-F": "Ionía Nísia", + "GR-D": "Ípeiros", + "GR-B": "Kentrikí Makedonía", + "GR-M": "Kríti", + "GR-L": "Nótio Aigaío", + "GR-J": "Pelopónnisos", + "GR-H": "Stereá Elláda", + "GR-E": "Thessalía", + "GR-K": "Vóreio Aigaío", + "GL-AV": "Avannaata Kommunia", + "GL-KU": "Kommune Kujalleq", + "GL-QT": "Kommune Qeqertalik", + "GL-SM": "Kommuneqarfik Sermersooq", + "GL-QE": "Qeqqata Kommunia", + "GD-01": "Saint Andrew", + "GD-02": "Saint David", + "GD-03": "Saint George", + "GD-04": "Saint John", + "GD-05": "Saint Mark", + "GD-06": "Saint Patrick", + "GD-10": "Southern Grenadine Islands", + "GT-16": "Alta Verapaz", + "GT-15": "Baja Verapaz", + "GT-04": "Chimaltenango", + "GT-20": "Chiquimula", + "GT-02": "El Progreso", + "GT-05": "Escuintla", + "GT-01": "Guatemala", + "GT-13": "Huehuetenango", + "GT-18": "Izabal", + "GT-21": "Jalapa", + "GT-22": "Jutiapa", + "GT-17": "Petén", + "GT-09": "Quetzaltenango", + "GT-14": "Quiché", + "GT-11": "Retalhuleu", + "GT-03": "Sacatepéquez", + "GT-12": "San Marcos", + "GT-06": "Santa Rosa", + "GT-07": "Sololá", + "GT-10": "Suchitepéquez", + "GT-08": "Totonicapán", + "GT-19": "Zacapa", + "GN-B": "Boké", + "GN-F": "Faranah", + "GN-K": "Kankan", + "GN-D": "Kindia", + "GN-L": "Labé", + "GN-M": "Mamou", + "GN-N": "Nzérékoré", + "GN-C": "Conakry", + "GW-L": "Leste", + "GW-N": "Norte", + "GW-S": "Sul", + "GY-BA": "Barima-Waini", + "GY-CU": "Cuyuni-Mazaruni", + "GY-DE": "Demerara-Mahaica", + "GY-EB": "East Berbice-Corentyne", + "GY-ES": "Essequibo Islands-West Demerara", + "GY-MA": "Mahaica-Berbice", + "GY-PM": "Pomeroon-Supenaam", + "GY-PT": "Potaro-Siparuni", + "GY-UD": "Upper Demerara-Berbice", + "GY-UT": "Upper Takutu-Upper Essequibo", + "HT-AR": "Artibonite", + "HT-CE": "Centre", + "HT-GA": "Grande’Anse", + "HT-NI": "Nippes", + "HT-ND": "Nord", + "HT-NE": "Nord-Est", + "HT-NO": "Nord-Ouest", + "HT-OU": "Ouest", + "HT-SD": "Sud", + "HT-SE": "Sud-Est", + "HN-AT": "Atlántida", + "HN-CH": "Choluteca", + "HN-CL": "Colón", + "HN-CM": "Comayagua", + "HN-CP": "Copán", + "HN-CR": "Cortés", + "HN-EP": "El Paraíso", + "HN-FM": "Francisco Morazán", + "HN-GD": "Gracias a Dios", + "HN-IN": "Intibucá", + "HN-IB": "Islas de la Bahía", + "HN-LP": "La Paz", + "HN-LE": "Lempira", + "HN-OC": "Ocotepeque", + "HN-OL": "Olancho", + "HN-SB": "Santa Bárbara", + "HN-VA": "Valle", + "HN-YO": "Yoro", + "HU-BK": "Bács-Kiskun", + "HU-BA": "Baranya", + "HU-BE": "Békés", + "HU-BC": "Békéscsaba", + "HU-BZ": "Borsod-Abaúj-Zemplén", + "HU-BU": "Budapest", + "HU-CS": "Csongrád-Csanád", + "HU-DE": "Debrecen", + "HU-DU": "Dunaújváros", + "HU-EG": "Eger", + "HU-ER": "Érd", + "HU-FE": "Fejér", + "HU-GY": "Győr", + "HU-GS": "Győr-Moson-Sopron", + "HU-HB": "Hajdú-Bihar", + "HU-HE": "Heves", + "HU-HV": "Hódmezővásárhely", + "HU-JN": "Jász-Nagykun-Szolnok", + "HU-KV": "Kaposvár", + "HU-KM": "Kecskemét", + "HU-KE": "Komárom-Esztergom", + "HU-MI": "Miskolc", + "HU-NK": "Nagykanizsa", + "HU-NO": "Nógrád", + "HU-NY": "Nyíregyháza", + "HU-PS": "Pécs", + "HU-PE": "Pest", + "HU-ST": "Salgótarján", + "HU-SO": "Somogy", + "HU-SN": "Sopron", + "HU-SZ": "Szabolcs-Szatmár-Bereg", + "HU-SD": "Szeged", + "HU-SF": "Székesfehérvár", + "HU-SS": "Szekszárd", + "HU-SK": "Szolnok", + "HU-SH": "Szombathely", + "HU-TB": "Tatabánya", + "HU-TO": "Tolna", + "HU-VA": "Vas", + "HU-VM": "Veszprém", + "HU-VE": "Veszprém", + "HU-ZA": "Zala", + "HU-ZE": "Zalaegerszeg", + "IS-7": "Austurland", + "IS-1": "Höfuðborgarsvæði", + "IS-6": "Norðurland eystra", + "IS-5": "Norðurland vestra", + "IS-8": "Suðurland", + "IS-2": "Suðurnes", + "IS-4": "Vestfirðir", + "IS-3": "Vesturland", + "IN-AN": "Andaman and Nicobar Islands", + "IN-AP": "Andhra Pradesh", + "IN-AR": "Arunāchal Pradesh", + "IN-AS": "Assam", + "IN-BR": "Bihār", + "IN-CH": "Chandīgarh", + "IN-CG": "Chhattīsgarh", + "IN-DH": "Dādra and Nagar Haveli and Damān and Diu[1]", + "IN-DL": "Delhi", + "IN-GA": "Goa", + "IN-GJ": "Gujarāt", + "IN-HR": "Haryāna", + "IN-HP": "Himāchal Pradesh", + "IN-JK": "Jammu and Kashmīr", + "IN-JH": "Jhārkhand", + "IN-KA": "Karnātaka", + "IN-KL": "Kerala", + "IN-LA": "Ladākh", + "IN-LD": "Lakshadweep", + "IN-MP": "Madhya Pradesh", + "IN-MH": "Mahārāshtra", + "IN-MN": "Manipur", + "IN-ML": "Meghālaya", + "IN-MZ": "Mizoram", + "IN-NL": "Nāgāland", + "IN-OD": "Odisha", + "IN-PY": "Puducherry", + "IN-PB": "Punjab", + "IN-RJ": "Rājasthān", + "IN-SK": "Sikkim", + "IN-TN": "Tamil Nādu", + "IN-TS": "Telangāna[2]", + "IN-TR": "Tripura", + "IN-UP": "Uttar Pradesh", + "IN-UK": "Uttarākhand", + "IN-WB": "West Bengal", + "ID-JW": "Jawa", + "ID-KA": "Kalimantan", + "ID-ML": "Maluku", + "ID-NU": "Nusa Tenggara", + "ID-PP": "Papua", + "ID-SL": "Sulawesi", + "ID-SM": "Sumatera", + "IR-30": "Alborz", + "IR-24": "Ardabīl", + "IR-04": "Āz̄ārbāyjān-e Ghārbī", + "IR-03": "Āz̄ārbāyjān-e Shārqī", + "IR-18": "Būshehr", + "IR-14": "Chahār Maḩāl va Bakhtīārī", + "IR-10": "Eşfahān", + "IR-07": "Fārs", + "IR-01": "Gīlān", + "IR-27": "Golestān", + "IR-13": "Hamadān", + "IR-22": "Hormozgān", + "IR-16": "Īlām", + "IR-08": "Kermān", + "IR-05": "Kermānshāh", + "IR-29": "Khorāsān-e Jonūbī", + "IR-09": "Khorāsān-e Raẕavī", + "IR-28": "Khorāsān-e Shomālī", + "IR-06": "Khūzestān", + "IR-17": "Kohgīlūyeh va Bowyer Aḩmad", + "IR-12": "Kordestān", + "IR-15": "Lorestān", + "IR-00": "Markazī", + "IR-02": "Māzandarān", + "IR-26": "Qazvīn", + "IR-25": "Qom", + "IR-20": "Semnān", + "IR-11": "Sīstān va Balūchestān", + "IR-23": "Tehrān", + "IR-21": "Yazd", + "IR-19": "Zanjān", + "IQ-AN": "Al Anbār", + "IQ-BA": "Al Başrah", + "IQ-MU": "Al Muthanná", + "IQ-QA": "Al Qādisīyah", + "IQ-NA": "An Najaf", + "IQ-AR": "Arbīl", + "IQ-SU": "As Sulaymānīyah", + "IQ-BB": "Bābil", + "IQ-BG": "Baghdād", + "IQ-DA": "Dahūk", + "IQ-DQ": "Dhī Qār", + "IQ-DI": "Diyālá", + "IQ-KR": "Iqlīm Kūrdistān", + "IQ-KA": "Karbalā’", + "IQ-KI": "Kirkūk", + "IQ-MA": "Maysān", + "IQ-NI": "Nīnawá", + "IQ-SD": "Şalāḩ ad Dīn", + "IQ-WA": "Wāsiţ", + "IE-C": "Connaught", + "IE-L": "Leinster", + "IE-M": "Munster", + "IE-U": "Ulster[a]", + "IL-D": "HaDarom", + "IL-M": "HaMerkaz", + "IL-Z": "HaTsafon", + "IL-HA": "H̱efa", + "IL-TA": "Tel Aviv", + "IL-JM": "Yerushalayim", + "IT-65": "Abruzzo", + "IT-77": "Basilicata", + "IT-78": "Calabria", + "IT-72": "Campania", + "IT-45": "Emilia-Romagna", + "IT-36": "Friuli Venezia Giulia", + "IT-62": "Lazio", + "IT-42": "Liguria", + "IT-25": "Lombardia", + "IT-57": "Marche", + "IT-67": "Molise", + "IT-21": "Piemonte", + "IT-75": "Puglia", + "IT-88": "Sardegna", + "IT-82": "Sicilia", + "IT-52": "Toscana", + "IT-32": "Trentino-Alto Adigede", + "IT-55": "Umbria", + "IT-23": "Valle d'Aostafr", + "IT-34": "Veneto", + "JM-13": "Clarendon", + "JM-09": "Hanover", + "JM-01": "Kingston", + "JM-12": "Manchester", + "JM-04": "Portland", + "JM-02": "Saint Andrew", + "JM-06": "Saint Ann", + "JM-14": "Saint Catherine", + "JM-11": "Saint Elizabeth", + "JM-08": "Saint James", + "JM-05": "Saint Mary", + "JM-03": "Saint Thomas", + "JM-07": "Trelawny", + "JM-10": "Westmoreland", + "JP-23": "Aiti", + "JP-05": "Akita", + "JP-02": "Aomori", + "JP-38": "Ehime", + "JP-21": "Gihu", + "JP-10": "Gunma", + "JP-34": "Hirosima", + "JP-01": "Hokkaidô", + "JP-18": "Hukui", + "JP-40": "Hukuoka", + "JP-07": "Hukusima", + "JP-28": "Hyôgo", + "JP-08": "Ibaraki", + "JP-17": "Isikawa", + "JP-03": "Iwate", + "JP-37": "Kagawa", + "JP-46": "Kagosima", + "JP-14": "Kanagawa", + "JP-39": "Kôti", + "JP-43": "Kumamoto", + "JP-26": "Kyôto", + "JP-24": "Mie", + "JP-04": "Miyagi", + "JP-45": "Miyazaki", + "JP-20": "Nagano", + "JP-42": "Nagasaki", + "JP-29": "Nara", + "JP-15": "Niigata", + "JP-44": "Ôita", + "JP-33": "Okayama", + "JP-47": "Okinawa", + "JP-27": "Ôsaka", + "JP-41": "Saga", + "JP-11": "Saitama", + "JP-25": "Siga", + "JP-32": "Simane", + "JP-22": "Sizuoka", + "JP-12": "Tiba", + "JP-36": "Tokusima", + "JP-13": "Tôkyô", + "JP-09": "Totigi", + "JP-31": "Tottori", + "JP-16": "Toyama", + "JP-30": "Wakayama", + "JP-06": "Yamagata", + "JP-35": "Yamaguti", + "JP-19": "Yamanasi", + "JO-AJ": "‘Ajlūn", + "JO-AQ": "Al ‘Aqabah", + "JO-AM": "Al ‘A̅şimah", + "JO-BA": "Al Balqā’", + "JO-KA": "Al Karak", + "JO-MA": "Al Mafraq", + "JO-AT": "Aţ Ţafīlah", + "JO-AZ": "Az Zarqā’", + "JO-IR": "Irbid", + "JO-JA": "Jarash", + "JO-MN": "Ma‘ān", + "JO-MD": "Mādabā", + "KZ-10": "Abayoblysy", + "KZ-75": "Almaty", + "KZ-19": "Almatyoblysy", + "KZ-11": "Aqmola oblysy", + "KZ-15": "Aqtöbe oblysy", + "KZ-71": "Astana", + "KZ-23": "Atyraūoblysy", + "KZ-27": "Batys Qazaqstan oblysy", + "KZ-47": "Mangghystaū oblysy", + "KZ-55": "Pavlodar oblysy", + "KZ-35": "Qaraghandy oblysy", + "KZ-39": "Qostanay oblysy", + "KZ-43": "Qyzylorda oblysy", + "KZ-63": "Shyghys Qazaqstan oblysy", + "KZ-79": "Shymkent", + "KZ-59": "Soltüstik Qazaqstan oblysy", + "KZ-61": "Türkistan oblysy", + "KZ-62": "Ulytaūoblysy", + "KZ-31": "Zhambyl oblysy", + "KZ-33": "Zhetisū oblysy", + "KE-01": "Baringo", + "KE-02": "Bomet", + "KE-03": "Bungoma", + "KE-04": "Busia", + "KE-05": "Elgeyo/Marakwet", + "KE-06": "Embu", + "KE-07": "Garissa", + "KE-08": "Homa Bay", + "KE-09": "Isiolo", + "KE-10": "Kajiado", + "KE-11": "Kakamega", + "KE-12": "Kericho", + "KE-13": "Kiambu", + "KE-14": "Kilifi", + "KE-15": "Kirinyaga", + "KE-16": "Kisii", + "KE-17": "Kisumu", + "KE-18": "Kitui", + "KE-19": "Kwale", + "KE-20": "Laikipia", + "KE-21": "Lamu", + "KE-22": "Machakos", + "KE-23": "Makueni", + "KE-24": "Mandera", + "KE-25": "Marsabit", + "KE-26": "Meru", + "KE-27": "Migori", + "KE-28": "Mombasa", + "KE-29": "Murang'a", + "KE-30": "Nairobi City", + "KE-31": "Nakuru", + "KE-32": "Nandi", + "KE-33": "Narok", + "KE-34": "Nyamira", + "KE-35": "Nyandarua", + "KE-36": "Nyeri", + "KE-37": "Samburu", + "KE-38": "Siaya", + "KE-39": "Taita/Taveta", + "KE-40": "Tana River", + "KE-41": "Tharaka-Nithi", + "KE-42": "Trans Nzoia", + "KE-43": "Turkana", + "KE-44": "Uasin Gishu", + "KE-45": "Vihiga", + "KE-46": "Wajir", + "KE-47": "West Pokot", + "KI-G": "Gilbert Islands", + "KI-L": "Line Islands", + "KI-P": "Phoenix Islands", + "KP-04": "Chagang-do", + "KP-09": "Hamgyǒng-bukto", + "KP-08": "Hamgyǒng-namdo", + "KP-06": "Hwanghae-bukto", + "KP-05": "Hwanghae-namdo", + "KP-15": "Kaesŏng", + "KP-07": "Kangwǒn-do", + "KP-14": "Namp’o", + "KP-03": "P'yǒngan-bukto", + "KP-02": "P'yǒngan-namdo", + "KP-01": "P'yǒngyang", + "KP-13": "Rasǒn", + "KP-10": "Ryanggang-do", + "KR-26": "Busan-gwangyeoksi", + "KR-43": "Chungcheongbuk-do", + "KR-44": "Chungcheongnam-do", + "KR-27": "Daegu-gwangyeoksi", + "KR-30": "Daejeon-gwangyeoksi", + "KR-42": "Gangwon-teukbyeoljachido", + "KR-29": "Gwangju-gwangyeoksi", + "KR-41": "Gyeonggi-do", + "KR-47": "Gyeongsangbuk-do", + "KR-48": "Gyeongsangnam-do", + "KR-28": "Incheon-gwangyeoksi", + "KR-49": "Jeju-teukbyeoljachido", + "KR-45": "Jeollabuk-do", + "KR-46": "Jeollanam-do", + "KR-50": "Sejong", + "KR-11": "Seoul-teukbyeolsi", + "KR-31": "Ulsan-gwangyeoksi", + "KW-AH": "Al Aḩmadī", + "KW-FA": "Al Farwānīyah", + "KW-JA": "Al Jahrā’", + "KW-KU": "Al ‘Āşimah", + "KW-HA": "Ḩawallī", + "KW-MU": "Mubārak al Kabīr", + "KG-B": "Batken", + "KG-GB": "Bishkek Shaary", + "KG-C": "Chüy", + "KG-J": "Jalal-Abad", + "KG-N": "Naryn", + "KG-O": "Osh", + "KG-GO": "Osh Shaary", + "KG-T": "Talas", + "KG-Y": "Ysyk-Köl", + "LA-AT": "Attapu", + "LA-BK": "Bokèo", + "LA-BL": "Bolikhamxai", + "LA-CH": "Champasak", + "LA-HO": "Houaphan", + "LA-KH": "Khammouan", + "LA-LM": "Louang Namtha", + "LA-LP": "Louangphabang", + "LA-OU": "Oudômxai", + "LA-PH": "Phôngsali", + "LA-SL": "Salavan", + "LA-SV": "Savannakhét", + "LA-VI": "Viangchan", + "LA-VT": "Viangchan", + "LA-XA": "Xaignabouli", + "LA-XS": "Xaisômboun", + "LA-XE": "Xékong", + "LA-XI": "Xiangkhouang", + "LV-002": "Aizkraukles novads", + "LV-007": "Alūksnes novads", + "LV-111": "Augšdaugavas novads", + "LV-011": "Ādažu novads", + "LV-015": "Balvu novads", + "LV-016": "Bauskas novads", + "LV-022": "Cēsu novads", + "LV-DGV": "Daugavpils", + "LV-112": "Dienvidkurzemes Novads", + "LV-026": "Dobeles novads", + "LV-033": "Gulbenes novads", + "LV-JEL": "Jelgava", + "LV-041": "Jelgavas novads", + "LV-042": "Jēkabpils novads", + "LV-JUR": "Jūrmala", + "LV-047": "Krāslavas novads", + "LV-050": "Kuldīgas novads", + "LV-052": "Ķekavas novads", + "LV-LPX": "Liepāja", + "LV-054": "Limbažu novads", + "LV-056": "Līvānu novads", + "LV-058": "Ludzas novads", + "LV-059": "Madonas novads", + "LV-062": "Mārupes novads", + "LV-067": "Ogres novads", + "LV-068": "Olaines novads", + "LV-073": "Preiļu novads", + "LV-REZ": "Rēzekne", + "LV-077": "Rēzeknes novads", + "LV-RIX": "Rīga", + "LV-080": "Ropažu novads", + "LV-087": "Salaspils novads", + "LV-088": "Saldus novads", + "LV-089": "Saulkrastu novads", + "LV-091": "Siguldas novads", + "LV-094": "Smiltenes novads", + "LV-097": "Talsu novads", + "LV-099": "Tukuma novads", + "LV-101": "Valkas novads", + "LV-113": "Valmieras Novads", + "LV-102": "Varakļānu novads", + "LV-VEN": "Ventspils", + "LV-106": "Ventspils novads", + "LB-AK": "Aakkâr", + "LB-BH": "Baalbek-Hermel", + "LB-BI": "Béqaa", + "LB-BA": "Beyrouth", + "LB-AS": "Liban-Nord", + "LB-JA": "Liban-Sud", + "LB-JL": "Mont-Liban", + "LB-NA": "Nabatîyé", + "LS-D": "Berea", + "LS-B": "Botha-Bothe", + "LS-C": "Leribe", + "LS-E": "Mafeteng", + "LS-A": "Maseru", + "LS-F": "Mohale's Hoek", + "LS-J": "Mokhotlong", + "LS-H": "Qacha's Nek", + "LS-G": "Quthing", + "LS-K": "Thaba-Tseka", + "LR-BM": "Bomi", + "LR-BG": "Bong", + "LR-GP": "Gbarpolu", + "LR-GB": "Grand Bassa", + "LR-CM": "Grand Cape Mount", + "LR-GG": "Grand Gedeh", + "LR-GK": "Grand Kru", + "LR-LO": "Lofa", + "LR-MG": "Margibi", + "LR-MY": "Maryland", + "LR-MO": "Montserrado", + "LR-NI": "Nimba", + "LR-RI": "River Cess", + "LR-RG": "River Gee", + "LR-SI": "Sinoe", + "LY-BU": "Al Buţnān", + "LY-JA": "Al Jabal al Akhḑar", + "LY-JG": "Al Jabal al Gharbī", + "LY-JI": "Al Jafārah", + "LY-JU": "Al Jufrah", + "LY-KF": "Al Kufrah", + "LY-MJ": "Al Marj", + "LY-MB": "Al Marqab", + "LY-WA": "Al Wāḩāt", + "LY-NQ": "An Nuqāţ al Khams", + "LY-ZA": "Az Zāwiyah", + "LY-BA": "Banghāzī", + "LY-DR": "Darnah", + "LY-GT": "Ghāt", + "LY-MI": "Mişrātah", + "LY-MQ": "Murzuq", + "LY-NL": "Nālūt", + "LY-SB": "Sabhā", + "LY-SR": "Surt", + "LY-TB": "Ţarābulus", + "LY-WD": "Wādī al Ḩayāt", + "LY-WS": "Wādī ash Shāţi’", + "LI-01": "Balzers", + "LI-02": "Eschen", + "LI-03": "Gamprin", + "LI-04": "Mauren", + "LI-05": "Planken", + "LI-06": "Ruggell", + "LI-07": "Schaan", + "LI-08": "Schellenberg", + "LI-09": "Triesen", + "LI-10": "Triesenberg", + "LI-11": "Vaduz", + "LT-AL": "Alytaus apskritis", + "LT-KU": "Kauno apskritis", + "LT-KL": "Klaipėdos apskritis", + "LT-MR": "Marijampolės apskritis", + "LT-PN": "Panevėžio apskritis", + "LT-SA": "Šiaulių apskritis", + "LT-TA": "Tauragės apskritis", + "LT-TE": "Telšių apskritis", + "LT-UT": "Utenos apskritis", + "LT-VL": "Vilniaus apskritis", + "LU-CA": "Capellen", + "LU-CL": "Clervaux", + "LU-DI": "Diekirch", + "LU-EC": "Echternach", + "LU-ES": "Esch-sur-Alzette", + "LU-GR": "Grevenmacher", + "LU-LU": "Luxembourg", + "LU-ME": "Mersch", + "LU-RD": "Redange", + "LU-RM": "Remich", + "LU-VD": "Vianden", + "LU-WI": "Wiltz", + "MG-T": "Antananarivo", + "MG-D": "Antsiranana", + "MG-F": "Fianarantsoa", + "MG-M": "Mahajanga", + "MG-A": "Toamasina", + "MG-U": "Toliara", + "MW-N": "Chakumpoto", + "MW-S": "Chakumwera", + "MW-C": "Chapakati", + "MY-01": "Johor", + "MY-02": "Kedah", + "MY-03": "Kelantan", + "MY-04": "Melaka", + "MY-05": "Negeri Sembilan", + "MY-06": "Pahang", + "MY-08": "Perak", + "MY-09": "Perlis", + "MY-07": "Pulau Pinang", + "MY-12": "Sabah", + "MY-13": "Sarawak", + "MY-10": "Selangor", + "MY-11": "Terengganu", + "MY-14": "Wilayah Persekutuan Kuala Lumpur", + "MY-15": "Wilayah Persekutuan Labuan", + "MY-16": "Wilayah Persekutuan Putrajaya", + "MV-01": "Addu", + "MV-00": "Ariatholhu Dhekunuburi", + "MV-02": "Ariatholhu Uthuruburi", + "MV-03": "Faadhippolhu", + "MV-04": "Felidheatholhu", + "MV-29": "Fuvammulah", + "MV-05": "Hahdhunmathi", + "MV-28": "Huvadhuatholhu Dhekunuburi", + "MV-27": "Huvadhuatholhu Uthuruburi", + "MV-08": "Kolhumadulu", + "MV-MLE": "Maale", + "MV-26": "Maaleatholhu", + "MV-20": "Maalhosmadulu Dhekunuburi", + "MV-13": "Maalhosmadulu Uthuruburi", + "MV-25": "Miladhunmadulu Dhekunuburi", + "MV-24": "Miladhunmadulu Uthuruburi", + "MV-12": "Mulakatholhu", + "MV-17": "Nilandheatholhu Dhekunuburi", + "MV-14": "Nilandheatholhu Uthuruburi", + "MV-23": "Thiladhunmathee Dhekunuburi", + "MV-07": "Thiladhunmathee Uthuruburi", + "ML-BKO": "Bamako", + "ML-7": "Gao", + "ML-1": "Kayes", + "ML-8": "Kidal", + "ML-2": "Koulikoro", + "ML-9": "Ménaka", + "ML-5": "Mopti", + "ML-4": "Ségou", + "ML-3": "Sikasso", + "ML-10": "Taoudénit", + "ML-6": "Tombouctou", + "MT-01": "Attard", + "MT-02": "Balzan", + "MT-03": "Birgu", + "MT-04": "Birkirkara", + "MT-05": "Birżebbuġa", + "MT-06": "Bormla", + "MT-07": "Dingli", + "MT-08": "Fgura", + "MT-09": "Floriana", + "MT-10": "Fontana", + "MT-11": "Gudja", + "MT-12": "Gżira", + "MT-13": "Għajnsielem", + "MT-14": "Għarb", + "MT-15": "Għargħur", + "MT-16": "Għasri", + "MT-17": "Għaxaq", + "MT-18": "Ħamrun", + "MT-19": "Iklin", + "MT-20": "Isla", + "MT-21": "Kalkara", + "MT-22": "Kerċem", + "MT-23": "Kirkop", + "MT-24": "Lija", + "MT-25": "Luqa", + "MT-26": "Marsa", + "MT-27": "Marsaskala", + "MT-28": "Marsaxlokk", + "MT-29": "Mdina", + "MT-30": "Mellieħa", + "MT-31": "Mġarr", + "MT-32": "Mosta", + "MT-33": "Mqabba", + "MT-34": "Msida", + "MT-35": "Mtarfa", + "MT-36": "Munxar", + "MT-37": "Nadur", + "MT-38": "Naxxar", + "MT-39": "Paola", + "MT-40": "Pembroke", + "MT-41": "Pietà", + "MT-42": "Qala", + "MT-43": "Qormi", + "MT-44": "Qrendi", + "MT-45": "Rabat Għawdex", + "MT-46": "Rabat Malta", + "MT-47": "Safi", + "MT-48": "San Ġiljan", + "MT-49": "San Ġwann", + "MT-50": "San Lawrenz", + "MT-51": "San Pawl il-Baħar", + "MT-52": "Sannat", + "MT-53": "Santa Luċija", + "MT-54": "Santa Venera", + "MT-55": "Siġġiewi", + "MT-56": "Sliema", + "MT-57": "Swieqi", + "MT-58": "Ta' Xbiex", + "MT-59": "Tarxien", + "MT-60": "Valletta", + "MT-61": "Xagħra", + "MT-62": "Xewkija", + "MT-63": "Xgħajra", + "MT-64": "Żabbar", + "MT-65": "Żebbuġ Għawdex", + "MT-66": "Żebbuġ Malta", + "MT-67": "Żejtun", + "MT-68": "Żurrieq", + "MH-L": "Ralik chain", + "MH-T": "Ratak chain", + "MR-07": "Adrar", + "MR-03": "Assaba", + "MR-05": "Brakna", + "MR-08": "Dakhlet Nouâdhibou", + "MR-04": "Gorgol", + "MR-10": "Guidimaka", + "MR-01": "Hodh ech Chargui", + "MR-02": "Hodh el Gharbi", + "MR-12": "Inchiri", + "MR-09": "Tagant", + "MR-11": "Tiris Zemmour", + "MR-06": "Trarza", + "MU-AG": "Agalega Islands", + "MU-BL": "Black River", + "MU-CC": "Cargados Carajos Shoals", + "MU-FL": "Flacq", + "MU-GP": "Grand Port", + "MU-MO": "Moka", + "MU-PA": "Pamplemousses", + "MU-PW": "Plaines Wilhems", + "MU-PL": "Port Louis", + "MU-RR": "Rivière du Rempart", + "MU-RO": "Rodrigues Island", + "MU-SA": "Savanne", + "MX-AGU": "Aguascalientes", + "MX-BCN": "Baja California", + "MX-BCS": "Baja California Sur", + "MX-CAM": "Campeche", + "MX-CMX": "Ciudad de México", + "MX-COA": "Coahuila de Zaragoza", + "MX-COL": "Colima", + "MX-CHP": "Chiapas", + "MX-CHH": "Chihuahua", + "MX-DUR": "Durango", + "MX-GUA": "Guanajuato", + "MX-GRO": "Guerrero", + "MX-HID": "Hidalgo", + "MX-JAL": "Jalisco", + "MX-MEX": "México", + "MX-MIC": "Michoacán de Ocampo", + "MX-MOR": "Morelos", + "MX-NAY": "Nayarit", + "MX-NLE": "Nuevo León", + "MX-OAX": "Oaxaca", + "MX-PUE": "Puebla", + "MX-QUE": "Querétaro", + "MX-ROO": "Quintana Roo", + "MX-SLP": "San Luis Potosí", + "MX-SIN": "Sinaloa", + "MX-SON": "Sonora", + "MX-TAB": "Tabasco", + "MX-TAM": "Tamaulipas", + "MX-TLA": "Tlaxcala", + "MX-VER": "Veracruz de Ignacio de la Llave", + "MX-YUC": "Yucatán", + "MX-ZAC": "Zacatecas", + "FM-TRK": "Chuuk", + "FM-KSA": "Kosrae", + "FM-PNI": "Pohnpei", + "FM-YAP": "Yap", + "MD-AN": "Anenii Noi", + "MD-BS": "Basarabeasca", + "MD-BA": "Bălți", + "MD-BD": "Bender", + "MD-BR": "Briceni", + "MD-CA": "Cahul", + "MD-CT": "Cantemir", + "MD-CL": "Călărași", + "MD-CS": "Căușeni", + "MD-CU": "Chișinău", + "MD-CM": "Cimișlia", + "MD-CR": "Criuleni", + "MD-DO": "Dondușeni", + "MD-DR": "Drochia", + "MD-DU": "Dubăsari", + "MD-ED": "Edineț", + "MD-FA": "Fălești", + "MD-FL": "Florești", + "MD-GA": "Găgăuzia", + "MD-GL": "Glodeni", + "MD-HI": "Hîncești", + "MD-IA": "Ialoveni", + "MD-LE": "Leova", + "MD-NI": "Nisporeni", + "MD-OC": "Ocnița", + "MD-OR": "Orhei", + "MD-RE": "Rezina", + "MD-RI": "Rîșcani", + "MD-SI": "Sîngerei", + "MD-SO": "Soroca", + "MD-SN": "Stînga Nistrului", + "MD-ST": "Strășeni", + "MD-SD": "Șoldănești", + "MD-SV": "Ștefan Vodă", + "MD-TA": "Taraclia", + "MD-TE": "Telenești", + "MD-UN": "Ungheni", + "MC-FO": "Fontvieille", + "MC-JE": "Jardin Exotique", + "MC-CL": "La Colle", + "MC-CO": "La Condamine", + "MC-GA": "La Gare", + "MC-SO": "La Source", + "MC-LA": "Larvotto", + "MC-MA": "Malbousquet", + "MC-MO": "Monaco-Ville", + "MC-MG": "Moneghetti", + "MC-MC": "Monte-Carlo", + "MC-MU": "Moulins", + "MC-PH": "Port-Hercule", + "MC-SR": "Saint-Roman", + "MC-SD": "Sainte-Dévote", + "MC-SP": "Spélugues", + "MC-VR": "Vallon de la Rousse", + "MN-073": "Arhangay", + "MN-069": "Bayanhongor", + "MN-071": "Bayan-Ölgiy", + "MN-067": "Bulgan", + "MN-037": "Darhan uul", + "MN-061": "Dornod", + "MN-063": "Dornogovĭ", + "MN-059": "Dundgovĭ", + "MN-057": "Dzavhan", + "MN-065": "Govĭ-Altay", + "MN-064": "Govĭ-Sümber", + "MN-039": "Hentiy", + "MN-043": "Hovd", + "MN-041": "Hövsgöl", + "MN-053": "Ömnögovĭ", + "MN-035": "Orhon", + "MN-055": "Övörhangay", + "MN-049": "Selenge", + "MN-051": "Sühbaatar", + "MN-047": "Töv", + "MN-1": "Ulaanbaatar", + "MN-046": "Uvs", + "MA-05": "Béni Mellal-Khénifra", + "MA-06": "Casablanca-Settat", + "MA-12": "Dakhla-Oued Ed-Dahab", + "MA-08": "Drâa-Tafilalet", + "MA-03": "Fès-Meknès", + "MA-10": "Guelmim-Oued Noun", + "MA-02": "L'Oriental", + "MA-11": "Laâyoune-Sakia El Hamra", + "MA-07": "Marrakech-Safi", + "MA-04": "Rabat-Salé-Kénitra", + "MA-09": "Souss-Massa", + "MA-01": "Tanger-Tétouan-Al Hoceïma", + "MZ-P": "Cabo Delgado", + "MZ-G": "Gaza", + "MZ-I": "Inhambane", + "MZ-B": "Manica", + "MZ-MPM": "Maputo", + "MZ-L": "Maputo", + "MZ-N": "Nampula", + "MZ-A": "Niassa", + "MZ-S": "Sofala", + "MZ-T": "Tete", + "MZ-Q": "Zambézia", + "MM-07": "Ayeyarwady", + "MM-02": "Bago", + "MM-14": "Chin", + "MM-11": "Kachin", + "MM-12": "Kayah", + "MM-13": "Kayin", + "MM-03": "Magway", + "MM-04": "Mandalay", + "MM-15": "Mon", + "MM-18": "Nay Pyi Taw", + "MM-16": "Rakhine", + "MM-01": "Sagaing", + "MM-17": "Shan", + "MM-05": "Tanintharyi", + "MM-06": "Yangon", + "NA-ER": "Erongo", + "NA-HA": "Hardap", + "NA-KA": "//Karas", + "NA-KE": "Kavango East", + "NA-KW": "Kavango West", + "NA-KH": "Khomas", + "NA-KU": "Kunene", + "NA-OW": "Ohangwena", + "NA-OH": "Omaheke", + "NA-OS": "Omusati", + "NA-ON": "Oshana", + "NA-OT": "Oshikoto", + "NA-OD": "Otjozondjupa", + "NA-CA": "Zambezi", + "NR-01": "Aiwo", + "NR-02": "Anabar", + "NR-03": "Anetan", + "NR-04": "Anibare", + "NR-05": "Baitsi", + "NR-06": "Boe", + "NR-07": "Buada", + "NR-08": "Denigomodu", + "NR-09": "Ewa", + "NR-10": "Ijuw", + "NR-11": "Meneng", + "NR-12": "Nibok", + "NR-13": "Uaboe", + "NR-14": "Yaren", + "NP-P3": "Bāgmatī", + "NP-P4": "Gaṇḍakī", + "NP-P6": "Karṇālī", + "NP-P1": "Koshī", + "NP-P5": "Lumbinī", + "NP-P2": "Madhesh", + "NP-P7": "Sudūrpashchim", + "NL-DR": "Drenthe", + "NL-FL": "Flevoland", + "NL-FR": "Fryslânfy", + "NL-GE": "Gelderland", + "NL-GR": "Groningen", + "NL-LI": "Limburg", + "NL-NB": "Noord-Brabant", + "NL-NH": "Noord-Holland", + "NL-OV": "Overijssel", + "NL-UT": "Utrecht", + "NL-ZE": "Zeeland", + "NL-ZH": "Zuid-Holland", + "NZ-AUK": "Auckland", + "NZ-BOP": "Bay of Plenty", + "NZ-CAN": "Canterbury", + "NZ-CIT": "Chatham Islands Territory", + "NZ-GIS": "Gisborne", + "NZ-WGN": "Greater Wellington", + "NZ-HKB": "Hawke's Bay", + "NZ-MWT": "Manawatū-Whanganui", + "NZ-MBH": "Marlborough", + "NZ-NSN": "Nelson", + "NZ-NTL": "Northland", + "NZ-OTA": "Otago", + "NZ-STL": "Southland", + "NZ-TKI": "Taranaki", + "NZ-TAS": "Tasman", + "NZ-WKO": "Waikato", + "NZ-WTC": "West Coast", + "NI-BO": "Boaco", + "NI-CA": "Carazo", + "NI-CI": "Chinandega", + "NI-CO": "Chontales", + "NI-AN": "Costa Caribe Norte", + "NI-AS": "Costa Caribe Sur", + "NI-ES": "Estelí", + "NI-GR": "Granada", + "NI-JI": "Jinotega", + "NI-LE": "León", + "NI-MD": "Madriz", + "NI-MN": "Managua", + "NI-MS": "Masaya", + "NI-MT": "Matagalpa", + "NI-NS": "Nueva Segovia", + "NI-SJ": "Río San Juan", + "NI-RI": "Rivas", + "NE-1": "Agadez", + "NE-2": "Diffa", + "NE-3": "Dosso", + "NE-4": "Maradi", + "NE-8": "Niamey", + "NE-5": "Tahoua", + "NE-6": "Tillabéri", + "NE-7": "Zinder", + "NG-AB": "Abia", + "NG-FC": "Abuja Federal Capital Territory", + "NG-AD": "Adamawa", + "NG-AK": "Akwa Ibom", + "NG-AN": "Anambra", + "NG-BA": "Bauchi", + "NG-BY": "Bayelsa", + "NG-BE": "Benue", + "NG-BO": "Borno", + "NG-CR": "Cross River", + "NG-DE": "Delta", + "NG-EB": "Ebonyi", + "NG-ED": "Edo", + "NG-EK": "Ekiti", + "NG-EN": "Enugu", + "NG-GO": "Gombe", + "NG-IM": "Imo", + "NG-JI": "Jigawa", + "NG-KD": "Kaduna", + "NG-KN": "Kano", + "NG-KT": "Katsina", + "NG-KE": "Kebbi", + "NG-KO": "Kogi", + "NG-KW": "Kwara", + "NG-LA": "Lagos", + "NG-NA": "Nasarawa", + "NG-NI": "Niger", + "NG-OG": "Ogun", + "NG-ON": "Ondo", + "NG-OS": "Osun", + "NG-OY": "Oyo", + "NG-PL": "Plateau", + "NG-RI": "Rivers", + "NG-SO": "Sokoto", + "NG-TA": "Taraba", + "NG-YO": "Yobe", + "NG-ZA": "Zamfara", + "MK-801": "Aerodrom", + "MK-802": "Aračinovo", + "MK-201": "Berovo", + "MK-501": "Bitola", + "MK-401": "Bogdanci", + "MK-601": "Bogovinje", + "MK-402": "Bosilovo", + "MK-602": "Brvenica", + "MK-803": "Butel", + "MK-814": "Centar", + "MK-313": "Centar Župa", + "MK-815": "Čair", + "MK-109": "Čaška", + "MK-210": "Češinovo-Obleševo", + "MK-816": "Čučer-Sandevo", + "MK-303": "Debar", + "MK-304": "Debrca", + "MK-203": "Delčevo", + "MK-502": "Demir Hisar", + "MK-103": "Demir Kapija", + "MK-406": "Dojran", + "MK-503": "Dolneni", + "MK-804": "Gazi Baba", + "MK-405": "Gevgelija", + "MK-805": "Gjorče Petrov", + "MK-604": "Gostivar", + "MK-102": "Gradsko", + "MK-807": "Ilinden", + "MK-606": "Jegunovce", + "MK-205": "Karbinci", + "MK-808": "Karpoš", + "MK-104": "Kavadarci", + "MK-307": "Kičevo", + "MK-809": "Kisela Voda", + "MK-206": "Kočani", + "MK-407": "Konče", + "MK-701": "Kratovo", + "MK-702": "Kriva Palanka", + "MK-504": "Krivogaštani", + "MK-505": "Kruševo", + "MK-703": "Kumanovo", + "MK-704": "Lipkovo", + "MK-105": "Lozovo", + "MK-207": "Makedonska Kamenica", + "MK-308": "Makedonski Brod", + "MK-607": "Mavrovo i Rostuše", + "MK-506": "Mogila", + "MK-106": "Negotino", + "MK-507": "Novaci", + "MK-408": "Novo Selo", + "MK-310": "Ohrid", + "MK-208": "Pehčevo", + "MK-810": "Petrovec", + "MK-311": "Plasnica", + "MK-508": "Prilep", + "MK-209": "Probištip", + "MK-409": "Radoviš", + "MK-705": "Rankovce", + "MK-509": "Resen", + "MK-107": "Rosoman", + "MK-811": "Saraj", + "MK-812": "Sopište", + "MK-706": "Staro Nagoričane", + "MK-312": "Struga", + "MK-410": "Strumica", + "MK-813": "Studeničani", + "MK-108": "Sveti Nikole", + "MK-211": "Štip", + "MK-817": "Šuto Orizari", + "MK-608": "Tearce", + "MK-609": "Tetovo", + "MK-403": "Valandovo", + "MK-404": "Vasilevo", + "MK-101": "Veles", + "MK-301": "Vevčani", + "MK-202": "Vinica", + "MK-603": "Vrapčište", + "MK-806": "Zelenikovo", + "MK-204": "Zrnovci", + "MK-605": "Želino", + "NO-42": "Agder", + "NO-34": "Innlandet", + "NO-22": "Jan Mayen", + "NO-15": "Møre og Romsdal", + "NO-18": "Nordland", + "NO-03": "Oslo", + "NO-11": "Rogaland", + "NO-21": "Svalbard", + "NO-54": "Troms og Finnmarksefkv", + "NO-50": "Trøndelagsma", + "NO-38": "Vestfold og Telemark", + "NO-46": "Vestland", + "NO-30": "Viken", + "OM-DA": "Ad Dākhilīyah", + "OM-BU": "Al Buraymī", + "OM-WU": "Al Wusţá", + "OM-ZA": "Az̧ Z̧āhirah", + "OM-BJ": "Janūb al Bāţinah", + "OM-SJ": "Janūb ash Sharqīyah", + "OM-MA": "Masqaţ", + "OM-MU": "Musandam", + "OM-BS": "Shamāl al Bāţinah", + "OM-SS": "Shamāl ash Sharqīyah", + "OM-ZU": "Z̧ufār", + "PK-JK": "Āzād Jammūñ o Kashmīr", + "PK-BA": "Balōchistān", + "PK-GB": "Gilgit-Baltistān", + "PK-IS": "Islāmābād", + "PK-KP": "Khaībar Pakhtūnkhwā", + "PK-PB": "Panjāb", + "PK-SD": "Sindh", + "PW-002": "Aimeliik", + "PW-004": "Airai", + "PW-010": "Angaur", + "PW-050": "Hatohobei", + "PW-100": "Kayangel", + "PW-150": "Koror", + "PW-212": "Melekeok", + "PW-214": "Ngaraard", + "PW-218": "Ngarchelong", + "PW-222": "Ngardmau", + "PW-224": "Ngatpang", + "PW-226": "Ngchesar", + "PW-227": "Ngeremlengui", + "PW-228": "Ngiwal", + "PW-350": "Peleliu", + "PW-370": "Sonsorol", + "PS-BTH": "Bayt Laḩm", + "PS-DEB": "Dayr al Balaḩ", + "PS-GZA": "Ghazzah", + "PS-HBN": "Al Khalīl", + "PS-JEN": "Janīn", + "PS-JRH": "Arīḩā wal Aghwār", + "PS-JEM": "Al Quds", + "PS-KYS": "Khān Yūnis", + "PS-NBS": "Nāblus", + "PS-NGZ": "Shamāl Ghazzah", + "PS-QQA": "Qalqīlyah", + "PS-RFH": "Rafaḩ", + "PS-RBH": "Rām Allāh wal Bīrah", + "PS-SLT": "Salfīt", + "PS-TBS": "Ţūbās", + "PS-TKM": "Ţūlkarm", + "PA-1": "Bocas del Toro", + "PA-4": "Chiriquí", + "PA-2": "Coclé", + "PA-3": "Colón", + "PA-5": "Darién", + "PA-EM": "Emberá", + "PA-KY": "Guna Yala", + "PA-6": "Herrera", + "PA-7": "Los Santos", + "PA-NT": "Naso Tjër Di", + "PA-NB": "Ngäbe-Buglé", + "PA-8": "Panamá", + "PA-10": "Panamá Oeste", + "PA-9": "Veraguas", + "PG-NSB": "Bougainville", + "PG-CPM": "Central", + "PG-CPK": "Chimbu", + "PG-EBR": "East New Britain", + "PG-ESW": "East Sepik", + "PG-EHG": "Eastern Highlands", + "PG-EPW": "Enga", + "PG-GPK": "Gulf", + "PG-HLA": "Hela", + "PG-JWK": "Jiwaka", + "PG-MPM": "Madang", + "PG-MRL": "Manus", + "PG-MBA": "Milne Bay", + "PG-MPL": "Morobe", + "PG-NCD": "National Capital District", + "PG-NIK": "New Ireland", + "PG-NPP": "Northern", + "PG-SHM": "Southern Highlands", + "PG-WBK": "West New Britain", + "PG-SAN": "West Sepik", + "PG-WPD": "Western", + "PG-WHM": "Western Highlands", + "PY-16": "Alto Paraguay", + "PY-10": "Alto Paraná", + "PY-13": "Amambay", + "PY-ASU": "Asunción", + "PY-19": "Boquerón", + "PY-5": "Caaguazú", + "PY-6": "Caazapá", + "PY-14": "Canindeyú", + "PY-11": "Central", + "PY-1": "Concepción", + "PY-3": "Cordillera", + "PY-4": "Guairá", + "PY-7": "Itapúa", + "PY-8": "Misiones", + "PY-12": "Ñeembucú", + "PY-9": "Paraguarí", + "PY-15": "Presidente Hayes", + "PY-2": "San Pedro", + "PE-AMA": "Amazonas", + "PE-ANC": "Ancash", + "PE-APU": "Apurímac", + "PE-ARE": "Arequipa", + "PE-AYA": "Ayacucho", + "PE-CAJ": "Cajamarca", + "PE-CUS": "Cusco", + "PE-CAL": "El Callao", + "PE-HUV": "Huancavelica", + "PE-HUC": "Huánuco", + "PE-ICA": "Ica", + "PE-JUN": "Junín", + "PE-LAL": "La Libertad", + "PE-LAM": "Lambayeque", + "PE-LIM": "Lima", + "PE-LOR": "Loreto", + "PE-MDD": "Madre de Dios", + "PE-MOQ": "Moquegua", + "PE-LMA": "Municipalidad Metropolitana de Lima", + "PE-PAS": "Pasco", + "PE-PIU": "Piura", + "PE-PUN": "Puno", + "PE-SAM": "San Martín", + "PE-TAC": "Tacna", + "PE-TUM": "Tumbes", + "PE-UCA": "Ucayali", + "PH-14": "Autonomous Region in Muslim Mindanao[b]", + "PH-05": "Bicol", + "PH-02": "Cagayan Valley", + "PH-40": "Calabarzon", + "PH-13": "Caraga", + "PH-03": "Central Luzon", + "PH-07": "Central Visayas", + "PH-15": "Cordillera Administrative Region", + "PH-11": "Davao", + "PH-08": "Eastern Visayas", + "PH-01": "Ilocos", + "PH-41": "Mimaropa", + "PH-00": "National Capital Region", + "PH-10": "Northern Mindanao", + "PH-12": "Soccsksargen", + "PH-06": "Western Visayas", + "PH-09": "Zamboanga Peninsula", + "PL-02": "Dolnośląskie", + "PL-04": "Kujawsko-Pomorskie", + "PL-06": "Lubelskie", + "PL-08": "Lubuskie", + "PL-10": "Łódzkie", + "PL-12": "Małopolskie", + "PL-14": "Mazowieckie", + "PL-16": "Opolskie", + "PL-18": "Podkarpackie", + "PL-20": "Podlaskie", + "PL-22": "Pomorskie", + "PL-24": "Śląskie", + "PL-26": "Świętokrzyskie", + "PL-28": "Warmińsko-Mazurskie", + "PL-30": "Wielkopolskie", + "PL-32": "Zachodniopomorskie", + "PT-01": "Aveiro", + "PT-02": "Beja", + "PT-03": "Braga", + "PT-04": "Bragança", + "PT-05": "Castelo Branco", + "PT-06": "Coimbra", + "PT-07": "Évora", + "PT-08": "Faro", + "PT-09": "Guarda", + "PT-10": "Leiria", + "PT-11": "Lisboa", + "PT-12": "Portalegre", + "PT-13": "Porto", + "PT-30": "Região Autónoma da Madeira", + "PT-20": "Região Autónoma dos Açores", + "PT-14": "Santarém", + "PT-15": "Setúbal", + "PT-16": "Viana do Castelo", + "PT-17": "Vila Real", + "PT-18": "Viseu", + "QA-DA": "Ad Dawḩah", + "QA-KH": "Al Khawr wa adh Dhakhīrah", + "QA-WA": "Al Wakrah", + "QA-RA": "Ar Rayyān", + "QA-MS": "Ash Shamāl", + "QA-SH": "Ash Shīḩānīyah", + "QA-ZA": "Az̧ Z̧a‘āyin", + "QA-US": "Umm Şalāl", + "RO-AB": "Alba", + "RO-AR": "Arad", + "RO-AG": "Argeș", + "RO-BC": "Bacău", + "RO-BH": "Bihor", + "RO-BN": "Bistrița-Năsăud", + "RO-BT": "Botoșani", + "RO-BV": "Brașov", + "RO-BR": "Brăila", + "RO-B": "București", + "RO-BZ": "Buzău", + "RO-CS": "Caraș-Severin", + "RO-CL": "Călărași", + "RO-CJ": "Cluj", + "RO-CT": "Constanța", + "RO-CV": "Covasna", + "RO-DB": "Dâmbovița", + "RO-DJ": "Dolj", + "RO-GL": "Galați", + "RO-GR": "Giurgiu", + "RO-GJ": "Gorj", + "RO-HR": "Harghita", + "RO-HD": "Hunedoara", + "RO-IL": "Ialomița", + "RO-IS": "Iași", + "RO-IF": "Ilfov", + "RO-MM": "Maramureș", + "RO-MH": "Mehedinți", + "RO-MS": "Mureș", + "RO-NT": "Neamț", + "RO-OT": "Olt", + "RO-PH": "Prahova", + "RO-SM": "Satu Mare", + "RO-SJ": "Sălaj", + "RO-SB": "Sibiu", + "RO-SV": "Suceava", + "RO-TR": "Teleorman", + "RO-TM": "Timiș", + "RO-TL": "Tulcea", + "RO-VS": "Vaslui", + "RO-VL": "Vâlcea", + "RO-VN": "Vrancea", + "RU-AD": "Adygeya", + "RU-AL": "Altay", + "RU-ALT": "Altayskiy kray", + "RU-AMU": "Amurskaya oblast'", + "RU-ARK": "Arkhangel'skaya oblast'", + "RU-AST": "Astrakhanskaya oblast'", + "RU-BA": "Bashkortostan", + "RU-BEL": "Belgorodskaya oblast'", + "RU-BRY": "Bryanskaya oblast'", + "RU-BU": "Buryatiya", + "RU-CE": "Chechenskaya Respublika", + "RU-CHE": "Chelyabinskaya oblast'", + "RU-CHU": "Chukotskiy avtonomnyy okrug", + "RU-CU": "Chuvashskaya Respublika", + "RU-DA": "Dagestan", + "RU-IN": "Ingushetiya", + "RU-IRK": "Irkutskaya oblast'", + "RU-IVA": "Ivanovskaya oblast'", + "RU-KB": "Kabardino-BalkarskayaRespublika", + "RU-KGD": "Kaliningradskaya oblast'", + "RU-KL": "Kalmykiya", + "RU-KLU": "Kaluzhskaya oblast'", + "RU-KAM": "Kamchatskiy kray", + "RU-KC": "Karachayevo-CherkesskayaRespublika", + "RU-KR": "Kareliya", + "RU-KEM": "Kemerovskaya oblast'", + "RU-KHA": "Khabarovskiy kray", + "RU-KK": "Khakasiya", + "RU-KHM": "Khanty-Mansiyskiyavtonomnyy okrug", + "RU-KIR": "Kirovskaya oblast'", + "RU-KO": "Komi", + "RU-KOS": "Kostromskaya oblast'", + "RU-KDA": "Krasnodarskiy kray", + "RU-KYA": "Krasnoyarskiy kray", + "RU-KGN": "Kurganskaya oblast'", + "RU-KRS": "Kurskaya oblast'", + "RU-LEN": "Leningradskayaoblast'", + "RU-LIP": "Lipetskaya oblast'", + "RU-MAG": "Magadanskaya oblast'", + "RU-ME": "Mariy El", + "RU-MO": "Mordoviya", + "RU-MOS": "Moskovskaya oblast'", + "RU-MOW": "Moskva", + "RU-MUR": "Murmanskaya oblast'", + "RU-NEN": "Nenetskiyavtonomnyy okrug", + "RU-NIZ": "Nizhegorodskaya oblast'", + "RU-NGR": "Novgorodskaya oblast'", + "RU-NVS": "Novosibirskayaoblast'", + "RU-OMS": "Omskaya oblast'", + "RU-ORE": "Orenburgskaya oblast'", + "RU-ORL": "Orlovskaya oblast'", + "RU-PNZ": "Penzenskaya oblast'", + "RU-PER": "Permskiy kray", + "RU-PRI": "Primorskiy kray", + "RU-PSK": "Pskovskaya oblast'", + "RU-ROS": "Rostovskaya oblast'", + "RU-RYA": "Ryazanskaya oblast'", + "RU-SA": "Saha", + "RU-SAK": "Sakhalinskaya oblast'", + "RU-SAM": "Samarskaya oblast'", + "RU-SPE": "Sankt-Peterburg", + "RU-SAR": "Saratovskaya oblast'", + "RU-SE": "Severnaya Osetiya", + "RU-SMO": "Smolenskaya oblast'", + "RU-STA": "Stavropol'skiy kray", + "RU-SVE": "Sverdlovskaya oblast'", + "RU-TAM": "Tambovskaya oblast'", + "RU-TA": "Tatarstan", + "RU-TOM": "Tomskaya oblast'", + "RU-TUL": "Tul'skaya oblast'", + "RU-TVE": "Tverskaya oblast'", + "RU-TYU": "Tyumenskaya oblast'", + "RU-TY": "Tyva", + "RU-UD": "Udmurtskaya Respublika", + "RU-ULY": "Ul'yanovskaya oblast'", + "RU-VLA": "Vladimirskayaoblast'", + "RU-VGG": "Volgogradskayaoblast'", + "RU-VLG": "Vologodskaya oblast'", + "RU-VOR": "Voronezhskaya oblast'", + "RU-YAN": "Yamalo-Nenetskiyavtonomnyy okrug", + "RU-YAR": "Yaroslavskaya oblast'", + "RU-YEV": "Yevreyskaya avtonomnaya oblast'", + "RU-ZAB": "Zabaykal'skiy kray", + "RW-01": "City of Kigali", + "RW-02": "Eastern", + "RW-03": "Northern", + "RW-05": "Southern", + "RW-04": "Western", + "SH-AC": "Ascension", + "SH-HL": "Saint Helena", + "SH-TA": "Tristan da Cunha", + "KN-K": "Saint Kitts", + "KN-N": "Nevis", + "LC-01": "Anse la Raye", + "LC-12": "Canaries", + "LC-02": "Castries", + "LC-03": "Choiseul", + "LC-05": "Dennery", + "LC-06": "Gros Islet", + "LC-07": "Laborie", + "LC-08": "Micoud", + "LC-10": "Soufrière", + "LC-11": "Vieux Fort", + "VC-01": "Charlotte", + "VC-06": "Grenadines", + "VC-02": "Saint Andrew", + "VC-03": "Saint David", + "VC-04": "Saint George", + "VC-05": "Saint Patrick", + "WS-AA": "A'ana", + "WS-AL": "Aiga-i-le-Tai", + "WS-AT": "Atua", + "WS-FA": "Fa'asaleleaga", + "WS-GE": "Gaga'emauga", + "WS-GI": "Gagaifomauga", + "WS-PA": "Palauli", + "WS-SA": "Satupa'itea", + "WS-TU": "Tuamasaga", + "WS-VF": "Va'a-o-Fonoti", + "WS-VS": "Vaisigano", + "SM-01": "Acquaviva", + "SM-06": "Borgo Maggiore", + "SM-02": "Chiesanuova", + "SM-07": "Città di San Marino", + "SM-03": "Domagnano", + "SM-04": "Faetano", + "SM-05": "Fiorentino", + "SM-08": "Montegiardino", + "SM-09": "Serravalle", + "ST-01": "Água Grande", + "ST-02": "Cantagalo", + "ST-03": "Caué", + "ST-04": "Lembá", + "ST-05": "Lobata", + "ST-06": "Mé-Zóchi", + "ST-P": "Príncipe", + "SA-14": "'Asīr", + "SA-11": "Al Bāḩah", + "SA-08": "Al Ḩudūd ash Shamālīyah", + "SA-12": "Al Jawf", + "SA-03": "Al Madīnah al Munawwarah", + "SA-05": "Al Qaşīm", + "SA-01": "Ar Riyāḑ", + "SA-04": "Ash Sharqīyah", + "SA-06": "Ḩā'il", + "SA-09": "Jāzān", + "SA-02": "Makkah al Mukarramah", + "SA-10": "Najrān", + "SA-07": "Tabūk", + "SN-DK": "Dakar", + "SN-DB": "Diourbel", + "SN-FK": "Fatick", + "SN-KA": "Kaffrine", + "SN-KL": "Kaolack", + "SN-KE": "Kédougou", + "SN-KD": "Kolda", + "SN-LG": "Louga", + "SN-MT": "Matam", + "SN-SL": "Saint-Louis", + "SN-SE": "Sédhiou", + "SN-TC": "Tambacounda", + "SN-TH": "Thiès", + "SN-ZG": "Ziguinchor", + "SC-01": "Anse aux Pins", + "SC-02": "Anse Boileau", + "SC-03": "Anse Etoile", + "SC-05": "Anse Royale", + "SC-04": "Au Cap", + "SC-06": "Baie Lazare", + "SC-07": "Baie Sainte Anne", + "SC-08": "Beau Vallon", + "SC-09": "Bel Air", + "SC-10": "Bel Ombre", + "SC-11": "Cascade", + "SC-16": "English River", + "SC-12": "Glacis", + "SC-13": "Grand Anse Mahe", + "SC-14": "Grand Anse Praslin", + "SC-26": "Ile Perseverance I", + "SC-27": "Ile Perseverance II", + "SC-15": "La Digue", + "SC-24": "Les Mamelles", + "SC-17": "Mont Buxton", + "SC-18": "Mont Fleuri", + "SC-19": "Plaisance", + "SC-20": "Pointe Larue", + "SC-21": "Port Glaud", + "SC-25": "Roche Caiman", + "SC-22": "Saint Louis", + "SC-23": "Takamaka", + "SL-E": "Eastern", + "SL-NW": "North Western", + "SL-N": "Northern", + "SL-S": "Southern", + "SL-W": "Western Area", + "SG-01": "Central Singapore", + "SG-02": "North East", + "SG-03": "North West", + "SG-04": "South East", + "SG-05": "South West", + "SK-BC": "Banskobystrický kraj", + "SK-BL": "Bratislavský kraj", + "SK-KI": "Košický kraj", + "SK-NI": "Nitriansky kraj", + "SK-PV": "Prešovský kraj", + "SK-TC": "Trenčiansky kraj", + "SK-TA": "Trnavský kraj", + "SK-ZI": "Žilinský kraj", + "SI-001": "Ajdovščina", + "SI-213": "Ankaran", + "SI-195": "Apače", + "SI-002": "Beltinci", + "SI-148": "Benedikt", + "SI-149": "Bistrica ob Sotli", + "SI-003": "Bled", + "SI-150": "Bloke", + "SI-004": "Bohinj", + "SI-005": "Borovnica", + "SI-006": "Bovec", + "SI-151": "Braslovče", + "SI-007": "Brda", + "SI-008": "Brezovica", + "SI-009": "Brežice", + "SI-152": "Cankova", + "SI-011": "Celje", + "SI-012": "Cerklje na Gorenjskem", + "SI-013": "Cerknica", + "SI-014": "Cerkno", + "SI-153": "Cerkvenjak", + "SI-196": "Cirkulane", + "SI-015": "Črenšovci", + "SI-016": "Črna na Koroškem", + "SI-017": "Črnomelj", + "SI-018": "Destrnik", + "SI-019": "Divača", + "SI-154": "Dobje", + "SI-020": "Dobrepolje", + "SI-155": "Dobrna", + "SI-021": "Dobrova-Polhov Gradec", + "SI-156": "Dobrovnik", + "SI-022": "Dol pri Ljubljani", + "SI-157": "Dolenjske Toplice", + "SI-023": "Domžale", + "SI-024": "Dornava", + "SI-025": "Dravograd", + "SI-026": "Duplek", + "SI-027": "Gorenja vas-Poljane", + "SI-028": "Gorišnica", + "SI-207": "Gorje", + "SI-029": "Gornja Radgona", + "SI-030": "Gornji Grad", + "SI-031": "Gornji Petrovci", + "SI-158": "Grad", + "SI-032": "Grosuplje", + "SI-159": "Hajdina", + "SI-160": "Hoče-Slivnica", + "SI-161": "Hodoš", + "SI-162": "Horjul", + "SI-034": "Hrastnik", + "SI-035": "Hrpelje-Kozina", + "SI-036": "Idrija", + "SI-037": "Ig", + "SI-038": "Ilirska Bistrica", + "SI-039": "Ivančna Gorica", + "SI-040": "Izola", + "SI-041": "Jesenice", + "SI-163": "Jezersko", + "SI-042": "Juršinci", + "SI-043": "Kamnik", + "SI-044": "Kanal ob Soči", + "SI-045": "Kidričevo", + "SI-046": "Kobarid", + "SI-047": "Kobilje", + "SI-048": "Kočevje", + "SI-049": "Komen", + "SI-164": "Komenda", + "SI-050": "Koper", + "SI-197": "Kostanjevica na Krki", + "SI-165": "Kostel", + "SI-051": "Kozje", + "SI-052": "Kranj", + "SI-053": "Kranjska Gora", + "SI-166": "Križevci", + "SI-054": "Krško", + "SI-055": "Kungota", + "SI-056": "Kuzma", + "SI-057": "Laško", + "SI-058": "Lenart", + "SI-059": "Lendava", + "SI-060": "Litija", + "SI-061": "Ljubljana", + "SI-062": "Ljubno", + "SI-063": "Ljutomer", + "SI-208": "Log-Dragomer", + "SI-064": "Logatec", + "SI-065": "Loška dolina", + "SI-066": "Loški Potok", + "SI-167": "Lovrenc na Pohorju", + "SI-067": "Luče", + "SI-068": "Lukovica", + "SI-069": "Majšperk", + "SI-198": "Makole", + "SI-070": "Maribor", + "SI-168": "Markovci", + "SI-071": "Medvode", + "SI-072": "Mengeš", + "SI-073": "Metlika", + "SI-074": "Mežica", + "SI-169": "Miklavž na Dravskem polju", + "SI-075": "Miren-Kostanjevica", + "SI-212": "Mirna", + "SI-170": "Mirna Peč", + "SI-076": "Mislinja", + "SI-199": "Mokronog-Trebelno", + "SI-077": "Moravče", + "SI-078": "Moravske Toplice", + "SI-079": "Mozirje", + "SI-080": "Murska Sobota", + "SI-081": "Muta", + "SI-082": "Naklo", + "SI-083": "Nazarje", + "SI-084": "Nova Gorica", + "SI-085": "Novo Mesto", + "SI-086": "Odranci", + "SI-171": "Oplotnica", + "SI-087": "Ormož", + "SI-088": "Osilnica", + "SI-089": "Pesnica", + "SI-090": "Piran", + "SI-091": "Pivka", + "SI-092": "Podčetrtek", + "SI-172": "Podlehnik", + "SI-093": "Podvelka", + "SI-200": "Poljčane", + "SI-173": "Polzela", + "SI-094": "Postojna", + "SI-174": "Prebold", + "SI-095": "Preddvor", + "SI-175": "Prevalje", + "SI-096": "Ptuj", + "SI-097": "Puconci", + "SI-098": "Rače-Fram", + "SI-099": "Radeče", + "SI-100": "Radenci", + "SI-101": "Radlje ob Dravi", + "SI-102": "Radovljica", + "SI-103": "Ravne na Koroškem", + "SI-176": "Razkrižje", + "SI-209": "Rečica ob Savinji", + "SI-201": "Renče-Vogrsko", + "SI-104": "Ribnica", + "SI-177": "Ribnica na Pohorju", + "SI-106": "Rogaška Slatina", + "SI-105": "Rogašovci", + "SI-107": "Rogatec", + "SI-108": "Ruše", + "SI-178": "Selnica ob Dravi", + "SI-109": "Semič", + "SI-110": "Sevnica", + "SI-111": "Sežana", + "SI-112": "Slovenj Gradec", + "SI-113": "Slovenska Bistrica", + "SI-114": "Slovenske Konjice", + "SI-179": "Sodražica", + "SI-180": "Solčava", + "SI-202": "Središče ob Dravi", + "SI-115": "Starše", + "SI-203": "Straža", + "SI-181": "Sveta Ana", + "SI-204": "Sveta Trojica v Slovenskih goricah", + "SI-182": "Sveti Andraž v Slovenskih goricah", + "SI-116": "Sveti Jurij ob Ščavnici", + "SI-210": "Sveti Jurij v Slovenskih goricah", + "SI-205": "Sveti Tomaž", + "SI-033": "Šalovci", + "SI-183": "Šempeter-Vrtojba", + "SI-117": "Šenčur", + "SI-118": "Šentilj", + "SI-119": "Šentjernej", + "SI-120": "Šentjur", + "SI-211": "Šentrupert", + "SI-121": "Škocjan", + "SI-122": "Škofja Loka", + "SI-123": "Škofljica", + "SI-124": "Šmarje pri Jelšah", + "SI-206": "Šmarješke Toplice", + "SI-125": "Šmartno ob Paki", + "SI-194": "Šmartno pri Litiji", + "SI-126": "Šoštanj", + "SI-127": "Štore", + "SI-184": "Tabor", + "SI-010": "Tišina", + "SI-128": "Tolmin", + "SI-129": "Trbovlje", + "SI-130": "Trebnje", + "SI-185": "Trnovska Vas", + "SI-186": "Trzin", + "SI-131": "Tržič", + "SI-132": "Turnišče", + "SI-133": "Velenje", + "SI-187": "Velika Polana", + "SI-134": "Velike Lašče", + "SI-188": "Veržej", + "SI-135": "Videm", + "SI-136": "Vipava", + "SI-137": "Vitanje", + "SI-138": "Vodice", + "SI-139": "Vojnik", + "SI-189": "Vransko", + "SI-140": "Vrhnika", + "SI-141": "Vuzenica", + "SI-142": "Zagorje ob Savi", + "SI-143": "Zavrč", + "SI-144": "Zreče", + "SI-190": "Žalec", + "SI-146": "Železniki", + "SI-191": "Žetale", + "SI-147": "Žiri", + "SI-192": "Žirovnica", + "SI-193": "Žužemberk", + "SB-CT": "Capital Territory", + "SB-CE": "Central", + "SB-CH": "Choiseul", + "SB-GU": "Guadalcanal", + "SB-IS": "Isabel", + "SB-MK": "Makira-Ulawa", + "SB-ML": "Malaita", + "SB-RB": "Rennell and Bellona", + "SB-TE": "Temotu", + "SB-WE": "Western", + "SO-AW": "Awdal", + "SO-BK": "Bakool", + "SO-BN": "Banaadir", + "SO-BR": "Bari", + "SO-BY": "Bay", + "SO-GA": "Galguduud", + "SO-GE": "Gedo", + "SO-HI": "Hiiraan", + "SO-JD": "Jubbada Dhexe", + "SO-JH": "Jubbada Hoose", + "SO-MU": "Mudug", + "SO-NU": "Nugaal", + "SO-SA": "Sanaag", + "SO-SD": "Shabeellaha Dhexe", + "SO-SH": "Shabeellaha Hoose", + "SO-SO": "Sool", + "SO-TO": "Togdheer", + "SO-WO": "Woqooyi Galbeed", + "ZA-EC": "Eastern Cape", + "ZA-FS": "Free State", + "ZA-GP": "Gauteng", + "ZA-KZN": "Kwazulu-Natal", + "ZA-LP": "Limpopo", + "ZA-MP": "Mpumalanga", + "ZA-NW": "North-West", + "ZA-NC": "Northern Cape", + "ZA-WC": "Western Cape", + "ES-AN": "Andalucía", + "ES-AR": "Aragón", + "ES-AS": "Asturias", + "ES-CN": "Canarias", + "ES-CB": "Cantabria", + "ES-CL": "Castilla y León", + "ES-CM": "Castilla-La Mancha", + "ES-CT": "Catalunya", + "ES-CE": "Ceuta", + "ES-EX": "Extremadura", + "ES-GA": "Galicia", + "ES-IB": "Illes Balears", + "ES-RI": "La Rioja", + "ES-MD": "Madrid", + "ES-ML": "Melilla", + "ES-MC": "Murcia", + "ES-NC": "Navarra", + "ES-PV": "País Vasco", + "ES-VC": "Valenciana", + "LK-1": "Basnāhira paḷāta", + "LK-3": "Dakuṇu paḷāta", + "LK-2": "Madhyama paḷāta", + "LK-5": "Næ̆gĕnahira paḷāta", + "LK-9": "Sabaragamuva paḷāta", + "LK-4": "Uturu paḷāta", + "LK-7": "Uturumæ̆da paḷāta", + "LK-8": "Ūva paḷāta", + "LK-6": "Vayamba paḷāta", + "SD-RS": "Al Baḩr al Aḩmar", + "SD-GZ": "Al Jazīrah", + "SD-KH": "Al Kharţūm", + "SD-GD": "Al Qaḑārif", + "SD-NW": "An Nīl al Abyaḑ", + "SD-NB": "An Nīl al Azraq", + "SD-NO": "Ash Shamālīyah", + "SD-DW": "Gharb Dārfūr", + "SD-GK": "Gharb Kurdufān", + "SD-DS": "Janūb Dārfūr", + "SD-KS": "Janūb Kurdufān", + "SD-KA": "Kassalā", + "SD-NR": "Nahr an Nīl", + "SD-DN": "Shamāl Dārfūr", + "SD-KN": "Shamāl Kurdufān", + "SD-DE": "Sharq Dārfūr", + "SD-SI": "Sinnār", + "SD-DC": "Wasaţ Dārfūr", + "SR-BR": "Brokopondo", + "SR-CM": "Commewijne", + "SR-CR": "Coronie", + "SR-MA": "Marowijne", + "SR-NI": "Nickerie", + "SR-PR": "Para", + "SR-PM": "Paramaribo", + "SR-SA": "Saramacca", + "SR-SI": "Sipaliwini", + "SR-WA": "Wanica", + "SZ-HH": "Hhohho", + "SZ-LU": "Lubombo", + "SZ-MA": "Manzini", + "SZ-SH": "Shiselweni", + "SE-K": "Blekinge län", + "SE-W": "Dalarnas län", + "SE-I": "Gotlands län", + "SE-X": "Gävleborgs län", + "SE-N": "Hallands län", + "SE-Z": "Jämtlands län", + "SE-F": "Jönköpings län", + "SE-H": "Kalmar län", + "SE-G": "Kronobergs län", + "SE-BD": "Norrbottens län", + "SE-M": "Skåne län", + "SE-AB": "Stockholms län", + "SE-D": "Södermanlands län", + "SE-C": "Uppsala län", + "SE-S": "Värmlands län", + "SE-AC": "Västerbottens län", + "SE-Y": "Västernorrlands län", + "SE-U": "Västmanlands län", + "SE-O": "Västra Götalands län", + "SE-T": "Örebro län", + "SE-E": "Östergötlands län", + "CH-AG": "Aargau", + "CH-AR": "Appenzell Ausserrhoden", + "CH-AI": "Appenzell Innerrhoden", + "CH-BL": "Basel-Landschaft", + "CH-BS": "Basel-Stadt", + "CH-BE": "Bern", + "CH-FR": "Fribourg", + "CH-GE": "Genève", + "CH-GL": "Glarus", + "CH-GR": "Graubünden", + "CH-JU": "Jura", + "CH-LU": "Luzern", + "CH-NE": "Neuchâtel", + "CH-NW": "Nidwalden", + "CH-OW": "Obwalden", + "CH-SG": "Sankt Gallen", + "CH-SH": "Schaffhausen", + "CH-SZ": "Schwyz", + "CH-SO": "Solothurn", + "CH-TG": "Thurgau", + "CH-TI": "Ticino", + "CH-UR": "Uri", + "CH-VS": "Valais", + "CH-VD": "Vaud", + "CH-ZG": "Zug", + "CH-ZH": "Zürich", + "SY-HA": "Al Ḩasakah", + "SY-LA": "Al Lādhiqīyah", + "SY-QU": "Al Qunayţirah", + "SY-RA": "Ar Raqqah", + "SY-SU": "As Suwaydā'", + "SY-DR": "Dar'ā", + "SY-DY": "Dayr az Zawr", + "SY-DI": "Dimashq", + "SY-HL": "Ḩalab", + "SY-HM": "Ḩamāh", + "SY-HI": "Ḩimş", + "SY-ID": "Idlib", + "SY-RD": "Rīf Dimashq", + "SY-TA": "Ţarţūs", + "TW-CHA": "Changhua", + "TW-CYI": "Chiayi", + "TW-CYQ": "Chiayi", + "TW-HSZ": "Hsinchu", + "TW-HSQ": "Hsinchu", + "TW-HUA": "Hualien", + "TW-KHH": "Kaohsiung", + "TW-KEE": "Keelung", + "TW-KIN": "Kinmen", + "TW-LIE": "Lienchiang", + "TW-MIA": "Miaoli", + "TW-NAN": "Nantou", + "TW-NWT": "New Taipei", + "TW-PEN": "Penghu", + "TW-PIF": "Pingtung", + "TW-TXG": "Taichung", + "TW-TNN": "Tainan", + "TW-TPE": "Taipei", + "TW-TTT": "Taitung", + "TW-TAO": "Taoyuan", + "TW-ILA": "Yilan", + "TW-YUN": "Yunlin", + "TJ-DU": "Dushanbe", + "TJ-KT": "Khatlon", + "TJ-GB": "Kŭhistoni Badakhshon", + "TJ-RA": "nohiyahoi tobei jumhurí", + "TJ-SU": "Sughd", + "TZ-01": "Arusha", + "TZ-02": "Dar es Salaam", + "TZ-03": "Dodoma", + "TZ-27": "Geita", + "TZ-04": "Iringa", + "TZ-05": "Kagera", + "TZ-06": "Kaskazini Pemba", + "TZ-07": "Kaskazini Unguja", + "TZ-28": "Katavi", + "TZ-08": "Kigoma", + "TZ-09": "Kilimanjaro", + "TZ-10": "Kusini Pemba", + "TZ-11": "Kusini Unguja", + "TZ-12": "Lindi", + "TZ-26": "Manyara", + "TZ-13": "Mara", + "TZ-14": "Mbeya", + "TZ-15": "Mjini Magharibi", + "TZ-16": "Morogoro", + "TZ-17": "Mtwara", + "TZ-18": "Mwanza", + "TZ-29": "Njombe", + "TZ-19": "Pwani", + "TZ-20": "Rukwa", + "TZ-21": "Ruvuma", + "TZ-22": "Shinyanga", + "TZ-30": "Simiyu", + "TZ-23": "Singida", + "TZ-31": "Songwe", + "TZ-24": "Tabora", + "TZ-25": "Tanga", + "TH-37": "Amnat Charoen", + "TH-15": "Ang Thong", + "TH-38": "Bueng Kan", + "TH-31": "Buri Ram", + "TH-24": "Chachoengsao", + "TH-18": "Chai Nat", + "TH-36": "Chaiyaphum", + "TH-22": "Chanthaburi", + "TH-50": "Chiang Mai", + "TH-57": "Chiang Rai", + "TH-20": "Chon Buri", + "TH-86": "Chumphon", + "TH-46": "Kalasin", + "TH-62": "Kamphaeng Phet", + "TH-71": "Kanchanaburi", + "TH-40": "Khon Kaen", + "TH-81": "Krabi", + "TH-10": "Bangkok", + "TH-52": "Lampang", + "TH-51": "Lamphun", + "TH-42": "Loei", + "TH-16": "Lop Buri", + "TH-58": "Mae Hong Son", + "TH-44": "Maha Sarakham", + "TH-49": "Mukdahan", + "TH-26": "Nakhon Nayok", + "TH-73": "Nakhon Pathom", + "TH-48": "Nakhon Phanom", + "TH-30": "Nakhon Ratchasima", + "TH-60": "Nakhon Sawan", + "TH-80": "Nakhon Si Thammarat", + "TH-55": "Nan", + "TH-96": "Narathiwat", + "TH-39": "Nong Bua Lam Phu", + "TH-43": "Nong Khai", + "TH-12": "Nonthaburi", + "TH-13": "Pathum Thani", + "TH-94": "Pattani", + "TH-82": "Phangnga", + "TH-93": "Phatthalung", + "TH-S": "Phatthaya", + "TH-56": "Phayao", + "TH-67": "Phetchabun", + "TH-76": "Phetchaburi", + "TH-66": "Phichit", + "TH-65": "Phitsanulok", + "TH-14": "Phra Nakhon Si Ayutthaya", + "TH-54": "Phrae", + "TH-83": "Phuket", + "TH-25": "Prachin Buri", + "TH-77": "Prachuap Khiri Khan", + "TH-85": "Ranong", + "TH-70": "Ratchaburi", + "TH-21": "Rayong", + "TH-45": "Roi Et", + "TH-27": "Sa Kaeo", + "TH-47": "Sakon Nakhon", + "TH-11": "Samut Prakan", + "TH-74": "Samut Sakhon", + "TH-75": "Samut Songkhram", + "TH-19": "Saraburi", + "TH-91": "Satun", + "TH-33": "Si Sa Ket", + "TH-17": "Sing Buri", + "TH-90": "Songkhla", + "TH-64": "Sukhothai", + "TH-72": "Suphan Buri", + "TH-84": "Surat Thani", + "TH-32": "Surin", + "TH-63": "Tak", + "TH-92": "Trang", + "TH-23": "Trat", + "TH-34": "Ubon Ratchathani", + "TH-41": "Udon Thani", + "TH-61": "Uthai Thani", + "TH-53": "Uttaradit", + "TH-95": "Yala", + "TH-35": "Yasothon", + "TL-AL": "Aileu", + "TL-AN": "Ainaro", + "TL-BA": "Baucau", + "TL-BO": "Bobonaro", + "TL-CO": "Cova Lima", + "TL-DI": "Díli", + "TL-ER": "Ermera", + "TL-LA": "Lautém", + "TL-LI": "Liquiça", + "TL-MT": "Manatuto", + "TL-MF": "Manufahi", + "TL-OE": "Oé-Cusse Ambeno", + "TL-VI": "Viqueque", + "TG-C": "Centrale", + "TG-K": "Kara", + "TG-M": "Maritime", + "TG-P": "Plateaux", + "TG-S": "Savanes", + "TO-01": "'Eua", + "TO-02": "Ha'apai", + "TO-03": "Niuas", + "TO-04": "Tongatapu", + "TO-05": "Vava'u", + "TT-ARI": "Arima", + "TT-CHA": "Chaguanas", + "TT-CTT": "Couva-Tabaquite-Talparo", + "TT-DMN": "Diego Martin", + "TT-MRC": "Mayaro-Rio Claro", + "TT-PED": "Penal-Debe", + "TT-POS": "Port of Spain", + "TT-PRT": "Princes Town", + "TT-PTF": "Point Fortin", + "TT-SFO": "San Fernando", + "TT-SGE": "Sangre Grande", + "TT-SIP": "Siparia", + "TT-SJL": "San Juan-Laventille", + "TT-TOB": "Tobago", + "TT-TUP": "Tunapuna-Piarco", + "TN-31": "Béja", + "TN-13": "Ben Arous", + "TN-23": "Bizerte", + "TN-81": "Gabès", + "TN-71": "Gafsa", + "TN-32": "Jendouba", + "TN-41": "Kairouan", + "TN-42": "Kasserine", + "TN-73": "Kébili", + "TN-12": "L'Ariana", + "TN-14": "La Manouba", + "TN-33": "Le Kef", + "TN-53": "Mahdia", + "TN-82": "Médenine", + "TN-52": "Monastir", + "TN-21": "Nabeul", + "TN-61": "Sfax", + "TN-43": "Sidi Bouzid", + "TN-34": "Siliana", + "TN-51": "Sousse", + "TN-83": "Tataouine", + "TN-72": "Tozeur", + "TN-11": "Tunis", + "TN-22": "Zaghouan", + "TR-01": "Adana", + "TR-02": "Adıyaman", + "TR-03": "Afyonkarahisar", + "TR-04": "Ağrı", + "TR-68": "Aksaray", + "TR-05": "Amasya", + "TR-06": "Ankara", + "TR-07": "Antalya", + "TR-75": "Ardahan", + "TR-08": "Artvin", + "TR-09": "Aydın", + "TR-10": "Balıkesir", + "TR-74": "Bartın", + "TR-72": "Batman", + "TR-69": "Bayburt", + "TR-11": "Bilecik", + "TR-12": "Bingöl", + "TR-13": "Bitlis", + "TR-14": "Bolu", + "TR-15": "Burdur", + "TR-16": "Bursa", + "TR-17": "Çanakkale", + "TR-18": "Çankırı", + "TR-19": "Çorum", + "TR-20": "Denizli", + "TR-21": "Diyarbakır", + "TR-81": "Düzce", + "TR-22": "Edirne", + "TR-23": "Elazığ", + "TR-24": "Erzincan", + "TR-25": "Erzurum", + "TR-26": "Eskişehir", + "TR-27": "Gaziantep", + "TR-28": "Giresun", + "TR-29": "Gümüşhane", + "TR-30": "Hakkâri", + "TR-31": "Hatay", + "TR-76": "Iğdır", + "TR-32": "Isparta", + "TR-34": "İstanbul", + "TR-35": "İzmir", + "TR-46": "Kahramanmaraş", + "TR-78": "Karabük", + "TR-70": "Karaman", + "TR-36": "Kars", + "TR-37": "Kastamonu", + "TR-38": "Kayseri", + "TR-71": "Kırıkkale", + "TR-39": "Kırklareli", + "TR-40": "Kırşehir", + "TR-79": "Kilis", + "TR-41": "Kocaeli", + "TR-42": "Konya", + "TR-43": "Kütahya", + "TR-44": "Malatya", + "TR-45": "Manisa", + "TR-47": "Mardin", + "TR-33": "Mersin", + "TR-48": "Muğla", + "TR-49": "Muş", + "TR-50": "Nevşehir", + "TR-51": "Niğde", + "TR-52": "Ordu", + "TR-80": "Osmaniye", + "TR-53": "Rize", + "TR-54": "Sakarya", + "TR-55": "Samsun", + "TR-56": "Siirt", + "TR-57": "Sinop", + "TR-58": "Sivas", + "TR-63": "Şanlıurfa", + "TR-73": "Şırnak", + "TR-59": "Tekirdağ", + "TR-60": "Tokat", + "TR-61": "Trabzon", + "TR-62": "Tunceli", + "TR-64": "Uşak", + "TR-65": "Van", + "TR-77": "Yalova", + "TR-66": "Yozgat", + "TR-67": "Zonguldak", + "TM-A": "Ahal", + "TM-S": "Aşgabat", + "TM-B": "Balkan", + "TM-D": "Daşoguz", + "TM-L": "Lebap", + "TM-M": "Mary", + "TV-FUN": "Funafuti", + "TV-NMG": "Nanumaga", + "TV-NMA": "Nanumea", + "TV-NIT": "Niutao", + "TV-NUI": "Nui", + "TV-NKF": "Nukufetau", + "TV-NKL": "Nukulaelae", + "TV-VAI": "Vaitupu", + "UG-C": "Central", + "UG-E": "Eastern", + "UG-N": "Northern", + "UG-W": "Western", + "UA-43": "Avtonomna Respublika Krym", + "UA-71": "Cherkaska oblast", + "UA-74": "Chernihivska oblast", + "UA-77": "Chernivetska oblast", + "UA-12": "Dnipropetrovska oblast", + "UA-14": "Donetska oblast", + "UA-26": "Ivano-Frankivska oblast", + "UA-63": "Kharkivska oblast", + "UA-65": "Khersonska oblast", + "UA-68": "Khmelnytska oblast", + "UA-35": "Kirovohradska oblast", + "UA-30": "Kyiv", + "UA-32": "Kyivska oblast", + "UA-09": "Luhanska oblast", + "UA-46": "Lvivska oblast", + "UA-48": "Mykolaivska oblast", + "UA-51": "Odeska oblast", + "UA-53": "Poltavska oblast", + "UA-56": "Rivnenska oblast", + "UA-40": "Sevastopol", + "UA-59": "Sumska oblast", + "UA-61": "Ternopilska oblast", + "UA-05": "Vinnytska oblast", + "UA-07": "Volynska oblast", + "UA-21": "Zakarpatska oblast", + "UA-23": "Zaporizka oblast", + "UA-18": "Zhytomyrska oblast", + "AE-AJ": "‘Ajmān", + "AE-AZ": "Abū Z̧aby", + "AE-FU": "Al Fujayrah", + "AE-SH": "Ash Shāriqah", + "AE-DU": "Dubayy", + "AE-RK": "Ra’s al Khaymah", + "AE-UQ": "Umm al Qaywayn", + "GB-ENG": "England", + "GB-NIR": "Northern Ireland", + "GB-SCT": "Scotland", + "GB-WLS": "Wales", + "US-AL": "Alabama", + "US-AK": "Alaska", + "US-AZ": "Arizona", + "US-AR": "Arkansas", + "US-CA": "California", + "US-CO": "Colorado", + "US-CT": "Connecticut", + "US-DE": "Delaware", + "US-FL": "Florida", + "US-GA": "Georgia", + "US-HI": "Hawaii", + "US-ID": "Idaho", + "US-IL": "Illinois", + "US-IN": "Indiana", + "US-IA": "Iowa", + "US-KS": "Kansas", + "US-KY": "Kentucky", + "US-LA": "Louisiana", + "US-ME": "Maine", + "US-MD": "Maryland", + "US-MA": "Massachusetts", + "US-MI": "Michigan", + "US-MN": "Minnesota", + "US-MS": "Mississippi", + "US-MO": "Missouri", + "US-MT": "Montana", + "US-NE": "Nebraska", + "US-NV": "Nevada", + "US-NH": "New Hampshire", + "US-NJ": "New Jersey", + "US-NM": "New Mexico", + "US-NY": "New York", + "US-NC": "North Carolina", + "US-ND": "North Dakota", + "US-OH": "Ohio", + "US-OK": "Oklahoma", + "US-OR": "Oregon", + "US-PA": "Pennsylvania", + "US-RI": "Rhode Island", + "US-SC": "South Carolina", + "US-SD": "South Dakota", + "US-TN": "Tennessee", + "US-TX": "Texas", + "US-UT": "Utah", + "US-VT": "Vermont", + "US-VA": "Virginia", + "US-WA": "Washington", + "US-WV": "West Virginia", + "US-WI": "Wisconsin", + "US-WY": "Wyoming", + "US-DC": "District of Columbia", + "US-AS": "American Samoa", + "US-GU": "Guam", + "US-MP": "Northern Mariana Islands", + "US-PR": "Puerto Rico", + "US-UM": "United States Minor Outlying Islands", + "US-VI": "Virgin Islands", + "UM-81": "Baker Island", + "UM-84": "Howland Island", + "UM-86": "Jarvis Island", + "UM-67": "Johnston Atoll", + "UM-89": "Kingman Reef", + "UM-71": "Midway Islands", + "UM-76": "Navassa Island", + "UM-95": "Palmyra Atoll", + "UM-79": "Wake Island", + "UY-AR": "Artigas", + "UY-CA": "Canelones", + "UY-CL": "Cerro Largo", + "UY-CO": "Colonia", + "UY-DU": "Durazno", + "UY-FS": "Flores", + "UY-FD": "Florida", + "UY-LA": "Lavalleja", + "UY-MA": "Maldonado", + "UY-MO": "Montevideo", + "UY-PA": "Paysandú", + "UY-RN": "Río Negro", + "UY-RV": "Rivera", + "UY-RO": "Rocha", + "UY-SA": "Salto", + "UY-SJ": "San José", + "UY-SO": "Soriano", + "UY-TA": "Tacuarembó", + "UY-TT": "Treinta y Tres", + "UZ-AN": "Andijon", + "UZ-BU": "Buxoro", + "UZ-FA": "Farg‘ona", + "UZ-JI": "Jizzax", + "UZ-NG": "Namangan", + "UZ-NW": "Navoiy", + "UZ-QA": "Qashqadaryo", + "UZ-QR": "Qoraqalpog‘iston Respublikasi", + "UZ-SA": "Samarqand", + "UZ-SI": "Sirdaryo", + "UZ-SU": "Surxondaryo", + "UZ-TK": "Toshkent", + "UZ-TO": "Toshkent", + "UZ-XO": "Xorazm", + "VU-MAP": "Malampa", + "VU-PAM": "Pénama", + "VU-SAM": "Sanma", + "VU-SEE": "Shéfa", + "VU-TAE": "Taféa", + "VU-TOB": "Torba", + "VE-Z": "Amazonas", + "VE-B": "Anzoátegui", + "VE-C": "Apure", + "VE-D": "Aragua", + "VE-E": "Barinas", + "VE-F": "Bolívar", + "VE-G": "Carabobo", + "VE-H": "Cojedes", + "VE-Y": "Delta Amacuro", + "VE-W": "Dependencias Federales", + "VE-A": "Distrito Capital", + "VE-I": "Falcón", + "VE-J": "Guárico", + "VE-X": "La Guaira", + "VE-K": "Lara", + "VE-L": "Mérida", + "VE-M": "Miranda", + "VE-N": "Monagas", + "VE-O": "Nueva Esparta", + "VE-P": "Portuguesa", + "VE-R": "Sucre", + "VE-S": "Táchira", + "VE-T": "Trujillo", + "VE-U": "Yaracuy", + "VE-V": "Zulia", + "VN-44": "An Giang", + "VN-43": "Bà Rịa - Vũng Tàu", + "VN-54": "Bắc Giang", + "VN-53": "Bắc Kạn", + "VN-55": "Bạc Liêu", + "VN-56": "Bắc Ninh", + "VN-50": "Bến Tre", + "VN-31": "Bình Định", + "VN-57": "Bình Dương", + "VN-58": "Bình Phước", + "VN-40": "Bình Thuận", + "VN-59": "Cà Mau", + "VN-CT": "Cần Thơ", + "VN-04": "Cao Bằng", + "VN-DN": "Đà Nẵng", + "VN-33": "Đắk Lắk", + "VN-72": "Đắk Nông", + "VN-71": "Điện Biên", + "VN-39": "Đồng Nai", + "VN-45": "Đồng Tháp", + "VN-30": "Gia Lai", + "VN-03": "Hà Giang", + "VN-63": "Hà Nam", + "VN-HN": "Hà Nội", + "VN-23": "Hà Tĩnh", + "VN-61": "Hải Dương", + "VN-HP": "Hải Phòng", + "VN-73": "Hậu Giang", + "VN-SG": "Hồ Chí Minh", + "VN-14": "Hòa Bình", + "VN-66": "Hưng Yên", + "VN-34": "Khánh Hòa", + "VN-47": "Kiến Giang", + "VN-28": "Kon Tum", + "VN-01": "Lai Châu", + "VN-35": "Lâm Đồng", + "VN-09": "Lạng Sơn", + "VN-02": "Lào Cai", + "VN-41": "Long An", + "VN-67": "Nam Định", + "VN-22": "Nghệ An", + "VN-18": "Ninh Bình", + "VN-36": "Ninh Thuận", + "VN-68": "Phú Thọ", + "VN-32": "Phú Yên", + "VN-24": "Quảng Bình", + "VN-27": "Quảng Nam", + "VN-29": "Quảng Ngãi", + "VN-13": "Quảng Ninh", + "VN-25": "Quảng Trị", + "VN-52": "Sóc Trăng", + "VN-05": "Sơn La", + "VN-37": "Tây Ninh", + "VN-20": "Thái Bình", + "VN-69": "Thái Nguyên", + "VN-21": "Thanh Hóa", + "VN-26": "Thừa Thiên-Huế", + "VN-46": "Tiền Giang", + "VN-51": "Trà Vinh", + "VN-07": "Tuyên Quang", + "VN-49": "Vĩnh Long", + "VN-70": "Vĩnh Phúc", + "VN-06": "Yên Bái", + "WF-AL": "Alo", + "WF-SG": "Sigave", + "WF-UV": "Uvea", + "YE-AD": "‘Adan", + "YE-AM": "‘Amrān", + "YE-AB": "Abyan", + "YE-DA": "Aḑ Ḑāli‘", + "YE-BA": "Al Bayḑā’", + "YE-HU": "Al Ḩudaydah", + "YE-JA": "Al Jawf", + "YE-MR": "Al Mahrah", + "YE-MW": "Al Maḩwīt", + "YE-SA": "Amānat al ‘Āşimah", + "YE-SU": "Arkhabīl Suquţrá", + "YE-DH": "Dhamār", + "YE-HD": "Ḩaḑramawt", + "YE-HJ": "Ḩajjah", + "YE-IB": "Ibb", + "YE-LA": "Laḩij", + "YE-MA": "Ma’rib", + "YE-RA": "Raymah", + "YE-SD": "Şāʻdah", + "YE-SN": "Şanʻā’", + "YE-SH": "Shabwah", + "YE-TA": "Tāʻizz", + "ZM-02": "Central", + "ZM-08": "Copperbelt", + "ZM-03": "Eastern", + "ZM-04": "Luapula", + "ZM-09": "Lusaka", + "ZM-10": "Muchinga", + "ZM-06": "North-Western", + "ZM-05": "Northern", + "ZM-07": "Southern", + "ZM-01": "Western", + "ZW-BU": "Bulawayo", + "ZW-HA": "Harare", + "ZW-MA": "Manicaland", + "ZW-MC": "Mashonaland Central", + "ZW-ME": "Mashonaland East", + "ZW-MW": "Mashonaland West", + "ZW-MV": "Masvingo", + "ZW-MN": "Matabeleland North", + "ZW-MS": "Matabeleland South", + "ZW-MI": "Midlands", + "BQ-BO": "Bonaire", + "BQ-SA": "Saba", + "BQ-SE": "Sint Eustatius", + "ME-01": "Andrijevica", + "ME-02": "Bar", + "ME-03": "Berane", + "ME-04": "Bijelo Polje", + "ME-05": "Budva", + "ME-06": "Cetinje", + "ME-07": "Danilovgrad", + "ME-22": "Gusinje", + "ME-08": "Herceg-Novi", + "ME-09": "Kolašin", + "ME-10": "Kotor", + "ME-11": "Mojkovac", + "ME-12": "Nikšić", + "ME-23": "Petnjica", + "ME-13": "Plav", + "ME-14": "Pljevlja", + "ME-15": "Plužine", + "ME-16": "Podgorica", + "ME-17": "Rožaje", + "ME-18": "Šavnik", + "ME-19": "Tivat", + "ME-24": "Tuzi", + "ME-20": "Ulcinj", + "ME-21": "Žabljak", + "ME-25": "Zeta", + "RS-KM": "Kosovo-Metohija[1]", + "RS-VO": "Vojvodina", + "SS-EC": "Central Equatoria", + "SS-EE": "Eastern Equatoria", + "SS-JG": "Jonglei", + "SS-LK": "Lakes", + "SS-BN": "Northern Bahr el Ghazal", + "SS-UY": "Unity", + "SS-NU": "Upper Nile", + "SS-WR": "Warrap", + "SS-BW": "Western Bahr el Ghazal", + "SS-EW": "Western Equatoria", }; export const CONTINENTS = { - AF: "Africa", - AN: "Antarctica", - AS: "Asia", - EU: "Europe", - NA: "North America", - OC: "Oceania", - SA: "South America", + AF: "Africa", + AN: "Antarctica", + AS: "Asia", + EU: "Europe", + NA: "North America", + OC: "Oceania", + SA: "South America", }; export const CONTINENT_CODES = Object.keys(CONTINENTS); export const COUNTRIES_TO_CONTINENTS = { - AF: "AS", // Afghanistan - Asia - AL: "EU", // Albania - Europe - DZ: "AF", // Algeria - Africa - AS: "OC", // American Samoa - Oceania - AD: "EU", // Andorra - Europe - AO: "AF", // Angola - Africa - AI: "NA", // Anguilla - North America - AQ: "AN", // Antarctica - Antarctica - AG: "NA", // Antigua and Barbuda - North America - AR: "SA", // Argentina - South America - AM: "AS", // Armenia - Asia - AW: "NA", // Aruba - North America - AU: "OC", // Australia - Oceania - AT: "EU", // Austria - Europe - AZ: "AS", // Azerbaijan - Asia - BS: "NA", // Bahamas - North America - BH: "AS", // Bahrain - Asia - BD: "AS", // Bangladesh - Asia - BB: "NA", // Barbados - North America - BY: "EU", // Belarus - Europe - BE: "EU", // Belgium - Europe - BZ: "NA", // Belize - North America - BJ: "AF", // Benin - Africa - BM: "NA", // Bermuda - North America - BT: "AS", // Bhutan - Asia - BO: "SA", // Bolivia - South America - BA: "EU", // Bosnia and Herzegovina - Europe - BW: "AF", // Botswana - Africa - BV: "AN", // Bouvet Island - Antarctica - BR: "SA", // Brazil - South America - IO: "AS", // British Indian Ocean Territory - Asia - BN: "AS", // Brunei Darussalam - Asia - BG: "EU", // Bulgaria - Europe - BF: "AF", // Burkina Faso - Africa - BI: "AF", // Burundi - Africa - KH: "AS", // Cambodia - Asia - CM: "AF", // Cameroon - Africa - CA: "NA", // Canada - North America - CV: "AF", // Cape Verde - Africa - KY: "NA", // Cayman Islands - North America - CF: "AF", // Central African Republic - Africa - TD: "AF", // Chad - Africa - CL: "SA", // Chile - South America - CN: "AS", // China - Asia - CX: "AS", // Christmas Island - Asia - CC: "AS", // Cocos (Keeling) Islands - Asia - CO: "SA", // Colombia - South America - KM: "AF", // Comoros - Africa - CG: "AF", // Congo (Republic) - Africa - CD: "AF", // Congo (Democratic Republic) - Africa - CK: "OC", // Cook Islands - Oceania - CR: "NA", // Costa Rica - North America - CI: "AF", // Ivory Coast - Africa - HR: "EU", // Croatia - Europe - CU: "NA", // Cuba - North America - CY: "AS", // Cyprus - Asia - CZ: "EU", // Czech Republic - Europe - DK: "EU", // Denmark - Europe - DJ: "AF", // Djibouti - Africa - DM: "NA", // Dominica - North America - DO: "NA", // Dominican Republic - North America - EC: "SA", // Ecuador - South America - EG: "AF", // Egypt - Africa - SV: "NA", // El Salvador - North America - GQ: "AF", // Equatorial Guinea - Africa - ER: "AF", // Eritrea - Africa - EE: "EU", // Estonia - Europe - ET: "AF", // Ethiopia - Africa - FK: "SA", // Falkland Islands - South America - FO: "EU", // Faroe Islands - Europe - FJ: "OC", // Fiji - Oceania - FI: "EU", // Finland - Europe - FR: "EU", // France - Europe - GF: "SA", // French Guiana - South America - PF: "OC", // French Polynesia - Oceania - TF: "AN", // French Southern Territories - Antarctica - GA: "AF", // Gabon - Africa - GM: "AF", // Gambia - Africa - GE: "AS", // Georgia - Asia - DE: "EU", // Germany - Europe - GH: "AF", // Ghana - Africa - GI: "EU", // Gibraltar - Europe - GR: "EU", // Greece - Europe - GL: "NA", // Greenland - North America - GD: "NA", // Grenada - North America - GP: "NA", // Guadeloupe - North America - GU: "OC", // Guam - Oceania - GT: "NA", // Guatemala - North America - GN: "AF", // Guinea - Africa - GW: "AF", // Guinea-Bissau - Africa - GY: "SA", // Guyana - South America - HT: "NA", // Haiti - North America - HM: "AN", // Heard Island and McDonald Islands - Antarctica - VA: "EU", // Vatican City - Europe - HN: "NA", // Honduras - North America - HK: "AS", // Hong Kong - Asia - HU: "EU", // Hungary - Europe - IS: "EU", // Iceland - Europe - IN: "AS", // India - Asia - ID: "AS", // Indonesia - Asia - IR: "AS", // Iran - Asia - IQ: "AS", // Iraq - Asia - IE: "EU", // Ireland - Europe - IL: "AS", // Israel - Asia - IT: "EU", // Italy - Europe - JM: "NA", // Jamaica - North America - JP: "AS", // Japan - Asia - JO: "AS", // Jordan - Asia - KZ: "AS", // Kazakhstan - Asia - KE: "AF", // Kenya - Africa - KI: "OC", // Kiribati - Oceania - KP: "AS", // North Korea - Asia - KR: "AS", // South Korea - Asia - KW: "AS", // Kuwait - Asia - KG: "AS", // Kyrgyzstan - Asia - LA: "AS", // Laos - Asia - LV: "EU", // Latvia - Europe - LB: "AS", // Lebanon - Asia - LS: "AF", // Lesotho - Africa - LR: "AF", // Liberia - Africa - LY: "AF", // Libya - Africa - LI: "EU", // Liechtenstein - Europe - LT: "EU", // Lithuania - Europe - LU: "EU", // Luxembourg - Europe - MO: "AS", // Macao - Asia - MG: "AF", // Madagascar - Africa - MW: "AF", // Malawi - Africa - MY: "AS", // Malaysia - Asia - MV: "AS", // Maldives - Asia - ML: "AF", // Mali - Africa - MT: "EU", // Malta - Europe - MH: "OC", // Marshall Islands - Oceania - MQ: "NA", // Martinique - North America - MR: "AF", // Mauritania - Africa - MU: "AF", // Mauritius - Africa - YT: "AF", // Mayotte - Africa - MX: "NA", // Mexico - North America - FM: "OC", // Micronesia - Oceania - MD: "EU", // Moldova - Europe - MC: "EU", // Monaco - Europe - MN: "AS", // Mongolia - Asia - MS: "NA", // Montserrat - North America - MA: "AF", // Morocco - Africa - MZ: "AF", // Mozambique - Africa - MM: "AS", // Myanmar - Asia - NA: "AF", // Namibia - Africa - NR: "OC", // Nauru - Oceania - NP: "AS", // Nepal - Asia - NL: "EU", // Netherlands - Europe - NC: "OC", // New Caledonia - Oceania - NZ: "OC", // New Zealand - Oceania - NI: "NA", // Nicaragua - North America - NE: "AF", // Niger - Africa - NG: "AF", // Nigeria - Africa - NU: "OC", // Niue - Oceania - NF: "OC", // Norfolk Island - Oceania - MK: "EU", // Macedonia - Europe - MP: "OC", // Northern Mariana Islands - Oceania - NO: "EU", // Norway - Europe - OM: "AS", // Oman - Asia - PK: "AS", // Pakistan - Asia - PW: "OC", // Palau - Oceania - PS: "AS", // Palestine - Asia - PA: "NA", // Panama - North America - PG: "OC", // Papua New Guinea - Oceania - PY: "SA", // Paraguay - South America - PE: "SA", // Peru - South America - PH: "AS", // Philippines - Asia - PN: "OC", // Pitcairn - Oceania - PL: "EU", // Poland - Europe - PT: "EU", // Portugal - Europe - PR: "NA", // Puerto Rico - North America - QA: "AS", // Qatar - Asia - RE: "AF", // Reunion - Africa - RO: "EU", // Romania - Europe - RU: "EU", // Russia - Europe - RW: "AF", // Rwanda - Africa - SH: "AF", // Saint Helena - Africa - KN: "NA", // Saint Kitts and Nevis - North America - LC: "NA", // Saint Lucia - North America - PM: "NA", // Saint Pierre and Miquelon - North America - VC: "NA", // Saint Vincent and the Grenadines - North America - WS: "OC", // Samoa - Oceania - SM: "EU", // San Marino - Europe - ST: "AF", // Sao Tome and Principe - Africa - SA: "AS", // Saudi Arabia - Asia - SN: "AF", // Senegal - Africa - SC: "AF", // Seychelles - Africa - SL: "AF", // Sierra Leone - Africa - SG: "AS", // Singapore - Asia - SK: "EU", // Slovakia - Europe - SI: "EU", // Slovenia - Europe - SB: "OC", // Solomon Islands - Oceania - SO: "AF", // Somalia - Africa - ZA: "AF", // South Africa - Africa - GS: "AN", // South Georgia and the South Sandwich Islands - Antarctica - ES: "EU", // Spain - Europe - LK: "AS", // Sri Lanka - Asia - SD: "AF", // Sudan - Africa - SR: "SA", // Suriname - South America - SJ: "EU", // Svalbard and Jan Mayen - Europe - SZ: "AF", // Eswatini - Africa - SE: "EU", // Sweden - Europe - CH: "EU", // Switzerland - Europe - SY: "AS", // Syrian Arab Republic - Asia - TW: "AS", // Taiwan - Asia - TJ: "AS", // Tajikistan - Asia - TZ: "AF", // Tanzania - Africa - TH: "AS", // Thailand - Asia - TL: "AS", // Timor-Leste - Asia - TG: "AF", // Togo - Africa - TK: "OC", // Tokelau - Oceania - TO: "OC", // Tonga - Oceania - TT: "NA", // Trinidad and Tobago - North America - TN: "AF", // Tunisia - Africa - TR: "AS", // Turkey - Asia - TM: "AS", // Turkmenistan - Asia - TC: "NA", // Turks and Caicos Islands - North America - TV: "OC", // Tuvalu - Oceania - UG: "AF", // Uganda - Africa - UA: "EU", // Ukraine - Europe - AE: "AS", // United Arab Emirates - Asia - GB: "EU", // United Kingdom - Europe - US: "NA", // United States - North America - UM: "OC", // United States Minor Outlying Islands - Oceania - UY: "SA", // Uruguay - South America - UZ: "AS", // Uzbekistan - Asia - VU: "OC", // Vanuatu - Oceania - VE: "SA", // Venezuela - South America - VN: "AS", // Vietnam - Asia - VG: "NA", // Virgin Islands, British - North America - VI: "NA", // Virgin Islands, U.S. - North America - WF: "OC", // Wallis and Futuna - Oceania - EH: "AF", // Western Sahara - Africa - YE: "AS", // Yemen - Asia - ZM: "AF", // Zambia - Africa - ZW: "AF", // Zimbabwe - Africa - AX: "EU", // Åland Islands - Europe - BQ: "NA", // Bonaire, Sint Eustatius and Saba - North America - CW: "NA", // Curaçao - North America - GG: "EU", // Guernsey - Europe - IM: "EU", // Isle of Man - Europe - JE: "EU", // Jersey - Europe - ME: "EU", // Montenegro - Europe - BL: "NA", // Saint Barthélemy - North America - MF: "NA", // Saint Martin (French part) - North America - RS: "EU", // Serbia - Europe - SX: "NA", // Sint Maarten (Dutch part) - North America - SS: "AF", // South Sudan - Africa - XK: "EU", // Kosovo - Europe + AF: "AS", // Afghanistan - Asia + AL: "EU", // Albania - Europe + DZ: "AF", // Algeria - Africa + AS: "OC", // American Samoa - Oceania + AD: "EU", // Andorra - Europe + AO: "AF", // Angola - Africa + AI: "NA", // Anguilla - North America + AQ: "AN", // Antarctica - Antarctica + AG: "NA", // Antigua and Barbuda - North America + AR: "SA", // Argentina - South America + AM: "AS", // Armenia - Asia + AW: "NA", // Aruba - North America + AU: "OC", // Australia - Oceania + AT: "EU", // Austria - Europe + AZ: "AS", // Azerbaijan - Asia + BS: "NA", // Bahamas - North America + BH: "AS", // Bahrain - Asia + BD: "AS", // Bangladesh - Asia + BB: "NA", // Barbados - North America + BY: "EU", // Belarus - Europe + BE: "EU", // Belgium - Europe + BZ: "NA", // Belize - North America + BJ: "AF", // Benin - Africa + BM: "NA", // Bermuda - North America + BT: "AS", // Bhutan - Asia + BO: "SA", // Bolivia - South America + BA: "EU", // Bosnia and Herzegovina - Europe + BW: "AF", // Botswana - Africa + BV: "AN", // Bouvet Island - Antarctica + BR: "SA", // Brazil - South America + IO: "AS", // British Indian Ocean Territory - Asia + BN: "AS", // Brunei Darussalam - Asia + BG: "EU", // Bulgaria - Europe + BF: "AF", // Burkina Faso - Africa + BI: "AF", // Burundi - Africa + KH: "AS", // Cambodia - Asia + CM: "AF", // Cameroon - Africa + CA: "NA", // Canada - North America + CV: "AF", // Cape Verde - Africa + KY: "NA", // Cayman Islands - North America + CF: "AF", // Central African Republic - Africa + TD: "AF", // Chad - Africa + CL: "SA", // Chile - South America + CN: "AS", // China - Asia + CX: "AS", // Christmas Island - Asia + CC: "AS", // Cocos (Keeling) Islands - Asia + CO: "SA", // Colombia - South America + KM: "AF", // Comoros - Africa + CG: "AF", // Congo (Republic) - Africa + CD: "AF", // Congo (Democratic Republic) - Africa + CK: "OC", // Cook Islands - Oceania + CR: "NA", // Costa Rica - North America + CI: "AF", // Ivory Coast - Africa + HR: "EU", // Croatia - Europe + CU: "NA", // Cuba - North America + CY: "AS", // Cyprus - Asia + CZ: "EU", // Czech Republic - Europe + DK: "EU", // Denmark - Europe + DJ: "AF", // Djibouti - Africa + DM: "NA", // Dominica - North America + DO: "NA", // Dominican Republic - North America + EC: "SA", // Ecuador - South America + EG: "AF", // Egypt - Africa + SV: "NA", // El Salvador - North America + GQ: "AF", // Equatorial Guinea - Africa + ER: "AF", // Eritrea - Africa + EE: "EU", // Estonia - Europe + ET: "AF", // Ethiopia - Africa + FK: "SA", // Falkland Islands - South America + FO: "EU", // Faroe Islands - Europe + FJ: "OC", // Fiji - Oceania + FI: "EU", // Finland - Europe + FR: "EU", // France - Europe + GF: "SA", // French Guiana - South America + PF: "OC", // French Polynesia - Oceania + TF: "AN", // French Southern Territories - Antarctica + GA: "AF", // Gabon - Africa + GM: "AF", // Gambia - Africa + GE: "AS", // Georgia - Asia + DE: "EU", // Germany - Europe + GH: "AF", // Ghana - Africa + GI: "EU", // Gibraltar - Europe + GR: "EU", // Greece - Europe + GL: "NA", // Greenland - North America + GD: "NA", // Grenada - North America + GP: "NA", // Guadeloupe - North America + GU: "OC", // Guam - Oceania + GT: "NA", // Guatemala - North America + GN: "AF", // Guinea - Africa + GW: "AF", // Guinea-Bissau - Africa + GY: "SA", // Guyana - South America + HT: "NA", // Haiti - North America + HM: "AN", // Heard Island and McDonald Islands - Antarctica + VA: "EU", // Vatican City - Europe + HN: "NA", // Honduras - North America + HK: "AS", // Hong Kong - Asia + HU: "EU", // Hungary - Europe + IS: "EU", // Iceland - Europe + IN: "AS", // India - Asia + ID: "AS", // Indonesia - Asia + IR: "AS", // Iran - Asia + IQ: "AS", // Iraq - Asia + IE: "EU", // Ireland - Europe + IL: "AS", // Israel - Asia + IT: "EU", // Italy - Europe + JM: "NA", // Jamaica - North America + JP: "AS", // Japan - Asia + JO: "AS", // Jordan - Asia + KZ: "AS", // Kazakhstan - Asia + KE: "AF", // Kenya - Africa + KI: "OC", // Kiribati - Oceania + KP: "AS", // North Korea - Asia + KR: "AS", // South Korea - Asia + KW: "AS", // Kuwait - Asia + KG: "AS", // Kyrgyzstan - Asia + LA: "AS", // Laos - Asia + LV: "EU", // Latvia - Europe + LB: "AS", // Lebanon - Asia + LS: "AF", // Lesotho - Africa + LR: "AF", // Liberia - Africa + LY: "AF", // Libya - Africa + LI: "EU", // Liechtenstein - Europe + LT: "EU", // Lithuania - Europe + LU: "EU", // Luxembourg - Europe + MO: "AS", // Macao - Asia + MG: "AF", // Madagascar - Africa + MW: "AF", // Malawi - Africa + MY: "AS", // Malaysia - Asia + MV: "AS", // Maldives - Asia + ML: "AF", // Mali - Africa + MT: "EU", // Malta - Europe + MH: "OC", // Marshall Islands - Oceania + MQ: "NA", // Martinique - North America + MR: "AF", // Mauritania - Africa + MU: "AF", // Mauritius - Africa + YT: "AF", // Mayotte - Africa + MX: "NA", // Mexico - North America + FM: "OC", // Micronesia - Oceania + MD: "EU", // Moldova - Europe + MC: "EU", // Monaco - Europe + MN: "AS", // Mongolia - Asia + MS: "NA", // Montserrat - North America + MA: "AF", // Morocco - Africa + MZ: "AF", // Mozambique - Africa + MM: "AS", // Myanmar - Asia + NA: "AF", // Namibia - Africa + NR: "OC", // Nauru - Oceania + NP: "AS", // Nepal - Asia + NL: "EU", // Netherlands - Europe + NC: "OC", // New Caledonia - Oceania + NZ: "OC", // New Zealand - Oceania + NI: "NA", // Nicaragua - North America + NE: "AF", // Niger - Africa + NG: "AF", // Nigeria - Africa + NU: "OC", // Niue - Oceania + NF: "OC", // Norfolk Island - Oceania + MK: "EU", // Macedonia - Europe + MP: "OC", // Northern Mariana Islands - Oceania + NO: "EU", // Norway - Europe + OM: "AS", // Oman - Asia + PK: "AS", // Pakistan - Asia + PW: "OC", // Palau - Oceania + PS: "AS", // Palestine - Asia + PA: "NA", // Panama - North America + PG: "OC", // Papua New Guinea - Oceania + PY: "SA", // Paraguay - South America + PE: "SA", // Peru - South America + PH: "AS", // Philippines - Asia + PN: "OC", // Pitcairn - Oceania + PL: "EU", // Poland - Europe + PT: "EU", // Portugal - Europe + PR: "NA", // Puerto Rico - North America + QA: "AS", // Qatar - Asia + RE: "AF", // Reunion - Africa + RO: "EU", // Romania - Europe + RU: "EU", // Russia - Europe + RW: "AF", // Rwanda - Africa + SH: "AF", // Saint Helena - Africa + KN: "NA", // Saint Kitts and Nevis - North America + LC: "NA", // Saint Lucia - North America + PM: "NA", // Saint Pierre and Miquelon - North America + VC: "NA", // Saint Vincent and the Grenadines - North America + WS: "OC", // Samoa - Oceania + SM: "EU", // San Marino - Europe + ST: "AF", // Sao Tome and Principe - Africa + SA: "AS", // Saudi Arabia - Asia + SN: "AF", // Senegal - Africa + SC: "AF", // Seychelles - Africa + SL: "AF", // Sierra Leone - Africa + SG: "AS", // Singapore - Asia + SK: "EU", // Slovakia - Europe + SI: "EU", // Slovenia - Europe + SB: "OC", // Solomon Islands - Oceania + SO: "AF", // Somalia - Africa + ZA: "AF", // South Africa - Africa + GS: "AN", // South Georgia and the South Sandwich Islands - Antarctica + ES: "EU", // Spain - Europe + LK: "AS", // Sri Lanka - Asia + SD: "AF", // Sudan - Africa + SR: "SA", // Suriname - South America + SJ: "EU", // Svalbard and Jan Mayen - Europe + SZ: "AF", // Eswatini - Africa + SE: "EU", // Sweden - Europe + CH: "EU", // Switzerland - Europe + SY: "AS", // Syrian Arab Republic - Asia + TW: "AS", // Taiwan - Asia + TJ: "AS", // Tajikistan - Asia + TZ: "AF", // Tanzania - Africa + TH: "AS", // Thailand - Asia + TL: "AS", // Timor-Leste - Asia + TG: "AF", // Togo - Africa + TK: "OC", // Tokelau - Oceania + TO: "OC", // Tonga - Oceania + TT: "NA", // Trinidad and Tobago - North America + TN: "AF", // Tunisia - Africa + TR: "AS", // Turkey - Asia + TM: "AS", // Turkmenistan - Asia + TC: "NA", // Turks and Caicos Islands - North America + TV: "OC", // Tuvalu - Oceania + UG: "AF", // Uganda - Africa + UA: "EU", // Ukraine - Europe + AE: "AS", // United Arab Emirates - Asia + GB: "EU", // United Kingdom - Europe + US: "NA", // United States - North America + UM: "OC", // United States Minor Outlying Islands - Oceania + UY: "SA", // Uruguay - South America + UZ: "AS", // Uzbekistan - Asia + VU: "OC", // Vanuatu - Oceania + VE: "SA", // Venezuela - South America + VN: "AS", // Vietnam - Asia + VG: "NA", // Virgin Islands, British - North America + VI: "NA", // Virgin Islands, U.S. - North America + WF: "OC", // Wallis and Futuna - Oceania + EH: "AF", // Western Sahara - Africa + YE: "AS", // Yemen - Asia + ZM: "AF", // Zambia - Africa + ZW: "AF", // Zimbabwe - Africa + AX: "EU", // Åland Islands - Europe + BQ: "NA", // Bonaire, Sint Eustatius and Saba - North America + CW: "NA", // Curaçao - North America + GG: "EU", // Guernsey - Europe + IM: "EU", // Isle of Man - Europe + JE: "EU", // Jersey - Europe + ME: "EU", // Montenegro - Europe + BL: "NA", // Saint Barthélemy - North America + MF: "NA", // Saint Martin (French part) - North America + RS: "EU", // Serbia - Europe + SX: "NA", // Sint Maarten (Dutch part) - North America + SS: "AF", // South Sudan - Africa + XK: "EU", // Kosovo - Europe }; export const COUNTRIES = { - AF: "Afghanistan", - AL: "Albania", - DZ: "Algeria", - AS: "American Samoa", - AD: "Andorra", - AO: "Angola", - AI: "Anguilla", - AQ: "Antarctica", - AG: "Antigua and Barbuda", - AR: "Argentina", - AM: "Armenia", - AW: "Aruba", - AU: "Australia", - AT: "Austria", - AZ: "Azerbaijan", - BS: "Bahamas", - BH: "Bahrain", - BD: "Bangladesh", - BB: "Barbados", - BY: "Belarus", - BE: "Belgium", - BZ: "Belize", - BJ: "Benin", - BM: "Bermuda", - BT: "Bhutan", - BO: "Bolivia", - BA: "Bosnia and Herzegovina", - BW: "Botswana", - BV: "Bouvet Island", - BR: "Brazil", - IO: "British Indian Ocean Territory", - BN: "Brunei Darussalam", - BG: "Bulgaria", - BF: "Burkina Faso", - BI: "Burundi", - KH: "Cambodia", - CM: "Cameroon", - CA: "Canada", - CV: "Cape Verde", - KY: "Cayman Islands", - CF: "Central African Republic", - TD: "Chad", - CL: "Chile", - CN: "China", - CX: "Christmas Island", - CC: "Cocos (Keeling) Islands", - CO: "Colombia", - KM: "Comoros", - CG: "Congo (Republic)", - CD: "Congo (Democratic Republic)", - CK: "Cook Islands", - CR: "Costa Rica", - CI: "Ivory Coast", - HR: "Croatia", - CU: "Cuba", - CY: "Cyprus", - CZ: "Czech Republic", - DK: "Denmark", - DJ: "Djibouti", - DM: "Dominica", - DO: "Dominican Republic", - EC: "Ecuador", - EG: "Egypt", - SV: "El Salvador", - GQ: "Equatorial Guinea", - ER: "Eritrea", - EE: "Estonia", - ET: "Ethiopia", - FK: "Falkland Islands", - FO: "Faroe Islands", - FJ: "Fiji", - FI: "Finland", - FR: "France", - GF: "French Guiana", - PF: "French Polynesia", - TF: "French Southern Territories", - GA: "Gabon", - GM: "Gambia", - GE: "Georgia", - DE: "Germany", - GH: "Ghana", - GI: "Gibraltar", - GR: "Greece", - GL: "Greenland", - GD: "Grenada", - GP: "Guadeloupe", - GU: "Guam", - GT: "Guatemala", - GN: "Guinea", - GW: "Guinea-Bissau", - GY: "Guyana", - HT: "Haiti", - HM: "Heard Island and McDonald Islands", - VA: "Vatican City", - HN: "Honduras", - HK: "Hong Kong", - HU: "Hungary", - IS: "Iceland", - IN: "India", - ID: "Indonesia", - IR: "Iran", - IQ: "Iraq", - IE: "Ireland", - IL: "Israel", - IT: "Italy", - JM: "Jamaica", - JP: "Japan", - JO: "Jordan", - KZ: "Kazakhstan", - KE: "Kenya", - KI: "Kiribati", - KP: "North Korea", - KR: "South Korea", - KW: "Kuwait", - KG: "Kyrgyzstan", - LA: "Laos", - LV: "Latvia", - LB: "Lebanon", - LS: "Lesotho", - LR: "Liberia", - LY: "Libya", - LI: "Liechtenstein", - LT: "Lithuania", - LU: "Luxembourg", - MO: "Macao", - MG: "Madagascar", - MW: "Malawi", - MY: "Malaysia", - MV: "Maldives", - ML: "Mali", - MT: "Malta", - MH: "Marshall Islands", - MQ: "Martinique", - MR: "Mauritania", - MU: "Mauritius", - YT: "Mayotte", - MX: "Mexico", - FM: "Micronesia", - MD: "Moldova", - MC: "Monaco", - MN: "Mongolia", - MS: "Montserrat", - MA: "Morocco", - MZ: "Mozambique", - MM: "Myanmar", - NA: "Namibia", - NR: "Nauru", - NP: "Nepal", - NL: "Netherlands", - NC: "New Caledonia", - NZ: "New Zealand", - NI: "Nicaragua", - NE: "Niger", - NG: "Nigeria", - NU: "Niue", - NF: "Norfolk Island", - MK: "Macedonia", - MP: "Northern Mariana Islands", - NO: "Norway", - OM: "Oman", - PK: "Pakistan", - PW: "Palau", - PS: "Palestine", - PA: "Panama", - PG: "Papua New Guinea", - PY: "Paraguay", - PE: "Peru", - PH: "Philippines", - PN: "Pitcairn", - PL: "Poland", - PT: "Portugal", - PR: "Puerto Rico", - QA: "Qatar", - RE: "Reunion", - RO: "Romania", - RU: "Russia", - RW: "Rwanda", - SH: "Saint Helena", - KN: "Saint Kitts and Nevis", - LC: "Saint Lucia", - PM: "Saint Pierre and Miquelon", - VC: "Saint Vincent and the Grenadines", - WS: "Samoa", - SM: "San Marino", - ST: "Sao Tome and Principe", - SA: "Saudi Arabia", - SN: "Senegal", - SC: "Seychelles", - SL: "Sierra Leone", - SG: "Singapore", - SK: "Slovakia", - SI: "Slovenia", - SB: "Solomon Islands", - SO: "Somalia", - ZA: "South Africa", - GS: "South Georgia and the South Sandwich Islands", - ES: "Spain", - LK: "Sri Lanka", - SD: "Sudan", - SR: "Suriname", - SJ: "Svalbard and Jan Mayen", - SZ: "Eswatini", - SE: "Sweden", - CH: "Switzerland", - SY: "Syrian Arab Republic", - TW: "Taiwan", - TJ: "Tajikistan", - TZ: "Tanzania", - TH: "Thailand", - TL: "Timor-Leste", - TG: "Togo", - TK: "Tokelau", - TO: "Tonga", - TT: "Trinidad and Tobago", - TN: "Tunisia", - TR: "Turkey", - TM: "Turkmenistan", - TC: "Turks and Caicos Islands", - TV: "Tuvalu", - UG: "Uganda", - UA: "Ukraine", - AE: "United Arab Emirates", - GB: "United Kingdom", - US: "United States", - UM: "United States Minor Outlying Islands", - UY: "Uruguay", - UZ: "Uzbekistan", - VU: "Vanuatu", - VE: "Venezuela", - VN: "Vietnam", - VG: "Virgin Islands, British", - VI: "Virgin Islands, U.S.", - WF: "Wallis and Futuna", - EH: "Western Sahara", - YE: "Yemen", - ZM: "Zambia", - ZW: "Zimbabwe", - AX: "Åland Islands", - BQ: "Bonaire, Sint Eustatius and Saba", - CW: "Curaçao", - GG: "Guernsey", - IM: "Isle of Man", - JE: "Jersey", - ME: "Montenegro", - BL: "Saint Barthélemy", - MF: "Saint Martin (French part)", - RS: "Serbia", - SX: "Sint Maarten (Dutch part)", - SS: "South Sudan", - XK: "Kosovo", + AF: "Afghanistan", + AL: "Albania", + DZ: "Algeria", + AS: "American Samoa", + AD: "Andorra", + AO: "Angola", + AI: "Anguilla", + AQ: "Antarctica", + AG: "Antigua and Barbuda", + AR: "Argentina", + AM: "Armenia", + AW: "Aruba", + AU: "Australia", + AT: "Austria", + AZ: "Azerbaijan", + BS: "Bahamas", + BH: "Bahrain", + BD: "Bangladesh", + BB: "Barbados", + BY: "Belarus", + BE: "Belgium", + BZ: "Belize", + BJ: "Benin", + BM: "Bermuda", + BT: "Bhutan", + BO: "Bolivia", + BA: "Bosnia and Herzegovina", + BW: "Botswana", + BV: "Bouvet Island", + BR: "Brazil", + IO: "British Indian Ocean Territory", + BN: "Brunei Darussalam", + BG: "Bulgaria", + BF: "Burkina Faso", + BI: "Burundi", + KH: "Cambodia", + CM: "Cameroon", + CA: "Canada", + CV: "Cape Verde", + KY: "Cayman Islands", + CF: "Central African Republic", + TD: "Chad", + CL: "Chile", + CN: "China", + CX: "Christmas Island", + CC: "Cocos (Keeling) Islands", + CO: "Colombia", + KM: "Comoros", + CG: "Congo (Republic)", + CD: "Congo (Democratic Republic)", + CK: "Cook Islands", + CR: "Costa Rica", + CI: "Ivory Coast", + HR: "Croatia", + CU: "Cuba", + CY: "Cyprus", + CZ: "Czech Republic", + DK: "Denmark", + DJ: "Djibouti", + DM: "Dominica", + DO: "Dominican Republic", + EC: "Ecuador", + EG: "Egypt", + SV: "El Salvador", + GQ: "Equatorial Guinea", + ER: "Eritrea", + EE: "Estonia", + ET: "Ethiopia", + FK: "Falkland Islands", + FO: "Faroe Islands", + FJ: "Fiji", + FI: "Finland", + FR: "France", + GF: "French Guiana", + PF: "French Polynesia", + TF: "French Southern Territories", + GA: "Gabon", + GM: "Gambia", + GE: "Georgia", + DE: "Germany", + GH: "Ghana", + GI: "Gibraltar", + GR: "Greece", + GL: "Greenland", + GD: "Grenada", + GP: "Guadeloupe", + GU: "Guam", + GT: "Guatemala", + GN: "Guinea", + GW: "Guinea-Bissau", + GY: "Guyana", + HT: "Haiti", + HM: "Heard Island and McDonald Islands", + VA: "Vatican City", + HN: "Honduras", + HK: "Hong Kong", + HU: "Hungary", + IS: "Iceland", + IN: "India", + ID: "Indonesia", + IR: "Iran", + IQ: "Iraq", + IE: "Ireland", + IL: "Israel", + IT: "Italy", + JM: "Jamaica", + JP: "Japan", + JO: "Jordan", + KZ: "Kazakhstan", + KE: "Kenya", + KI: "Kiribati", + KP: "North Korea", + KR: "South Korea", + KW: "Kuwait", + KG: "Kyrgyzstan", + LA: "Laos", + LV: "Latvia", + LB: "Lebanon", + LS: "Lesotho", + LR: "Liberia", + LY: "Libya", + LI: "Liechtenstein", + LT: "Lithuania", + LU: "Luxembourg", + MO: "Macao", + MG: "Madagascar", + MW: "Malawi", + MY: "Malaysia", + MV: "Maldives", + ML: "Mali", + MT: "Malta", + MH: "Marshall Islands", + MQ: "Martinique", + MR: "Mauritania", + MU: "Mauritius", + YT: "Mayotte", + MX: "Mexico", + FM: "Micronesia", + MD: "Moldova", + MC: "Monaco", + MN: "Mongolia", + MS: "Montserrat", + MA: "Morocco", + MZ: "Mozambique", + MM: "Myanmar", + NA: "Namibia", + NR: "Nauru", + NP: "Nepal", + NL: "Netherlands", + NC: "New Caledonia", + NZ: "New Zealand", + NI: "Nicaragua", + NE: "Niger", + NG: "Nigeria", + NU: "Niue", + NF: "Norfolk Island", + MK: "Macedonia", + MP: "Northern Mariana Islands", + NO: "Norway", + OM: "Oman", + PK: "Pakistan", + PW: "Palau", + PS: "Palestine", + PA: "Panama", + PG: "Papua New Guinea", + PY: "Paraguay", + PE: "Peru", + PH: "Philippines", + PN: "Pitcairn", + PL: "Poland", + PT: "Portugal", + PR: "Puerto Rico", + QA: "Qatar", + RE: "Reunion", + RO: "Romania", + RU: "Russia", + RW: "Rwanda", + SH: "Saint Helena", + KN: "Saint Kitts and Nevis", + LC: "Saint Lucia", + PM: "Saint Pierre and Miquelon", + VC: "Saint Vincent and the Grenadines", + WS: "Samoa", + SM: "San Marino", + ST: "Sao Tome and Principe", + SA: "Saudi Arabia", + SN: "Senegal", + SC: "Seychelles", + SL: "Sierra Leone", + SG: "Singapore", + SK: "Slovakia", + SI: "Slovenia", + SB: "Solomon Islands", + SO: "Somalia", + ZA: "South Africa", + GS: "South Georgia and the South Sandwich Islands", + ES: "Spain", + LK: "Sri Lanka", + SD: "Sudan", + SR: "Suriname", + SJ: "Svalbard and Jan Mayen", + SZ: "Eswatini", + SE: "Sweden", + CH: "Switzerland", + SY: "Syrian Arab Republic", + TW: "Taiwan", + TJ: "Tajikistan", + TZ: "Tanzania", + TH: "Thailand", + TL: "Timor-Leste", + TG: "Togo", + TK: "Tokelau", + TO: "Tonga", + TT: "Trinidad and Tobago", + TN: "Tunisia", + TR: "Turkey", + TM: "Turkmenistan", + TC: "Turks and Caicos Islands", + TV: "Tuvalu", + UG: "Uganda", + UA: "Ukraine", + AE: "United Arab Emirates", + GB: "United Kingdom", + US: "United States", + UM: "United States Minor Outlying Islands", + UY: "Uruguay", + UZ: "Uzbekistan", + VU: "Vanuatu", + VE: "Venezuela", + VN: "Vietnam", + VG: "Virgin Islands, British", + VI: "Virgin Islands, U.S.", + WF: "Wallis and Futuna", + EH: "Western Sahara", + YE: "Yemen", + ZM: "Zambia", + ZW: "Zimbabwe", + AX: "Åland Islands", + BQ: "Bonaire, Sint Eustatius and Saba", + CW: "Curaçao", + GG: "Guernsey", + IM: "Isle of Man", + JE: "Jersey", + ME: "Montenegro", + BL: "Saint Barthélemy", + MF: "Saint Martin (French part)", + RS: "Serbia", + SX: "Sint Maarten (Dutch part)", + SS: "South Sudan", + XK: "Kosovo", }; export const COUNTRY_CODES = Object.keys(COUNTRIES); export const EU_COUNTRY_CODES = [ - "AT", - "BE", - "BG", - "CY", - "CZ", - "DE", - "DK", - "EE", - "ES", - "FI", - "FR", - "GB", - "GR", - "HR", - "HU", - "IE", - "IT", - "LT", - "LU", - "LV", - "MT", - "NL", - "PL", - "PT", - "RO", - "SE", - "SI", - "SK", + "AT", + "BE", + "BG", + "CY", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FR", + "GB", + "GR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LU", + "LV", + "MT", + "NL", + "PL", + "PT", + "RO", + "SE", + "SI", + "SK", ]; function usageAndExit(message, code = 1) { - if (message) console.error(message); - console.error( - [ - "Usage: node scripts/analytics/migrate-dub-to-tinybird.js [options]", - "", - "Options:", - " --dry-run Dry run (no writes). Default: true", - " --apply Perform writes to Tinybird (sets dry-run=false)", - " --domain Dub link domain. Default: cap.link", - " --interval Dub interval (24h,7d,30d,90d,1y,all). Default: 30d", - " --start Start ISO datetime (overrides interval)", - " --end End ISO datetime (overrides interval)", - " --timezone IANA timezone for timeseries. Default: UTC", - " --video Video ID to migrate (repeatable, optional - defaults to all links from domain)", - " --videos-file File with newline-separated video IDs (optional)", - " --org Default tenant orgId for videos (optional, uses empty string if not provided)", - " --map JSON mapping file: { \"\": \"\" } (optional)", - " --max-cities Limit number of cities per video. Default: 25", - " --limit Limit number of videos to process (useful for testing). Default: unlimited", - " --video-concurrency Number of videos to process in parallel. Default: 4", - " --dub-concurrency Max concurrent Dub API requests per process. Default: 8", - " --ingest-chunk Tinybird ingest chunk size. Default: 5000", - " --ingest-concurrency Max concurrent Tinybird ingest requests. Default: 4", - " --ingest-rate-limit Tinybird requests per second. Default: 10", - "", - "By default, fetches all links from the specified domain and migrates analytics for each.", - "The link key (slug) is used as the videoId.", - "", - "Environment:", - " DUB_API_KEY (required), TINYBIRD_TOKEN (required for --apply), TINYBIRD_HOST (optional)", - ].join("\n") - ); - process.exit(code); + if (message) console.error(message); + console.error( + [ + "Usage: node scripts/analytics/migrate-dub-to-tinybird.js [options]", + "", + "Options:", + " --dry-run Dry run (no writes). Default: true", + " --apply Perform writes to Tinybird (sets dry-run=false)", + " --domain Dub link domain. Default: cap.link", + " --interval Dub interval (24h,7d,30d,90d,1y,all). Default: 30d", + " --start Start ISO datetime (overrides interval)", + " --end End ISO datetime (overrides interval)", + " --timezone IANA timezone for timeseries. Default: UTC", + " --video Video ID to migrate (repeatable, optional - defaults to all links from domain)", + " --videos-file File with newline-separated video IDs (optional)", + " --org Default tenant orgId for videos (optional, uses empty string if not provided)", + ' --map JSON mapping file: { "": "" } (optional)', + " --max-cities Limit number of cities per video. Default: 25", + " --limit Limit number of videos to process (useful for testing). Default: unlimited", + " --video-concurrency Number of videos to process in parallel. Default: 4", + " --dub-concurrency Max concurrent Dub API requests per process. Default: 8", + " --ingest-chunk Tinybird ingest chunk size. Default: 5000", + " --ingest-concurrency Max concurrent Tinybird ingest requests. Default: 4", + " --ingest-rate-limit Tinybird requests per second. Default: 10", + "", + "By default, fetches all links from the specified domain and migrates analytics for each.", + "The link key (slug) is used as the videoId.", + "", + "Environment:", + " DUB_API_KEY (required), TINYBIRD_TOKEN (required for --apply), TINYBIRD_HOST (optional)", + ].join("\n"), + ); + process.exit(code); } function parseArgs(argv) { - const args = { - dryRun: true, - domain: DEFAULT_DOMAIN, - interval: DEFAULT_INTERVAL, - start: null, - end: null, - timezone: DEFAULT_TIMEZONE, - videoIds: [], - orgs: [], - mapPath: null, - maxCities: MAX_CITY_COUNT, - limit: null, - apply: false, - videoConcurrency: DEFAULT_VIDEO_CONCURRENCY, - apiConcurrency: DEFAULT_API_CONCURRENCY, - ingestChunk: INGEST_CHUNK_SIZE, - ingestConcurrency: DEFAULT_INGEST_CONCURRENCY, - ingestRateLimit: DEFAULT_INGEST_RATE_LIMIT, - }; - for (let i = 2; i < argv.length; i++) { - const a = argv[i]; - if (a === "--dry-run") args.dryRun = true; - else if (a === "--apply") { - args.apply = true; - args.dryRun = false; - } else if (a === "--domain") args.domain = argv[++i]; - else if (a === "--interval") args.interval = argv[++i]; - else if (a === "--start") args.start = argv[++i]; - else if (a === "--end") args.end = argv[++i]; - else if (a === "--timezone") args.timezone = argv[++i]; - else if (a === "--video") args.videoIds.push(argv[++i]); - else if (a === "--videos-file") { - const file = argv[++i]; - const raw = fs.readFileSync(path.resolve(process.cwd(), file), "utf8"); - const ids = raw - .split(/\r?\n/) - .map((l) => l.trim()) - .filter(Boolean); - args.videoIds.push(...ids); - } else if (a === "--org") args.orgs.push(argv[++i]); - else if (a === "--map") args.mapPath = argv[++i]; - else if (a === "--max-cities") args.maxCities = Number(argv[++i] || MAX_CITY_COUNT) || MAX_CITY_COUNT; - else if (a === "--limit") { - const limitVal = argv[++i]; - if (limitVal) { - const num = Number(limitVal); - args.limit = num > 0 ? num : null; - } - } else if (a === "--video-concurrency") { - const n = Number(argv[++i]); - if (!Number.isNaN(n) && n > 0) args.videoConcurrency = n; - } else if (a === "--dub-concurrency") { - const n = Number(argv[++i]); - if (!Number.isNaN(n) && n > 0) args.apiConcurrency = n; - } else if (a === "--ingest-chunk") { - const n = Number(argv[++i]); - if (!Number.isNaN(n) && n > 0) args.ingestChunk = n; - } else if (a === "--ingest-concurrency") { - const n = Number(argv[++i]); - if (!Number.isNaN(n) && n > 0) args.ingestConcurrency = n; - } else if (a === "--ingest-rate-limit") { - const n = Number(argv[++i]); - if (!Number.isNaN(n) && n > 0) args.ingestRateLimit = n; - } - else usageAndExit(`Unknown argument: ${a}`); - } - return args; + const args = { + dryRun: true, + domain: DEFAULT_DOMAIN, + interval: DEFAULT_INTERVAL, + start: null, + end: null, + timezone: DEFAULT_TIMEZONE, + videoIds: [], + orgs: [], + mapPath: null, + maxCities: MAX_CITY_COUNT, + limit: null, + apply: false, + videoConcurrency: DEFAULT_VIDEO_CONCURRENCY, + apiConcurrency: DEFAULT_API_CONCURRENCY, + ingestChunk: INGEST_CHUNK_SIZE, + ingestConcurrency: DEFAULT_INGEST_CONCURRENCY, + ingestRateLimit: DEFAULT_INGEST_RATE_LIMIT, + }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dry-run") args.dryRun = true; + else if (a === "--apply") { + args.apply = true; + args.dryRun = false; + } else if (a === "--domain") args.domain = argv[++i]; + else if (a === "--interval") args.interval = argv[++i]; + else if (a === "--start") args.start = argv[++i]; + else if (a === "--end") args.end = argv[++i]; + else if (a === "--timezone") args.timezone = argv[++i]; + else if (a === "--video") args.videoIds.push(argv[++i]); + else if (a === "--videos-file") { + const file = argv[++i]; + const raw = fs.readFileSync(path.resolve(process.cwd(), file), "utf8"); + const ids = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + args.videoIds.push(...ids); + } else if (a === "--org") args.orgs.push(argv[++i]); + else if (a === "--map") args.mapPath = argv[++i]; + else if (a === "--max-cities") + args.maxCities = Number(argv[++i] || MAX_CITY_COUNT) || MAX_CITY_COUNT; + else if (a === "--limit") { + const limitVal = argv[++i]; + if (limitVal) { + const num = Number(limitVal); + args.limit = num > 0 ? num : null; + } + } else if (a === "--video-concurrency") { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.videoConcurrency = n; + } else if (a === "--dub-concurrency") { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.apiConcurrency = n; + } else if (a === "--ingest-chunk") { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.ingestChunk = n; + } else if (a === "--ingest-concurrency") { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.ingestConcurrency = n; + } else if (a === "--ingest-rate-limit") { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.ingestRateLimit = n; + } else usageAndExit(`Unknown argument: ${a}`); + } + return args; } function loadVideoToOrgMap(args, videoIds) { - const map = new Map(); - if (args.mapPath) { - const p = path.resolve(process.cwd(), args.mapPath); - const json = JSON.parse(fs.readFileSync(p, "utf8")); - for (const [k, v] of Object.entries(json)) map.set(String(k), String(v)); - } - if (args.orgs.length) { - const defaultOrg = args.orgs[0] || ""; - if (args.orgs.length === 1) { - for (const vid of videoIds) { - if (!map.has(vid)) map.set(vid, defaultOrg); - } - } else if (args.orgs.length === videoIds.length) { - for (let i = 0; i < videoIds.length; i++) { - if (!map.has(videoIds[i])) map.set(videoIds[i], args.orgs[i] || ""); - } - } else { - usageAndExit("Provide either one --org for all videos or one --org per video"); - } - } - return map; + const map = new Map(); + if (args.mapPath) { + const p = path.resolve(process.cwd(), args.mapPath); + const json = JSON.parse(fs.readFileSync(p, "utf8")); + for (const [k, v] of Object.entries(json)) map.set(String(k), String(v)); + } + if (args.orgs.length) { + const defaultOrg = args.orgs[0] || ""; + if (args.orgs.length === 1) { + for (const vid of videoIds) { + if (!map.has(vid)) map.set(vid, defaultOrg); + } + } else if (args.orgs.length === videoIds.length) { + for (let i = 0; i < videoIds.length; i++) { + if (!map.has(videoIds[i])) map.set(videoIds[i], args.orgs[i] || ""); + } + } else { + usageAndExit( + "Provide either one --org for all videos or one --org per video", + ); + } + } + return map; } function requireEnv(name) { - const v = process.env[name]; - if (!v || !v.trim()) usageAndExit(`Missing required env: ${name}`); - return v.trim(); + const v = process.env[name]; + if (!v || !v.trim()) usageAndExit(`Missing required env: ${name}`); + return v.trim(); } function qs(params) { - const sp = new URLSearchParams(); - Object.entries(params).forEach(([k, v]) => { - if (v === undefined || v === null || v === "") return; - sp.set(k, String(v)); - }); - return sp.toString(); + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null || v === "") return; + sp.set(k, String(v)); + }); + return sp.toString(); } function createLimiter(max) { - let active = 0; - const queue = []; - const runNext = () => { - if (active >= max) return; - const item = queue.shift(); - if (!item) return; - active++; - Promise.resolve() - .then(item.fn) - .then((v) => { - active--; - item.resolve(v); - runNext(); - }) - .catch((e) => { - active--; - item.reject(e); - runNext(); - }); - }; - return function limit(fn) { - return new Promise((resolve, reject) => { - queue.push({ fn, resolve, reject }); - runNext(); - }); - }; + let active = 0; + const queue = []; + const runNext = () => { + if (active >= max) return; + const item = queue.shift(); + if (!item) return; + active++; + Promise.resolve() + .then(item.fn) + .then((v) => { + active--; + item.resolve(v); + runNext(); + }) + .catch((e) => { + active--; + item.reject(e); + runNext(); + }); + }; + return function limit(fn) { + return new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + runNext(); + }); + }; } async function mapWithConcurrency(items, mapper, limit) { - const limiter = createLimiter(limit); - return Promise.all( - items.map((item, idx) => - limiter(() => mapper(item, idx)) - ) - ); + const limiter = createLimiter(limit); + return Promise.all( + items.map((item, idx) => limiter(() => mapper(item, idx))), + ); } function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); + return new Promise((r) => setTimeout(r, ms)); } const tinybirdRateLimitState = { - lastRequestTime: 0, + lastRequestTime: 0, }; function createTinybirdRateLimiter(requestsPerSecond) { - const minDelayMs = requestsPerSecond > 0 ? 1000 / requestsPerSecond : 0; - return { - minDelayMs, - async wait() { - const now = Date.now(); - const timeSinceLastRequest = now - tinybirdRateLimitState.lastRequestTime; - if (timeSinceLastRequest < minDelayMs) { - const waitTime = minDelayMs - timeSinceLastRequest; - await sleep(waitTime); - } - tinybirdRateLimitState.lastRequestTime = Date.now(); - }, - }; + const minDelayMs = requestsPerSecond > 0 ? 1000 / requestsPerSecond : 0; + return { + minDelayMs, + async wait() { + const now = Date.now(); + const timeSinceLastRequest = now - tinybirdRateLimitState.lastRequestTime; + if (timeSinceLastRequest < minDelayMs) { + const waitTime = minDelayMs - timeSinceLastRequest; + await sleep(waitTime); + } + tinybirdRateLimitState.lastRequestTime = Date.now(); + }, + }; } const dubRateLimitState = { - limit: 3000, - remaining: 3000, - resetAt: Date.now() + 60000, - requests: [], + limit: 3000, + remaining: 3000, + resetAt: Date.now() + 60000, + requests: [], }; async function waitForRateLimit() { - const state = dubRateLimitState; - const now = Date.now(); - - if (now >= state.resetAt) { - state.remaining = state.limit; - state.resetAt = now + 60000; - state.requests = []; - } - - state.requests = state.requests.filter((timestamp) => timestamp > now - 60000); - - if (state.requests.length >= state.limit) { - const oldestRequest = Math.min(...state.requests); - const waitTime = 60000 - (now - oldestRequest) + 100; - if (waitTime > 0) { - await sleep(waitTime); - return waitForRateLimit(); - } - } - - if (state.remaining <= 0) { - const waitTime = state.resetAt - now + 100; - if (waitTime > 0) { - await sleep(waitTime); - return waitForRateLimit(); - } - } - - state.requests.push(now); - if (state.remaining > 0) { - state.remaining--; - } + const state = dubRateLimitState; + const now = Date.now(); + + if (now >= state.resetAt) { + state.remaining = state.limit; + state.resetAt = now + 60000; + state.requests = []; + } + + state.requests = state.requests.filter( + (timestamp) => timestamp > now - 60000, + ); + + if (state.requests.length >= state.limit) { + const oldestRequest = Math.min(...state.requests); + const waitTime = 60000 - (now - oldestRequest) + 100; + if (waitTime > 0) { + await sleep(waitTime); + return waitForRateLimit(); + } + } + + if (state.remaining <= 0) { + const waitTime = state.resetAt - now + 100; + if (waitTime > 0) { + await sleep(waitTime); + return waitForRateLimit(); + } + } + + state.requests.push(now); + if (state.remaining > 0) { + state.remaining--; + } } function updateRateLimitState(response) { - const state = dubRateLimitState; - const limitHeader = response.headers.get("x-ratelimit-limit"); - const remainingHeader = response.headers.get("x-ratelimit-remaining"); - const resetHeader = response.headers.get("x-ratelimit-reset"); - - if (limitHeader) { - const limit = Number(limitHeader); - if (Number.isFinite(limit) && limit > 0) { - state.limit = limit; - } - } - - if (remainingHeader) { - const remaining = Number(remainingHeader); - if (Number.isFinite(remaining) && remaining >= 0) { - state.remaining = remaining; - } - } - - if (resetHeader) { - const resetTimestamp = Number(resetHeader); - if (Number.isFinite(resetTimestamp) && resetTimestamp > 0) { - let resetMs; - const now = Date.now(); - if (resetTimestamp > now * 1000) { - resetMs = Math.floor(resetTimestamp / 1000); - } else if (resetTimestamp > now) { - resetMs = resetTimestamp; - } else if (resetTimestamp < 1e10) { - resetMs = resetTimestamp * 1000; - } else { - resetMs = Math.floor(resetTimestamp / 1000); - } - if (resetMs > now && resetMs < now + 86400000) { - state.resetAt = resetMs; - } - } - } + const state = dubRateLimitState; + const limitHeader = response.headers.get("x-ratelimit-limit"); + const remainingHeader = response.headers.get("x-ratelimit-remaining"); + const resetHeader = response.headers.get("x-ratelimit-reset"); + + if (limitHeader) { + const limit = Number(limitHeader); + if (Number.isFinite(limit) && limit > 0) { + state.limit = limit; + } + } + + if (remainingHeader) { + const remaining = Number(remainingHeader); + if (Number.isFinite(remaining) && remaining >= 0) { + state.remaining = remaining; + } + } + + if (resetHeader) { + const resetTimestamp = Number(resetHeader); + if (Number.isFinite(resetTimestamp) && resetTimestamp > 0) { + let resetMs; + const now = Date.now(); + if (resetTimestamp > now * 1000) { + resetMs = Math.floor(resetTimestamp / 1000); + } else if (resetTimestamp > now) { + resetMs = resetTimestamp; + } else if (resetTimestamp < 1e10) { + resetMs = resetTimestamp * 1000; + } else { + resetMs = Math.floor(resetTimestamp / 1000); + } + if (resetMs > now && resetMs < now + 86400000) { + state.resetAt = resetMs; + } + } + } } function normalizeDimensionField(value) { - if (value === undefined || value === null) return ""; - const str = String(value).trim(); - if (!str || str === "*" || str === "null" || str === "undefined") return ""; - return str; + if (value === undefined || value === null) return ""; + const str = String(value).trim(); + if (!str || str === "*" || str === "null" || str === "undefined") return ""; + return str; } function resolveRegionName(region, regionCode, country) { - const sources = []; - if (region !== undefined) sources.push(region); - if (regionCode !== undefined) sources.push(regionCode); - const normalizedCountry = normalizeDimensionField(country).toUpperCase(); - if (normalizedCountry) { - if (region) sources.push(`${normalizedCountry}-${region}`); - if (regionCode) sources.push(`${normalizedCountry}-${regionCode}`); - } - for (const source of sources) { - const normalized = normalizeDimensionField(source); - if (!normalized) continue; - if (REGIONS[normalized]) return REGIONS[normalized]; - const upper = normalized.toUpperCase(); - if (REGIONS[upper]) return REGIONS[upper]; - } - const fallback = normalizeDimensionField(region) || normalizeDimensionField(regionCode); - return fallback; + const sources = []; + if (region !== undefined) sources.push(region); + if (regionCode !== undefined) sources.push(regionCode); + const normalizedCountry = normalizeDimensionField(country).toUpperCase(); + if (normalizedCountry) { + if (region) sources.push(`${normalizedCountry}-${region}`); + if (regionCode) sources.push(`${normalizedCountry}-${regionCode}`); + } + for (const source of sources) { + const normalized = normalizeDimensionField(source); + if (!normalized) continue; + if (REGIONS[normalized]) return REGIONS[normalized]; + const upper = normalized.toUpperCase(); + if (REGIONS[upper]) return REGIONS[upper]; + } + const fallback = + normalizeDimensionField(region) || normalizeDimensionField(regionCode); + return fallback; } function resolveCountryCode(countryCode, country, continent) { - const normalizedContinent = normalizeDimensionField(continent).toUpperCase(); - const normalizedCountryCode = normalizeDimensionField(countryCode).toUpperCase(); - const normalizedCountryName = normalizeDimensionField(country); - const upperCountryName = normalizedCountryName ? normalizedCountryName.toUpperCase() : ""; - - if (normalizedCountryCode && COUNTRIES[normalizedCountryCode]) { - if (normalizedContinent) { - const expectedContinent = COUNTRIES_TO_CONTINENTS[normalizedCountryCode]; - if (expectedContinent && expectedContinent === normalizedContinent) { - return normalizedCountryCode; - } - } else { - return normalizedCountryCode; - } - } - - if (upperCountryName && COUNTRIES[upperCountryName]) { - if (normalizedContinent) { - const expectedContinent = COUNTRIES_TO_CONTINENTS[upperCountryName]; - if (expectedContinent && expectedContinent === normalizedContinent) { - return upperCountryName; - } - } else { - return upperCountryName; - } - } - - if (normalizedCountryName) { - for (const [code, name] of Object.entries(COUNTRIES)) { - if (name.toUpperCase() === upperCountryName || name === normalizedCountryName) { - if (normalizedContinent) { - const expectedContinent = COUNTRIES_TO_CONTINENTS[code]; - if (expectedContinent && expectedContinent === normalizedContinent) { - return code; - } - } else { - return code; - } - } - } - } - - if (normalizedCountryCode && COUNTRIES[normalizedCountryCode]) { - return normalizedCountryCode; - } - - return normalizedCountryCode || upperCountryName || ""; + const normalizedContinent = normalizeDimensionField(continent).toUpperCase(); + const normalizedCountryCode = + normalizeDimensionField(countryCode).toUpperCase(); + const normalizedCountryName = normalizeDimensionField(country); + const upperCountryName = normalizedCountryName + ? normalizedCountryName.toUpperCase() + : ""; + + if (normalizedCountryCode && COUNTRIES[normalizedCountryCode]) { + if (normalizedContinent) { + const expectedContinent = COUNTRIES_TO_CONTINENTS[normalizedCountryCode]; + if (expectedContinent && expectedContinent === normalizedContinent) { + return normalizedCountryCode; + } + } else { + return normalizedCountryCode; + } + } + + if (upperCountryName && COUNTRIES[upperCountryName]) { + if (normalizedContinent) { + const expectedContinent = COUNTRIES_TO_CONTINENTS[upperCountryName]; + if (expectedContinent && expectedContinent === normalizedContinent) { + return upperCountryName; + } + } else { + return upperCountryName; + } + } + + if (normalizedCountryName) { + for (const [code, name] of Object.entries(COUNTRIES)) { + if ( + name.toUpperCase() === upperCountryName || + name === normalizedCountryName + ) { + if (normalizedContinent) { + const expectedContinent = COUNTRIES_TO_CONTINENTS[code]; + if (expectedContinent && expectedContinent === normalizedContinent) { + return code; + } + } else { + return code; + } + } + } + } + + if (normalizedCountryCode && COUNTRIES[normalizedCountryCode]) { + return normalizedCountryCode; + } + + return normalizedCountryCode || upperCountryName || ""; } async function dubFetch(pathname, token, params = {}) { - const url = `${DUB_API_URL}${pathname}${Object.keys(params).length ? `?${qs(params)}` : ""}`; - const maxAttempts = 5; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - await waitForRateLimit(); - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, - }); - - updateRateLimitState(response); - - const text = await response.text(); - if (response.ok) { - try { - return JSON.parse(text || "{}"); - } catch { - return {}; - } - } - const status = response.status; - const shouldRetry = status === 429 || (status >= 500 && status < 600); - if (!shouldRetry || attempt === maxAttempts) { - let message = text; - try { - const payload = JSON.parse(text || "{}"); - message = payload?.error || payload?.message || text; - } catch {} - throw new Error(`Dub request failed (${status}): ${message}`); - } - const retryAfter = Number(response.headers.get("retry-after")); - const base = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** (attempt - 1); - const jitter = Math.floor(Math.random() * 250); - await sleep(base + jitter); - } + const url = `${DUB_API_URL}${pathname}${Object.keys(params).length ? `?${qs(params)}` : ""}`; + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await waitForRateLimit(); + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + + updateRateLimitState(response); + + const text = await response.text(); + if (response.ok) { + try { + return JSON.parse(text || "{}"); + } catch { + return {}; + } + } + const status = response.status; + const shouldRetry = status === 429 || (status >= 500 && status < 600); + if (!shouldRetry || attempt === maxAttempts) { + let message = text; + try { + const payload = JSON.parse(text || "{}"); + message = payload?.error || payload?.message || text; + } catch {} + throw new Error(`Dub request failed (${status}): ${message}`); + } + const retryAfter = Number(response.headers.get("retry-after")); + const base = + Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : 500 * 2 ** (attempt - 1); + const jitter = Math.floor(Math.random() * 250); + await sleep(base + jitter); + } } async function dubListLinks({ token, domain, limit = 100, offset = 0 }) { - const params = { - domain, - limit, - offset, - }; - const res = await dubFetch("/links", token, params); - const links = Array.isArray(res?.links) ? res.links : Array.isArray(res) ? res : []; - const hasMore = res?.hasMore ?? false; - return { links, hasMore }; + const params = { + domain, + limit, + offset, + }; + const res = await dubFetch("/links", token, params); + const links = Array.isArray(res?.links) + ? res.links + : Array.isArray(res) + ? res + : []; + const hasMore = res?.hasMore ?? false; + return { links, hasMore }; } async function dubFetchAllLinks({ token, domain, maxLinks = null }) { - const allLinks = []; - let offset = 0; - const limit = 100; + const allLinks = []; + let offset = 0; + const limit = 100; + + while (true) { + const { links, hasMore } = await dubListLinks({ + token, + domain, + limit, + offset, + }); + allLinks.push(...links); + offset += limit; + + if (maxLinks !== null && allLinks.length >= maxLinks) { + return allLinks.slice(0, maxLinks); + } + + if (links.length < limit) break; - while (true) { - const { links, hasMore } = await dubListLinks({ token, domain, limit, offset }); - allLinks.push(...links); - offset += limit; - - if (maxLinks !== null && allLinks.length >= maxLinks) { - return allLinks.slice(0, maxLinks); - } - - if (links.length < limit) break; - - if (!hasMore && maxLinks === null) break; - } + if (!hasMore && maxLinks === null) break; + } - return allLinks; + return allLinks; } -async function dubRetrieveTimeseries({ token, domain, key, start, end, interval, timezone, city, country, region }) { - const allRows = []; - - if (start && end) { - const startDate = new Date(start); - const endDate = new Date(end); - const currentDate = new Date(startDate); - - while (currentDate <= endDate) { - const dayStart = new Date(currentDate); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(currentDate); - dayEnd.setHours(23, 59, 59, 999); - - const dayStartISO = dayStart.toISOString(); - const dayEndISO = dayEnd.toISOString(); - - const params = { - event: "clicks", - groupBy: "timeseries", - domain, - key, - timezone: timezone || DEFAULT_TIMEZONE, - interval: "24h", - start: dayStartISO, - end: dayEndISO, - city: city || undefined, - country: country || undefined, - region: region || undefined, - }; - - const res = await dubFetch("/analytics", token, params); - let data = []; - if (Array.isArray(res)) { - data = res; - } else if (Array.isArray(res?.data)) { - data = res.data; - } else if (res && typeof res === "object") { - data = [res]; - } - - for (const row of data) { - if (!row || typeof row !== "object") continue; - const t = row.start || row.timestamp || row.date || row.ts || row.time || row.d || row.t || row.startDate; - const c = row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0; - if (!t) continue; - - let tsStr; - if (typeof t === "string") { - tsStr = t; - } else if (typeof t === "number") { - tsStr = new Date(t).toISOString(); - } else { - tsStr = String(t); - } - - if (!tsStr || tsStr === "undefined" || tsStr === "null" || tsStr === "Invalid Date") continue; - - if (tsStr.includes("+0000")) { - tsStr = tsStr.replace("+0000", "Z"); - } else if (tsStr.match(/[+-]\d{4}$/)) { - const parsed = new Date(tsStr); - if (!Number.isNaN(parsed.getTime())) { - tsStr = parsed.toISOString(); - } - } else if (!tsStr.includes("T") || !tsStr.includes(":")) { - const parsed = new Date(tsStr); - if (!Number.isNaN(parsed.getTime())) { - tsStr = parsed.toISOString(); - } - } - - const count = Number(c) || 0; - if (count > 0) { - allRows.push({ timestamp: tsStr, count }); - } - } - - currentDate.setDate(currentDate.getDate() + 1); - } - } else { - const params = { - event: "clicks", - groupBy: "timeseries", - domain, - key, - timezone: timezone || DEFAULT_TIMEZONE, - interval: interval || DEFAULT_INTERVAL, - start: start || undefined, - end: end || undefined, - city: city || undefined, - country: country || undefined, - region: region || undefined, - }; - const res = await dubFetch("/analytics", token, params); - let data = []; - if (Array.isArray(res)) { - data = res; - } else if (Array.isArray(res?.data)) { - data = res.data; - } else if (res && typeof res === "object") { - data = [res]; - } - - for (const row of data) { - if (!row || typeof row !== "object") continue; - const t = row.start || row.timestamp || row.date || row.ts || row.time || row.d || row.t || row.startDate; - const c = row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0; - if (!t) continue; - - let tsStr; - if (typeof t === "string") { - tsStr = t; - } else if (typeof t === "number") { - tsStr = new Date(t).toISOString(); - } else { - tsStr = String(t); - } - - if (!tsStr || tsStr === "undefined" || tsStr === "null" || tsStr === "Invalid Date") continue; - - if (tsStr.includes("+0000")) { - tsStr = tsStr.replace("+0000", "Z"); - } else if (tsStr.match(/[+-]\d{4}$/)) { - const parsed = new Date(tsStr); - if (!Number.isNaN(parsed.getTime())) { - tsStr = parsed.toISOString(); - } - } else if (!tsStr.includes("T") || !tsStr.includes(":")) { - const parsed = new Date(tsStr); - if (!Number.isNaN(parsed.getTime())) { - tsStr = parsed.toISOString(); - } - } - - const count = Number(c) || 0; - if (count > 0) { - allRows.push({ timestamp: tsStr, count }); - } - } - } - - return allRows.filter((r) => r.timestamp && r.timestamp !== "undefined" && r.timestamp !== "null"); +async function dubRetrieveTimeseries({ + token, + domain, + key, + start, + end, + interval, + timezone, + city, + country, + region, +}) { + const allRows = []; + + if (start && end) { + const startDate = new Date(start); + const endDate = new Date(end); + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dayStart = new Date(currentDate); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(currentDate); + dayEnd.setHours(23, 59, 59, 999); + + const dayStartISO = dayStart.toISOString(); + const dayEndISO = dayEnd.toISOString(); + + const params = { + event: "clicks", + groupBy: "timeseries", + domain, + key, + timezone: timezone || DEFAULT_TIMEZONE, + interval: "24h", + start: dayStartISO, + end: dayEndISO, + city: city || undefined, + country: country || undefined, + region: region || undefined, + }; + + const res = await dubFetch("/analytics", token, params); + let data = []; + if (Array.isArray(res)) { + data = res; + } else if (Array.isArray(res?.data)) { + data = res.data; + } else if (res && typeof res === "object") { + data = [res]; + } + + for (const row of data) { + if (!row || typeof row !== "object") continue; + const t = + row.start || + row.timestamp || + row.date || + row.ts || + row.time || + row.d || + row.t || + row.startDate; + const c = + row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0; + if (!t) continue; + + let tsStr; + if (typeof t === "string") { + tsStr = t; + } else if (typeof t === "number") { + tsStr = new Date(t).toISOString(); + } else { + tsStr = String(t); + } + + if ( + !tsStr || + tsStr === "undefined" || + tsStr === "null" || + tsStr === "Invalid Date" + ) + continue; + + if (tsStr.includes("+0000")) { + tsStr = tsStr.replace("+0000", "Z"); + } else if (tsStr.match(/[+-]\d{4}$/)) { + const parsed = new Date(tsStr); + if (!Number.isNaN(parsed.getTime())) { + tsStr = parsed.toISOString(); + } + } else if (!tsStr.includes("T") || !tsStr.includes(":")) { + const parsed = new Date(tsStr); + if (!Number.isNaN(parsed.getTime())) { + tsStr = parsed.toISOString(); + } + } + + const count = Number(c) || 0; + if (count > 0) { + allRows.push({ timestamp: tsStr, count }); + } + } + + currentDate.setDate(currentDate.getDate() + 1); + } + } else { + const params = { + event: "clicks", + groupBy: "timeseries", + domain, + key, + timezone: timezone || DEFAULT_TIMEZONE, + interval: interval || DEFAULT_INTERVAL, + start: start || undefined, + end: end || undefined, + city: city || undefined, + country: country || undefined, + region: region || undefined, + }; + const res = await dubFetch("/analytics", token, params); + let data = []; + if (Array.isArray(res)) { + data = res; + } else if (Array.isArray(res?.data)) { + data = res.data; + } else if (res && typeof res === "object") { + data = [res]; + } + + for (const row of data) { + if (!row || typeof row !== "object") continue; + const t = + row.start || + row.timestamp || + row.date || + row.ts || + row.time || + row.d || + row.t || + row.startDate; + const c = row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0; + if (!t) continue; + + let tsStr; + if (typeof t === "string") { + tsStr = t; + } else if (typeof t === "number") { + tsStr = new Date(t).toISOString(); + } else { + tsStr = String(t); + } + + if ( + !tsStr || + tsStr === "undefined" || + tsStr === "null" || + tsStr === "Invalid Date" + ) + continue; + + if (tsStr.includes("+0000")) { + tsStr = tsStr.replace("+0000", "Z"); + } else if (tsStr.match(/[+-]\d{4}$/)) { + const parsed = new Date(tsStr); + if (!Number.isNaN(parsed.getTime())) { + tsStr = parsed.toISOString(); + } + } else if (!tsStr.includes("T") || !tsStr.includes(":")) { + const parsed = new Date(tsStr); + if (!Number.isNaN(parsed.getTime())) { + tsStr = parsed.toISOString(); + } + } + + const count = Number(c) || 0; + if (count > 0) { + allRows.push({ timestamp: tsStr, count }); + } + } + } + + return allRows.filter( + (r) => r.timestamp && r.timestamp !== "undefined" && r.timestamp !== "null", + ); } -async function dubRetrieveBreakdown({ token, domain, key, start, end, interval, timezone, groupBy, city, country, region }) { - const params = { - event: "clicks", - groupBy, - domain, - key, - timezone: timezone || DEFAULT_TIMEZONE, - interval: start || end ? undefined : interval || DEFAULT_INTERVAL, - start: start || undefined, - end: end || undefined, - city: city || undefined, - country: country || undefined, - region: region || undefined, - }; - const res = await dubFetch("/analytics", token, params); - const data = Array.isArray(res?.data) ? res.data : res; - const rows = Array.isArray(data) ? data : []; - const normalized = []; - for (const row of rows) { - if (!row || typeof row !== "object") continue; - const name = normalizeDimensionField( - row.name ?? row.city ?? row.country ?? row.region ?? row.device ?? row.browser ?? row.os ?? row.key - ); - if (!name) continue; - const value = Number(row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0) || 0; - const rowCity = normalizeDimensionField(row.city); - const rowCountry = - normalizeDimensionField(row.country) || - normalizeDimensionField(row.countryCode) || - normalizeDimensionField(row.country_code); - const rowRegion = - normalizeDimensionField(row.region) || - normalizeDimensionField(row.regionCode) || - normalizeDimensionField(row.region_code) || - normalizeDimensionField(row.state); - const countryCode = - normalizeDimensionField(row.countryCode) || - normalizeDimensionField(row.country_code) || - normalizeDimensionField(row.isoCode) || - normalizeDimensionField(row.iso_code); - const regionCode = - normalizeDimensionField(row.regionCode) || - normalizeDimensionField(row.region_code) || - normalizeDimensionField(row.subdivisionCode) || - normalizeDimensionField(row.subdivision_code); - normalized.push({ - name, - value, - city: rowCity, - country: rowCountry, - region: rowRegion, - countryCode, - regionCode, - }); - } - return normalized; +async function dubRetrieveBreakdown({ + token, + domain, + key, + start, + end, + interval, + timezone, + groupBy, + city, + country, + region, +}) { + const params = { + event: "clicks", + groupBy, + domain, + key, + timezone: timezone || DEFAULT_TIMEZONE, + interval: start || end ? undefined : interval || DEFAULT_INTERVAL, + start: start || undefined, + end: end || undefined, + city: city || undefined, + country: country || undefined, + region: region || undefined, + }; + const res = await dubFetch("/analytics", token, params); + const data = Array.isArray(res?.data) ? res.data : res; + const rows = Array.isArray(data) ? data : []; + const normalized = []; + for (const row of rows) { + if (!row || typeof row !== "object") continue; + const name = normalizeDimensionField( + row.name ?? + row.city ?? + row.country ?? + row.region ?? + row.device ?? + row.browser ?? + row.os ?? + row.key, + ); + if (!name) continue; + const value = + Number(row.count ?? row.clicks ?? row.value ?? row.total ?? row.n ?? 0) || + 0; + const rowCity = normalizeDimensionField(row.city); + const rowCountry = + normalizeDimensionField(row.country) || + normalizeDimensionField(row.countryCode) || + normalizeDimensionField(row.country_code); + const rowRegion = + normalizeDimensionField(row.region) || + normalizeDimensionField(row.regionCode) || + normalizeDimensionField(row.region_code) || + normalizeDimensionField(row.state); + const countryCode = + normalizeDimensionField(row.countryCode) || + normalizeDimensionField(row.country_code) || + normalizeDimensionField(row.isoCode) || + normalizeDimensionField(row.iso_code); + const regionCode = + normalizeDimensionField(row.regionCode) || + normalizeDimensionField(row.region_code) || + normalizeDimensionField(row.subdivisionCode) || + normalizeDimensionField(row.subdivision_code); + normalized.push({ + name, + value, + city: rowCity, + country: rowCountry, + region: rowRegion, + countryCode, + regionCode, + }); + } + return normalized; } function dayMidpointIso(day) { - if (!day || day === "undefined" || day === "null") return null; - const dayStr = String(day); - if (dayStr.length >= 10) { - const datePart = dayStr.slice(0, 10); - if (datePart.match(/^\d{4}-\d{2}-\d{2}$/)) { - return `${datePart}T12:00:00.000Z`; - } - } - try { - const d = new Date(dayStr + "T00:00:00.000Z"); - if (!Number.isNaN(d.getTime())) { - return d.toISOString().slice(0, 10) + "T12:00:00.000Z"; - } - } catch { - } - return null; + if (!day || day === "undefined" || day === "null") return null; + const dayStr = String(day); + if (dayStr.length >= 10) { + const datePart = dayStr.slice(0, 10); + if (datePart.match(/^\d{4}-\d{2}-\d{2}$/)) { + return `${datePart}T12:00:00.000Z`; + } + } + try { + const d = new Date(dayStr + "T00:00:00.000Z"); + if (!Number.isNaN(d.getTime())) { + return d.toISOString().slice(0, 10) + "T12:00:00.000Z"; + } + } catch {} + return null; } function* generateSessionIds(prefix, count) { - for (let i = 0; i < count; i++) { - yield `${prefix}-${i}`; - } + for (let i = 0; i < count; i++) { + yield `${prefix}-${i}`; + } } function toNdjson(rows) { - return rows.map((r) => JSON.stringify(r)).join("\n"); + return rows.map((r) => JSON.stringify(r)).join("\n"); } -async function tinybirdIngest({ host, token, datasource, ndjson, rateLimiter }) { - const maxAttempts = 5; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (rateLimiter) { - await rateLimiter.wait(); - } - const search = new URLSearchParams({ name: datasource, format: "ndjson" }); - const url = `${host.replace(/\/$/, "")}/v0/events?${search.toString()}`; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/x-ndjson", - Accept: "application/json", - }, - body: ndjson, - }); - const text = await response.text(); - if (response.ok) { - try { - return text ? JSON.parse(text) : {}; - } catch { - return {}; - } - } - const status = response.status; - let responseData = null; - try { - responseData = text ? JSON.parse(text) : null; - } catch {} - - if (status === 429 && responseData && (responseData.quarantined || responseData.imported || responseData.success)) { - return responseData; - } - - const shouldRetry = status === 429 || (status >= 500 && status < 600); - if (!shouldRetry || attempt === maxAttempts) { - let message = text; - if (responseData) { - message = responseData?.error || responseData?.message || text; - } - throw new Error(`Tinybird ingest failed (${status}): ${message}`); - } - const retryAfter = response.headers.get("retry-after"); - let waitTime; - if (retryAfter) { - const retryAfterNum = Number(retryAfter); - if (Number.isFinite(retryAfterNum) && retryAfterNum > 0) { - if (retryAfterNum < 86400) { - waitTime = retryAfterNum * 1000; - } else if (retryAfterNum < 86400000) { - waitTime = retryAfterNum; - } else { - waitTime = 60000; - } - if (waitTime > 60000) { - waitTime = 60000; - } - } else { - waitTime = Math.min(500 * 2 ** (attempt - 1), 60000); - } - } else { - waitTime = Math.min(500 * 2 ** (attempt - 1), 60000); - } - const jitter = Math.floor(Math.random() * 250); - await sleep(Math.min(waitTime + jitter, 60000)); - } +async function tinybirdIngest({ + host, + token, + datasource, + ndjson, + rateLimiter, +}) { + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (rateLimiter) { + await rateLimiter.wait(); + } + const search = new URLSearchParams({ name: datasource, format: "ndjson" }); + const url = `${host.replace(/\/$/, "")}/v0/events?${search.toString()}`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + Accept: "application/json", + }, + body: ndjson, + }); + const text = await response.text(); + if (response.ok) { + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } + } + const status = response.status; + let responseData = null; + try { + responseData = text ? JSON.parse(text) : null; + } catch {} + + if ( + status === 429 && + responseData && + (responseData.quarantined || + responseData.imported || + responseData.success) + ) { + return responseData; + } + + const shouldRetry = status === 429 || (status >= 500 && status < 600); + if (!shouldRetry || attempt === maxAttempts) { + let message = text; + if (responseData) { + message = responseData?.error || responseData?.message || text; + } + throw new Error(`Tinybird ingest failed (${status}): ${message}`); + } + const retryAfter = response.headers.get("retry-after"); + let waitTime; + if (retryAfter) { + const retryAfterNum = Number(retryAfter); + if (Number.isFinite(retryAfterNum) && retryAfterNum > 0) { + if (retryAfterNum < 86400) { + waitTime = retryAfterNum * 1000; + } else if (retryAfterNum < 86400000) { + waitTime = retryAfterNum; + } else { + waitTime = 60000; + } + if (waitTime > 60000) { + waitTime = 60000; + } + } else { + waitTime = Math.min(500 * 2 ** (attempt - 1), 60000); + } + } else { + waitTime = Math.min(500 * 2 ** (attempt - 1), 60000); + } + const jitter = Math.floor(Math.random() * 250); + await sleep(Math.min(waitTime + jitter, 60000)); + } } -async function migrateVideo({ tokenDub, tb, domain, videoId, orgId = "", window, limits, dryRun, apiConcurrency }) { - const pathname = `/s/${videoId}`; - const baseArgs = { - token: tokenDub, - domain, - key: videoId, - timezone: window.timezone, - start: window.start, - end: window.end, - interval: window.interval, - }; +async function migrateVideo({ + tokenDub, + tb, + domain, + videoId, + orgId = "", + window, + limits, + dryRun, + apiConcurrency, +}) { + const pathname = `/s/${videoId}`; + const baseArgs = { + token: tokenDub, + domain, + key: videoId, + timezone: window.timezone, + start: window.start, + end: window.end, + interval: window.interval, + }; + + const apiLimit = createLimiter(apiConcurrency || DEFAULT_API_CONCURRENCY); + + const timeseries = await apiLimit(() => dubRetrieveTimeseries(baseArgs)); + + if (timeseries.length === 0) { + if (dryRun) { + return { + videoId, + orgId, + timeseriesPoints: 0, + citiesConsidered: 0, + plannedEvents: 0, + sample: [], + }; + } + return { videoId, orgId, written: 0 }; + } + + const [countries, cities, browsers, devices, os] = await Promise.all([ + apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "countries" })), + apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "cities" })), + apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "browsers" })), + apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "devices" })), + apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "os" })), + ]); + + const selectedCities = cities + .filter((c) => c.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, limits.maxCities) + .map((city) => { + const resolvedRegion = resolveRegionName( + city.region, + city.regionCode, + city.countryCode || city.country, + ); + return resolvedRegion ? { ...city, region: resolvedRegion } : city; + }); + const cityMetaMap = new Map(selectedCities.map((city) => [city.name, city])); + + if (dryRun && timeseries.length > 0) { + console.log(` Sample timeseries entries (showing first 5):`); + timeseries.slice(0, 5).forEach((entry, idx) => { + console.log(` [${idx}]:`, JSON.stringify(entry)); + }); + if (countries.length > 0) + console.log(` Sample countries:`, JSON.stringify(countries.slice(0, 3))); + if (browsers.length > 0) + console.log(` Sample browsers:`, JSON.stringify(browsers.slice(0, 3))); + } + + const cityToCountryMap = new Map(); + const cityToCountryCodeMap = new Map(); + const cityToRegionMap = new Map(); + for (const city of selectedCities) { + const resolvedCountryCode = resolveCountryCode( + city.countryCode, + city.country, + city.continent || + (city.countryCode + ? COUNTRIES_TO_CONTINENTS[city.countryCode.toUpperCase()] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || city.country || "", + ); + } else if (city.country) { + cityToCountryMap.set(city.name, city.country); + } + if (city.region) cityToRegionMap.set(city.name, city.region); + } - const apiLimit = createLimiter(apiConcurrency || DEFAULT_API_CONCURRENCY); - - const timeseries = await apiLimit(() => dubRetrieveTimeseries(baseArgs)); - - if (timeseries.length === 0) { - if (dryRun) { - return { - videoId, - orgId, - timeseriesPoints: 0, - citiesConsidered: 0, - plannedEvents: 0, - sample: [], - }; - } - return { videoId, orgId, written: 0 }; - } - - const [countries, cities, browsers, devices, os] = await Promise.all([ - apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "countries" })), - apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "cities" })), - apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "browsers" })), - apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "devices" })), - apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "os" })), - ]); - - const selectedCities = cities - .filter((c) => c.value > 0) - .sort((a, b) => b.value - a.value) - .slice(0, limits.maxCities) - .map((city) => { - const resolvedRegion = resolveRegionName(city.region, city.regionCode, city.countryCode || city.country); - return resolvedRegion ? { ...city, region: resolvedRegion } : city; - }); - const cityMetaMap = new Map(selectedCities.map((city) => [city.name, city])); - - if (dryRun && timeseries.length > 0) { - console.log(` Sample timeseries entries (showing first 5):`); - timeseries.slice(0, 5).forEach((entry, idx) => { - console.log(` [${idx}]:`, JSON.stringify(entry)); - }); - if (countries.length > 0) console.log(` Sample countries:`, JSON.stringify(countries.slice(0, 3))); - if (browsers.length > 0) console.log(` Sample browsers:`, JSON.stringify(browsers.slice(0, 3))); - } + // Try to get country/region for cities by querying cities breakdown which might include country info + // If that doesn't work, try filtering countries/regions by city + const citiesMissingCountry = selectedCities.filter( + (city) => !cityToCountryMap.has(city.name), + ); + await mapWithConcurrency( + citiesMissingCountry, + async (city) => { + const cityWithCountry = await apiLimit(() => + dubRetrieveBreakdown({ + ...baseArgs, + groupBy: "countries", + city: city.name, + }), + ); + const validCountries = cityWithCountry.filter( + (c) => c.name && c.name !== "*" && c.value > 0, + ); + if (validCountries.length === 1) { + const resolvedCountryCode = resolveCountryCode( + validCountries[0].countryCode, + validCountries[0].name, + validCountries[0].continent || + (validCountries[0].countryCode + ? COUNTRIES_TO_CONTINENTS[ + validCountries[0].countryCode.toUpperCase() + ] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || validCountries[0].name, + ); + } else { + cityToCountryMap.set(city.name, validCountries[0].name); + } + } else if (validCountries.length > 1) { + const topCountry = validCountries.sort((a, b) => b.value - a.value)[0]; + const resolvedCountryCode = resolveCountryCode( + topCountry.countryCode, + topCountry.name, + topCountry.continent || + (topCountry.countryCode + ? COUNTRIES_TO_CONTINENTS[topCountry.countryCode.toUpperCase()] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || topCountry.name, + ); + } else { + cityToCountryMap.set(city.name, topCountry.name); + } + } + const cityWithRegion = await apiLimit(() => + dubRetrieveBreakdown({ + ...baseArgs, + groupBy: "regions", + city: city.name, + }), + ); + const validRegions = cityWithRegion.filter( + (r) => r.name && r.name !== "*" && r.value > 0, + ); + if (validRegions.length === 1) { + const regionCountryCode = + validRegions[0].countryCode || + validRegions[0].country || + city.country; + const resolvedRegion = resolveRegionName( + validRegions[0].region || validRegions[0].name, + validRegions[0].regionCode, + regionCountryCode, + ); + if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); + if (!cityToCountryCodeMap.has(city.name)) { + const resolvedCountryCode = resolveCountryCode( + validRegions[0].countryCode, + validRegions[0].country || city.country, + validRegions[0].continent || + (validRegions[0].countryCode + ? COUNTRIES_TO_CONTINENTS[ + validRegions[0].countryCode.toUpperCase() + ] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + if (!cityToCountryMap.has(city.name)) { + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + validRegions[0].country || + city.country || + "", + ); + } + } + } + } else if (validRegions.length > 1) { + const topRegion = validRegions.sort((a, b) => b.value - a.value)[0]; + const regionCountryCode = + topRegion.countryCode || topRegion.country || city.country; + const resolvedRegion = resolveRegionName( + topRegion.region || topRegion.name, + topRegion.regionCode, + regionCountryCode, + ); + if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); + if (!cityToCountryCodeMap.has(city.name)) { + const resolvedCountryCode = resolveCountryCode( + topRegion.countryCode, + topRegion.country || city.country, + topRegion.continent || + (topRegion.countryCode + ? COUNTRIES_TO_CONTINENTS[topRegion.countryCode.toUpperCase()] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + if (!cityToCountryMap.has(city.name)) { + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + topRegion.country || + city.country || + "", + ); + } + } + } + } + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); + const citiesMissingRegion = selectedCities.filter( + (city) => !cityToRegionMap.has(city.name), + ); + await mapWithConcurrency( + citiesMissingRegion, + async (city) => { + if (cityToRegionMap.has(city.name)) return; + const cityWithRegion = await apiLimit(() => + dubRetrieveBreakdown({ + ...baseArgs, + groupBy: "regions", + city: city.name, + }), + ); + const validRegions = cityWithRegion.filter( + (r) => r.name && r.name !== "*" && r.value > 0, + ); + if (validRegions.length === 1) { + const regionCountryCode = + validRegions[0].countryCode || + validRegions[0].country || + city.country; + const resolvedRegion = resolveRegionName( + validRegions[0].region || validRegions[0].name, + validRegions[0].regionCode, + regionCountryCode, + ); + if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); + if (!cityToCountryCodeMap.has(city.name)) { + const resolvedCountryCode = resolveCountryCode( + validRegions[0].countryCode, + validRegions[0].country || city.country, + validRegions[0].continent || + (validRegions[0].countryCode + ? COUNTRIES_TO_CONTINENTS[ + validRegions[0].countryCode.toUpperCase() + ] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + if (!cityToCountryMap.has(city.name)) { + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + validRegions[0].country || + city.country || + "", + ); + } + } + } + } else if (validRegions.length > 1) { + const topRegion = validRegions.sort((a, b) => b.value - a.value)[0]; + const regionCountryCode = + topRegion.countryCode || topRegion.country || city.country; + const resolvedRegion = resolveRegionName( + topRegion.region || topRegion.name, + topRegion.regionCode, + regionCountryCode, + ); + if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); + if (!cityToCountryCodeMap.has(city.name)) { + const resolvedCountryCode = resolveCountryCode( + topRegion.countryCode, + topRegion.country || city.country, + topRegion.continent || + (topRegion.countryCode + ? COUNTRIES_TO_CONTINENTS[topRegion.countryCode.toUpperCase()] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + if (!cityToCountryMap.has(city.name)) { + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + topRegion.country || + city.country || + "", + ); + } + } + } + } + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); - const cityToCountryMap = new Map(); - const cityToCountryCodeMap = new Map(); - const cityToRegionMap = new Map(); - for (const city of selectedCities) { - const resolvedCountryCode = resolveCountryCode( - city.countryCode, - city.country, - city.continent || (city.countryCode ? COUNTRIES_TO_CONTINENTS[city.countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || city.country || ""); - } else if (city.country) { - cityToCountryMap.set(city.name, city.country); - } - if (city.region) cityToRegionMap.set(city.name, city.region); - } - - // Try to get country/region for cities by querying cities breakdown which might include country info - // If that doesn't work, try filtering countries/regions by city - const citiesMissingCountry = selectedCities.filter((city) => !cityToCountryMap.has(city.name)); - await mapWithConcurrency(citiesMissingCountry, async (city) => { - const cityWithCountry = await apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "countries", city: city.name })); - const validCountries = cityWithCountry.filter((c) => c.name && c.name !== "*" && c.value > 0); - if (validCountries.length === 1) { - const resolvedCountryCode = resolveCountryCode( - validCountries[0].countryCode, - validCountries[0].name, - validCountries[0].continent || (validCountries[0].countryCode ? COUNTRIES_TO_CONTINENTS[validCountries[0].countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || validCountries[0].name); - } else { - cityToCountryMap.set(city.name, validCountries[0].name); - } - } else if (validCountries.length > 1) { - const topCountry = validCountries.sort((a, b) => b.value - a.value)[0]; - const resolvedCountryCode = resolveCountryCode( - topCountry.countryCode, - topCountry.name, - topCountry.continent || (topCountry.countryCode ? COUNTRIES_TO_CONTINENTS[topCountry.countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || topCountry.name); - } else { - cityToCountryMap.set(city.name, topCountry.name); - } - } - const cityWithRegion = await apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "regions", city: city.name })); - const validRegions = cityWithRegion.filter((r) => r.name && r.name !== "*" && r.value > 0); - if (validRegions.length === 1) { - const regionCountryCode = validRegions[0].countryCode || validRegions[0].country || city.country; - const resolvedRegion = resolveRegionName( - validRegions[0].region || validRegions[0].name, - validRegions[0].regionCode, - regionCountryCode - ); - if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); - if (!cityToCountryCodeMap.has(city.name)) { - const resolvedCountryCode = resolveCountryCode( - validRegions[0].countryCode, - validRegions[0].country || city.country, - validRegions[0].continent || (validRegions[0].countryCode ? COUNTRIES_TO_CONTINENTS[validRegions[0].countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - if (!cityToCountryMap.has(city.name)) { - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || validRegions[0].country || city.country || ""); - } - } - } - } else if (validRegions.length > 1) { - const topRegion = validRegions.sort((a, b) => b.value - a.value)[0]; - const regionCountryCode = topRegion.countryCode || topRegion.country || city.country; - const resolvedRegion = resolveRegionName( - topRegion.region || topRegion.name, - topRegion.regionCode, - regionCountryCode - ); - if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); - if (!cityToCountryCodeMap.has(city.name)) { - const resolvedCountryCode = resolveCountryCode( - topRegion.countryCode, - topRegion.country || city.country, - topRegion.continent || (topRegion.countryCode ? COUNTRIES_TO_CONTINENTS[topRegion.countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - if (!cityToCountryMap.has(city.name)) { - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || topRegion.country || city.country || ""); - } - } - } - } - }, apiConcurrency || DEFAULT_API_CONCURRENCY); - const citiesMissingRegion = selectedCities.filter((city) => !cityToRegionMap.has(city.name)); - await mapWithConcurrency(citiesMissingRegion, async (city) => { - if (cityToRegionMap.has(city.name)) return; - const cityWithRegion = await apiLimit(() => dubRetrieveBreakdown({ ...baseArgs, groupBy: "regions", city: city.name })); - const validRegions = cityWithRegion.filter((r) => r.name && r.name !== "*" && r.value > 0); - if (validRegions.length === 1) { - const regionCountryCode = validRegions[0].countryCode || validRegions[0].country || city.country; - const resolvedRegion = resolveRegionName( - validRegions[0].region || validRegions[0].name, - validRegions[0].regionCode, - regionCountryCode - ); - if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); - if (!cityToCountryCodeMap.has(city.name)) { - const resolvedCountryCode = resolveCountryCode( - validRegions[0].countryCode, - validRegions[0].country || city.country, - validRegions[0].continent || (validRegions[0].countryCode ? COUNTRIES_TO_CONTINENTS[validRegions[0].countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - if (!cityToCountryMap.has(city.name)) { - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || validRegions[0].country || city.country || ""); - } - } - } - } else if (validRegions.length > 1) { - const topRegion = validRegions.sort((a, b) => b.value - a.value)[0]; - const regionCountryCode = topRegion.countryCode || topRegion.country || city.country; - const resolvedRegion = resolveRegionName( - topRegion.region || topRegion.name, - topRegion.regionCode, - regionCountryCode - ); - if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); - if (!cityToCountryCodeMap.has(city.name)) { - const resolvedCountryCode = resolveCountryCode( - topRegion.countryCode, - topRegion.country || city.country, - topRegion.continent || (topRegion.countryCode ? COUNTRIES_TO_CONTINENTS[topRegion.countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - if (!cityToCountryMap.has(city.name)) { - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || topRegion.country || city.country || ""); - } - } - } - } - }, apiConcurrency || DEFAULT_API_CONCURRENCY); - - // Fallback: if we have cities but no country mapping, try to match cities to countries from overall breakdown - // by checking if city name appears in country-specific city breakdowns - const unresolvedCountryCities = selectedCities.filter((city) => !cityToCountryMap.has(city.name)); - if (unresolvedCountryCities.length > 0 && countries.length > 0) { - const topCountries = countries.slice(0, 10); - const countryCitiesList = await mapWithConcurrency( - topCountries, - (country) => apiLimit(() => - dubRetrieveBreakdown({ ...baseArgs, groupBy: "cities", country: country.name }) - .then((rows) => ({ country, rows })) - ), - apiConcurrency || DEFAULT_API_CONCURRENCY - ); - for (const { country, rows: countryCities } of countryCitiesList) { - for (const city of unresolvedCountryCities) { - if (cityToCountryMap.has(city.name)) continue; - const matchingCity = countryCities.find((c) => c.name === city.name || c.city === city.name); - if (matchingCity) { - if (!cityToCountryCodeMap.has(city.name)) { - const resolvedCountryCode = resolveCountryCode( - matchingCity.countryCode, - matchingCity.country || country.name, - matchingCity.continent || (matchingCity.countryCode ? COUNTRIES_TO_CONTINENTS[matchingCity.countryCode.toUpperCase()] : undefined) - ); - if (resolvedCountryCode) { - cityToCountryCodeMap.set(city.name, resolvedCountryCode); - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || matchingCity.country || country.name || matchingCity.name); - } else { - const resolvedCountry = matchingCity.country || country.name || matchingCity.name; - if (resolvedCountry) cityToCountryMap.set(city.name, resolvedCountry); - } - } else if (!cityToCountryMap.has(city.name)) { - const resolvedCountryCode = cityToCountryCodeMap.get(city.name); - cityToCountryMap.set(city.name, COUNTRIES[resolvedCountryCode] || matchingCity.country || country.name || matchingCity.name); - } - if (!cityToRegionMap.has(city.name) && (matchingCity.region || matchingCity.regionCode)) { - const resolvedRegion = resolveRegionName( - matchingCity.region || matchingCity.name, - matchingCity.regionCode, - matchingCity.countryCode || matchingCity.country || country.name - ); - if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); - } - } - } - } - } + // Fallback: if we have cities but no country mapping, try to match cities to countries from overall breakdown + // by checking if city name appears in country-specific city breakdowns + const unresolvedCountryCities = selectedCities.filter( + (city) => !cityToCountryMap.has(city.name), + ); + if (unresolvedCountryCities.length > 0 && countries.length > 0) { + const topCountries = countries.slice(0, 10); + const countryCitiesList = await mapWithConcurrency( + topCountries, + (country) => + apiLimit(() => + dubRetrieveBreakdown({ + ...baseArgs, + groupBy: "cities", + country: country.name, + }).then((rows) => ({ country, rows })), + ), + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); + for (const { country, rows: countryCities } of countryCitiesList) { + for (const city of unresolvedCountryCities) { + if (cityToCountryMap.has(city.name)) continue; + const matchingCity = countryCities.find( + (c) => c.name === city.name || c.city === city.name, + ); + if (matchingCity) { + if (!cityToCountryCodeMap.has(city.name)) { + const resolvedCountryCode = resolveCountryCode( + matchingCity.countryCode, + matchingCity.country || country.name, + matchingCity.continent || + (matchingCity.countryCode + ? COUNTRIES_TO_CONTINENTS[ + matchingCity.countryCode.toUpperCase() + ] + : undefined), + ); + if (resolvedCountryCode) { + cityToCountryCodeMap.set(city.name, resolvedCountryCode); + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + matchingCity.country || + country.name || + matchingCity.name, + ); + } else { + const resolvedCountry = + matchingCity.country || country.name || matchingCity.name; + if (resolvedCountry) + cityToCountryMap.set(city.name, resolvedCountry); + } + } else if (!cityToCountryMap.has(city.name)) { + const resolvedCountryCode = cityToCountryCodeMap.get(city.name); + cityToCountryMap.set( + city.name, + COUNTRIES[resolvedCountryCode] || + matchingCity.country || + country.name || + matchingCity.name, + ); + } + if ( + !cityToRegionMap.has(city.name) && + (matchingCity.region || matchingCity.regionCode) + ) { + const resolvedRegion = resolveRegionName( + matchingCity.region || matchingCity.name, + matchingCity.regionCode, + matchingCity.countryCode || matchingCity.country || country.name, + ); + if (resolvedRegion) cityToRegionMap.set(city.name, resolvedRegion); + } + } + } + } + } - const perCityTimeseries = new Map(); - await mapWithConcurrency(selectedCities, async (c) => { - const series = await apiLimit(() => dubRetrieveTimeseries({ ...baseArgs, city: c.name })); - perCityTimeseries.set(c.name, series); - }, apiConcurrency || DEFAULT_API_CONCURRENCY); - - const perBrowserTimeseries = new Map(); - const topBrowsers = browsers.filter((b) => b.value > 0).sort((a, b) => b.value - a.value).slice(0, 10); - await mapWithConcurrency(topBrowsers, async (browser) => { - const series = await apiLimit(() => dubRetrieveTimeseries({ ...baseArgs, browser: browser.name })); - perBrowserTimeseries.set(browser.name, series); - }, apiConcurrency || DEFAULT_API_CONCURRENCY); - - const perDeviceTimeseries = new Map(); - const topDevices = devices.filter((d) => d.value > 0).sort((a, b) => b.value - a.value).slice(0, 5); - await mapWithConcurrency(topDevices, async (device) => { - const series = await apiLimit(() => dubRetrieveTimeseries({ ...baseArgs, device: device.name })); - perDeviceTimeseries.set(device.name, series); - }, apiConcurrency || DEFAULT_API_CONCURRENCY); - - const perOSTimeseries = new Map(); - const topOS = os.filter((o) => o.value > 0).sort((a, b) => b.value - a.value).slice(0, 5); - await mapWithConcurrency(topOS, async (osItem) => { - const series = await apiLimit(() => dubRetrieveTimeseries({ ...baseArgs, os: osItem.name })); - perOSTimeseries.set(osItem.name, series); - }, apiConcurrency || DEFAULT_API_CONCURRENCY); + const perCityTimeseries = new Map(); + await mapWithConcurrency( + selectedCities, + async (c) => { + const series = await apiLimit(() => + dubRetrieveTimeseries({ ...baseArgs, city: c.name }), + ); + perCityTimeseries.set(c.name, series); + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); - // Build rows per-city per-day with browser/device/OS distribution - const rows = []; - const dayRowMap = new Map(); - for (const seriesItem of timeseries) { - if (seriesItem.count <= 0) continue; - const tsStr = String(seriesItem.timestamp); - if (!tsStr || tsStr === "undefined" || tsStr === "null") continue; - - let parsedDate; - let day; - let tsIso; - - if (tsStr.includes("T") && tsStr.includes(":")) { - parsedDate = new Date(tsStr); - if (!Number.isNaN(parsedDate.getTime())) { - day = parsedDate.toISOString().slice(0, 10); - tsIso = `${day}T00:00:00.000Z`; - } else { - day = tsStr.slice(0, 10); - tsIso = dayMidpointIso(day); - } - } else { - day = tsStr.slice(0, 10); - const dateStrWithUTC = tsStr.includes("Z") || tsStr.match(/[+-]\d{2}:?\d{2}$/) ? tsStr : tsStr + "T00:00:00.000Z"; - parsedDate = new Date(dateStrWithUTC); - if (!Number.isNaN(parsedDate.getTime())) { - day = parsedDate.toISOString().slice(0, 10); - tsIso = `${day}T00:00:00.000Z`; - } else { - tsIso = dayMidpointIso(day); - } - } - - if (!day || day.length < 10 || day === "undefined") continue; - if (!tsIso) continue; - const dayTotal = seriesItem.count; - let dayData = dayRowMap.get(day); - if (!dayData) { - dayData = { total: dayTotal, rows: [] }; - dayRowMap.set(day, dayData); - } else { - dayData.total = Math.max(dayData.total, dayTotal); - } - const dayRows = dayData.rows; + const perBrowserTimeseries = new Map(); + const topBrowsers = browsers + .filter((b) => b.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + await mapWithConcurrency( + topBrowsers, + async (browser) => { + const series = await apiLimit(() => + dubRetrieveTimeseries({ ...baseArgs, browser: browser.name }), + ); + perBrowserTimeseries.set(browser.name, series); + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); - // Get browser/device/OS breakdowns for this day - const dayBrowsers = []; - for (const [browserName, browserSeries] of perBrowserTimeseries.entries()) { - const dayEntry = browserSeries.find((s) => String(s.timestamp).slice(0, 10) === day); - if (dayEntry && dayEntry.count > 0) { - dayBrowsers.push({ name: browserName, count: dayEntry.count }); - } - } - dayBrowsers.sort((a, b) => b.count - a.count); - const totalBrowserClicks = dayBrowsers.reduce((a, b) => a + b.count, 0); - - const dayDevices = []; - for (const [deviceName, deviceSeries] of perDeviceTimeseries.entries()) { - const dayEntry = deviceSeries.find((s) => String(s.timestamp).slice(0, 10) === day); - if (dayEntry && dayEntry.count > 0) { - dayDevices.push({ name: deviceName, count: dayEntry.count }); - } - } - dayDevices.sort((a, b) => b.count - a.count); - - const dayOS = []; - for (const [osName, osSeries] of perOSTimeseries.entries()) { - const dayEntry = osSeries.find((s) => String(s.timestamp).slice(0, 10) === day); - if (dayEntry && dayEntry.count > 0) { - dayOS.push({ name: osName, count: dayEntry.count }); - } - } - dayOS.sort((a, b) => b.count - a.count); + const perDeviceTimeseries = new Map(); + const topDevices = devices + .filter((d) => d.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 5); + await mapWithConcurrency( + topDevices, + async (device) => { + const series = await apiLimit(() => + dubRetrieveTimeseries({ ...baseArgs, device: device.name }), + ); + perDeviceTimeseries.set(device.name, series); + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); - let allocated = 0; - for (const [cityName, citySeries] of perCityTimeseries.entries()) { - const dayEntry = citySeries.find((s) => String(s.timestamp).slice(0, 10) === day); - const cityCount = dayEntry?.count || 0; - if (cityCount <= 0) continue; - allocated += cityCount; - - const cityMeta = cityMetaMap.get(cityName); - const resolvedCountryCode = cityToCountryCodeMap.get(cityName); - let country = ""; - if (resolvedCountryCode && COUNTRIES[resolvedCountryCode]) { - country = COUNTRIES[resolvedCountryCode]; - } else { - const fallbackCountry = cityToCountryMap.get(cityName) || cityMeta?.country || ""; - if (fallbackCountry) { - const fallbackCode = fallbackCountry.toUpperCase(); - if (COUNTRIES[fallbackCode]) { - country = COUNTRIES[fallbackCode]; - } else { - country = fallbackCountry; - } - } - } - const region = cityToRegionMap.get(cityName) || cityMeta?.region || ""; - - // Distribute city clicks across browsers proportionally - let browserAllocated = 0; - for (const browser of dayBrowsers) { - const browserProportion = totalBrowserClicks > 0 ? browser.count / totalBrowserClicks : 0; - const browserAllocation = Math.round(cityCount * browserProportion); - if (browserAllocation <= 0) continue; - browserAllocated += browserAllocation; - - // For each browser allocation, use most common device/OS for that day - const device = dayDevices[0]?.name || topDevices[0]?.name || "desktop"; - const os = dayOS[0]?.name || topOS[0]?.name || "unknown"; - - const sidPrefix = `mig:${videoId}:${day}:${cityName}:${browser.name}`; - for (const sid of generateSessionIds(sidPrefix, browserAllocation)) { - dayRows.push({ - timestamp: tsIso, - session_id: sid, - tenant_id: orgId, - action: "page_hit", - version: "dub_migration_v1", - pathname, - video_id: videoId, - country, - region, - city: cityName, - browser: browser.name, - device, - os, - }); - } - } - - // Handle remainder for browsers (or if no browser data) - const browserRemainder = cityCount - browserAllocated; - if (browserRemainder > 0 || totalBrowserClicks === 0) { - const defaultBrowser = dayBrowsers[0]?.name || topBrowsers[0]?.name || "unknown"; - const defaultDevice = dayDevices[0]?.name || topDevices[0]?.name || "desktop"; - const defaultOS = dayOS[0]?.name || topOS[0]?.name || "unknown"; - const sidPrefix = `mig:${videoId}:${day}:${cityName}:${defaultBrowser}`; - for (const sid of generateSessionIds(sidPrefix, browserRemainder)) { - dayRows.push({ - timestamp: tsIso, - session_id: sid, - tenant_id: orgId, - action: "page_hit", - version: "dub_migration_v1", - pathname, - video_id: videoId, - country, - region, - city: cityName, - browser: defaultBrowser, - device: defaultDevice, - os: defaultOS, - }); - } - } - } + const perOSTimeseries = new Map(); + const topOS = os + .filter((o) => o.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 5); + await mapWithConcurrency( + topOS, + async (osItem) => { + const series = await apiLimit(() => + dubRetrieveTimeseries({ ...baseArgs, os: osItem.name }), + ); + perOSTimeseries.set(osItem.name, series); + }, + apiConcurrency || DEFAULT_API_CONCURRENCY, + ); - // Handle remainder (uncategorized) - const remainder = Math.max(0, dayTotal - allocated); - if (remainder > 0) { - const defaultCountry = countries[0]?.name || ""; - const defaultBrowser = dayBrowsers[0]?.name || topBrowsers[0]?.name || "unknown"; - const defaultDevice = dayDevices[0]?.name || topDevices[0]?.name || "desktop"; - const defaultOS = dayOS[0]?.name || topOS[0]?.name || "unknown"; - const sidPrefix = `mig:${videoId}:${day}:__uncategorized__`; - for (const sid of generateSessionIds(sidPrefix, remainder)) { - dayRows.push({ - timestamp: tsIso, - session_id: sid, - tenant_id: orgId, - action: "page_hit", - version: "dub_migration_v1", - pathname, - video_id: videoId, - country: defaultCountry, - region: "", - city: "", - browser: defaultBrowser, - device: defaultDevice, - os: defaultOS, - }); - } - } - } + // Build rows per-city per-day with browser/device/OS distribution + const rows = []; + const dayRowMap = new Map(); + for (const seriesItem of timeseries) { + if (seriesItem.count <= 0) continue; + const tsStr = String(seriesItem.timestamp); + if (!tsStr || tsStr === "undefined" || tsStr === "null") continue; - for (const [day, { total: dayTotal, rows: dayRows }] of dayRowMap.entries()) { - if (dayRows.length > dayTotal) { - const excess = dayRows.length - dayTotal; - console.log(` Day ${day}: Capping rows from ${dayRows.length} to ${dayTotal} (removing ${excess} excess row(s))`); - dayRows.splice(dayTotal); - } - rows.push(...dayRows); - } + let parsedDate; + let day; + let tsIso; - const totalPlanned = rows.length; + if (tsStr.includes("T") && tsStr.includes(":")) { + parsedDate = new Date(tsStr); + if (!Number.isNaN(parsedDate.getTime())) { + day = parsedDate.toISOString().slice(0, 10); + tsIso = `${day}T00:00:00.000Z`; + } else { + day = tsStr.slice(0, 10); + tsIso = dayMidpointIso(day); + } + } else { + day = tsStr.slice(0, 10); + const dateStrWithUTC = + tsStr.includes("Z") || tsStr.match(/[+-]\d{2}:?\d{2}$/) + ? tsStr + : tsStr + "T00:00:00.000Z"; + parsedDate = new Date(dateStrWithUTC); + if (!Number.isNaN(parsedDate.getTime())) { + day = parsedDate.toISOString().slice(0, 10); + tsIso = `${day}T00:00:00.000Z`; + } else { + tsIso = dayMidpointIso(day); + } + } - const seen = new Map(); - const deduplicatedRows = []; - for (const row of rows) { - const key = `${row.timestamp}|${row.session_id}|${row.video_id}`; - if (!seen.has(key)) { - seen.set(key, true); - deduplicatedRows.push(row); - } - } + if (!day || day.length < 10 || day === "undefined") continue; + if (!tsIso) continue; + const dayTotal = seriesItem.count; + let dayData = dayRowMap.get(day); + if (!dayData) { + dayData = { total: dayTotal, rows: [] }; + dayRowMap.set(day, dayData); + } else { + dayData.total = Math.max(dayData.total, dayTotal); + } + const dayRows = dayData.rows; - const duplicatesRemoved = totalPlanned - deduplicatedRows.length; - if (duplicatesRemoved > 0) { - console.log(` Removed ${duplicatesRemoved} duplicate row(s) (${totalPlanned} -> ${deduplicatedRows.length})`); - } + // Get browser/device/OS breakdowns for this day + const dayBrowsers = []; + for (const [browserName, browserSeries] of perBrowserTimeseries.entries()) { + const dayEntry = browserSeries.find( + (s) => String(s.timestamp).slice(0, 10) === day, + ); + if (dayEntry && dayEntry.count > 0) { + dayBrowsers.push({ name: browserName, count: dayEntry.count }); + } + } + dayBrowsers.sort((a, b) => b.count - a.count); + const totalBrowserClicks = dayBrowsers.reduce((a, b) => a + b.count, 0); - if (dryRun) { - return { - videoId, - orgId, - timeseriesPoints: timeseries.length, - citiesConsidered: selectedCities.length, - plannedEvents: deduplicatedRows.length, - duplicatesRemoved, - sample: deduplicatedRows.slice(0, Math.min(3, deduplicatedRows.length)), - }; - } + const dayDevices = []; + for (const [deviceName, deviceSeries] of perDeviceTimeseries.entries()) { + const dayEntry = deviceSeries.find( + (s) => String(s.timestamp).slice(0, 10) === day, + ); + if (dayEntry && dayEntry.count > 0) { + dayDevices.push({ name: deviceName, count: dayEntry.count }); + } + } + dayDevices.sort((a, b) => b.count - a.count); - let written = 0; - const chunkSize = limits.ingestChunkSize || INGEST_CHUNK_SIZE; - const ingestConcurrency = limits.ingestConcurrency || DEFAULT_INGEST_CONCURRENCY; - const ingestRateLimit = limits.ingestRateLimit || DEFAULT_INGEST_RATE_LIMIT; - const rateLimiter = createTinybirdRateLimiter(ingestRateLimit); - const chunks = []; - for (let i = 0; i < deduplicatedRows.length; i += chunkSize) { - chunks.push(deduplicatedRows.slice(i, i + chunkSize)); - } - const ingestLimit = createLimiter(ingestConcurrency); - const results = await Promise.all( - chunks.map((chunk) => - ingestLimit(async () => { - const ndjson = toNdjson(chunk); - await tinybirdIngest({ - host: tb.host, - token: tb.token, - datasource: TB_DATASOURCE, - ndjson, - rateLimiter, - }); - return chunk.length; - }) - ) - ); - written = results.reduce((a, b) => a + b, 0); + const dayOS = []; + for (const [osName, osSeries] of perOSTimeseries.entries()) { + const dayEntry = osSeries.find( + (s) => String(s.timestamp).slice(0, 10) === day, + ); + if (dayEntry && dayEntry.count > 0) { + dayOS.push({ name: osName, count: dayEntry.count }); + } + } + dayOS.sort((a, b) => b.count - a.count); - return { - videoId, - orgId, - written, - }; + let allocated = 0; + for (const [cityName, citySeries] of perCityTimeseries.entries()) { + const dayEntry = citySeries.find( + (s) => String(s.timestamp).slice(0, 10) === day, + ); + const cityCount = dayEntry?.count || 0; + if (cityCount <= 0) continue; + allocated += cityCount; + + const cityMeta = cityMetaMap.get(cityName); + const resolvedCountryCode = cityToCountryCodeMap.get(cityName); + let country = ""; + if (resolvedCountryCode && COUNTRIES[resolvedCountryCode]) { + country = COUNTRIES[resolvedCountryCode]; + } else { + const fallbackCountry = + cityToCountryMap.get(cityName) || cityMeta?.country || ""; + if (fallbackCountry) { + const fallbackCode = fallbackCountry.toUpperCase(); + if (COUNTRIES[fallbackCode]) { + country = COUNTRIES[fallbackCode]; + } else { + country = fallbackCountry; + } + } + } + const region = cityToRegionMap.get(cityName) || cityMeta?.region || ""; + + // Distribute city clicks across browsers proportionally + let browserAllocated = 0; + for (const browser of dayBrowsers) { + const browserProportion = + totalBrowserClicks > 0 ? browser.count / totalBrowserClicks : 0; + const browserAllocation = Math.round(cityCount * browserProportion); + if (browserAllocation <= 0) continue; + browserAllocated += browserAllocation; + + // For each browser allocation, use most common device/OS for that day + const device = dayDevices[0]?.name || topDevices[0]?.name || "desktop"; + const os = dayOS[0]?.name || topOS[0]?.name || "unknown"; + + const sidPrefix = `mig:${videoId}:${day}:${cityName}:${browser.name}`; + for (const sid of generateSessionIds(sidPrefix, browserAllocation)) { + dayRows.push({ + timestamp: tsIso, + session_id: sid, + tenant_id: orgId, + action: "page_hit", + version: "dub_migration_v1", + pathname, + video_id: videoId, + country, + region, + city: cityName, + browser: browser.name, + device, + os, + }); + } + } + + // Handle remainder for browsers (or if no browser data) + const browserRemainder = cityCount - browserAllocated; + if (browserRemainder > 0 || totalBrowserClicks === 0) { + const defaultBrowser = + dayBrowsers[0]?.name || topBrowsers[0]?.name || "unknown"; + const defaultDevice = + dayDevices[0]?.name || topDevices[0]?.name || "desktop"; + const defaultOS = dayOS[0]?.name || topOS[0]?.name || "unknown"; + const sidPrefix = `mig:${videoId}:${day}:${cityName}:${defaultBrowser}`; + for (const sid of generateSessionIds(sidPrefix, browserRemainder)) { + dayRows.push({ + timestamp: tsIso, + session_id: sid, + tenant_id: orgId, + action: "page_hit", + version: "dub_migration_v1", + pathname, + video_id: videoId, + country, + region, + city: cityName, + browser: defaultBrowser, + device: defaultDevice, + os: defaultOS, + }); + } + } + } + + // Handle remainder (uncategorized) + const remainder = Math.max(0, dayTotal - allocated); + if (remainder > 0) { + const defaultCountry = countries[0]?.name || ""; + const defaultBrowser = + dayBrowsers[0]?.name || topBrowsers[0]?.name || "unknown"; + const defaultDevice = + dayDevices[0]?.name || topDevices[0]?.name || "desktop"; + const defaultOS = dayOS[0]?.name || topOS[0]?.name || "unknown"; + const sidPrefix = `mig:${videoId}:${day}:__uncategorized__`; + for (const sid of generateSessionIds(sidPrefix, remainder)) { + dayRows.push({ + timestamp: tsIso, + session_id: sid, + tenant_id: orgId, + action: "page_hit", + version: "dub_migration_v1", + pathname, + video_id: videoId, + country: defaultCountry, + region: "", + city: "", + browser: defaultBrowser, + device: defaultDevice, + os: defaultOS, + }); + } + } + } + + for (const [day, { total: dayTotal, rows: dayRows }] of dayRowMap.entries()) { + if (dayRows.length > dayTotal) { + const excess = dayRows.length - dayTotal; + console.log( + ` Day ${day}: Capping rows from ${dayRows.length} to ${dayTotal} (removing ${excess} excess row(s))`, + ); + dayRows.splice(dayTotal); + } + rows.push(...dayRows); + } + + const totalPlanned = rows.length; + + const seen = new Map(); + const deduplicatedRows = []; + for (const row of rows) { + const key = `${row.timestamp}|${row.session_id}|${row.video_id}`; + if (!seen.has(key)) { + seen.set(key, true); + deduplicatedRows.push(row); + } + } + + const duplicatesRemoved = totalPlanned - deduplicatedRows.length; + if (duplicatesRemoved > 0) { + console.log( + ` Removed ${duplicatesRemoved} duplicate row(s) (${totalPlanned} -> ${deduplicatedRows.length})`, + ); + } + + if (dryRun) { + return { + videoId, + orgId, + timeseriesPoints: timeseries.length, + citiesConsidered: selectedCities.length, + plannedEvents: deduplicatedRows.length, + duplicatesRemoved, + sample: deduplicatedRows.slice(0, Math.min(3, deduplicatedRows.length)), + }; + } + + let written = 0; + const chunkSize = limits.ingestChunkSize || INGEST_CHUNK_SIZE; + const ingestConcurrency = + limits.ingestConcurrency || DEFAULT_INGEST_CONCURRENCY; + const ingestRateLimit = limits.ingestRateLimit || DEFAULT_INGEST_RATE_LIMIT; + const rateLimiter = createTinybirdRateLimiter(ingestRateLimit); + const chunks = []; + for (let i = 0; i < deduplicatedRows.length; i += chunkSize) { + chunks.push(deduplicatedRows.slice(i, i + chunkSize)); + } + const ingestLimit = createLimiter(ingestConcurrency); + const results = await Promise.all( + chunks.map((chunk) => + ingestLimit(async () => { + const ndjson = toNdjson(chunk); + await tinybirdIngest({ + host: tb.host, + token: tb.token, + datasource: TB_DATASOURCE, + ndjson, + rateLimiter, + }); + return chunk.length; + }), + ), + ); + written = results.reduce((a, b) => a + b, 0); + + return { + videoId, + orgId, + written, + }; } async function main() { - const args = parseArgs(process.argv); - if (!args.dryRun && !args.apply) usageAndExit("Specify --apply to perform writes or omit to dry run"); + const args = parseArgs(process.argv); + if (!args.dryRun && !args.apply) + usageAndExit("Specify --apply to perform writes or omit to dry run"); - const dubToken = requireEnv("DUB_API_KEY"); - const tbToken = args.dryRun ? null : requireEnv("TINYBIRD_TOKEN"); - const tbHost = DEFAULT_HOST; + const dubToken = requireEnv("DUB_API_KEY"); + const tbToken = args.dryRun ? null : requireEnv("TINYBIRD_TOKEN"); + const tbHost = DEFAULT_HOST; - let videoIds = [...args.videoIds]; + let videoIds = [...args.videoIds]; - if (videoIds.length === 0) { - console.log(`Fetching all links from domain: ${args.domain}...`); - const links = await dubFetchAllLinks({ - token: dubToken, - domain: args.domain, - maxLinks: args.limit || null - }); - videoIds = links.map((link) => link.key || link.id).filter(Boolean); - console.log(`Found ${videoIds.length} links total`); - } + if (videoIds.length === 0) { + console.log(`Fetching all links from domain: ${args.domain}...`); + const links = await dubFetchAllLinks({ + token: dubToken, + domain: args.domain, + maxLinks: args.limit || null, + }); + videoIds = links.map((link) => link.key || link.id).filter(Boolean); + console.log(`Found ${videoIds.length} links total`); + } - if (videoIds.length === 0) { - console.error("No video IDs found to migrate"); - process.exit(0); - } + if (videoIds.length === 0) { + console.error("No video IDs found to migrate"); + process.exit(0); + } - const originalCount = videoIds.length; - if (args.limit && args.limit > 0 && args.limit < videoIds.length) { - videoIds = videoIds.slice(0, args.limit); - console.log(`Limiting to ${args.limit} videos (out of ${originalCount} total)`); - } else if (args.limit && args.limit > 0) { - console.log(`Limit of ${args.limit} specified, but only ${originalCount} videos found`); - } + const originalCount = videoIds.length; + if (args.limit && args.limit > 0 && args.limit < videoIds.length) { + videoIds = videoIds.slice(0, args.limit); + console.log( + `Limiting to ${args.limit} videos (out of ${originalCount} total)`, + ); + } else if (args.limit && args.limit > 0) { + console.log( + `Limit of ${args.limit} specified, but only ${originalCount} videos found`, + ); + } - const map = loadVideoToOrgMap(args, videoIds); + const map = loadVideoToOrgMap(args, videoIds); - const window = { - interval: args.start || args.end ? undefined : args.interval, - start: args.start || undefined, - end: args.end || undefined, - timezone: args.timezone, - }; + const window = { + interval: args.start || args.end ? undefined : args.interval, + start: args.start || undefined, + end: args.end || undefined, + timezone: args.timezone, + }; - const limits = { maxCities: args.maxCities }; - const maxToProcess = args.limit && args.limit > 0 ? Math.min(args.limit, videoIds.length) : videoIds.length; - console.log(`Processing ${maxToProcess} video(s) with video concurrency=${args.videoConcurrency}, API concurrency=${args.apiConcurrency}, ingest rate limit=${args.ingestRateLimit}/s...`); - const extendedLimits = { - ...limits, - ingestChunkSize: args.ingestChunk, - ingestConcurrency: args.ingestConcurrency, - ingestRateLimit: args.ingestRateLimit, - }; - const videoLimiter = createLimiter(args.videoConcurrency); - const tasks = videoIds.slice(0, maxToProcess).map((videoId, idx) => - videoLimiter(async () => { - const orgId = map.get(videoId) || ""; - console.log(`Migrating ${videoId}... (${idx + 1}/${maxToProcess})`); - return migrateVideo({ - tokenDub: dubToken, - tb: { host: tbHost, token: tbToken }, - domain: args.domain, - videoId, - orgId, - window, - limits: extendedLimits, - dryRun: args.dryRun, - apiConcurrency: args.apiConcurrency, - }); - }) - ); - const results = await Promise.all(tasks); + const limits = { maxCities: args.maxCities }; + const maxToProcess = + args.limit && args.limit > 0 + ? Math.min(args.limit, videoIds.length) + : videoIds.length; + console.log( + `Processing ${maxToProcess} video(s) with video concurrency=${args.videoConcurrency}, API concurrency=${args.apiConcurrency}, ingest rate limit=${args.ingestRateLimit}/s...`, + ); + const extendedLimits = { + ...limits, + ingestChunkSize: args.ingestChunk, + ingestConcurrency: args.ingestConcurrency, + ingestRateLimit: args.ingestRateLimit, + }; + const videoLimiter = createLimiter(args.videoConcurrency); + const tasks = videoIds.slice(0, maxToProcess).map((videoId, idx) => + videoLimiter(async () => { + const orgId = map.get(videoId) || ""; + console.log(`Migrating ${videoId}... (${idx + 1}/${maxToProcess})`); + return migrateVideo({ + tokenDub: dubToken, + tb: { host: tbHost, token: tbToken }, + domain: args.domain, + videoId, + orgId, + window, + limits: extendedLimits, + dryRun: args.dryRun, + apiConcurrency: args.apiConcurrency, + }); + }), + ); + const results = await Promise.all(tasks); - const totalPlanned = results.reduce((acc, r) => acc + (r.plannedEvents || 0), 0); - const totalWritten = results.reduce((acc, r) => acc + (r.written || 0), 0); + const totalPlanned = results.reduce( + (acc, r) => acc + (r.plannedEvents || 0), + 0, + ); + const totalWritten = results.reduce((acc, r) => acc + (r.written || 0), 0); - const summary = { - mode: args.dryRun ? "dry-run" : "apply", - videos: videoIds.length, - totalPlanned, - totalWritten, - results, - }; - console.log(JSON.stringify(summary, null, 2)); + const summary = { + mode: args.dryRun ? "dry-run" : "apply", + videos: videoIds.length, + totalPlanned, + totalWritten, + results, + }; + console.log(JSON.stringify(summary, null, 2)); } main().catch((err) => { - console.error(err); - process.exit(1); + console.error(err); + process.exit(1); }); - - diff --git a/scripts/analytics/populate-test-data.js b/scripts/analytics/populate-test-data.js index 992bd68cbd..354779533b 100644 --- a/scripts/analytics/populate-test-data.js +++ b/scripts/analytics/populate-test-data.js @@ -5,7 +5,14 @@ const TB_DATASOURCE = "analytics_events"; const MAX_VIEWS = 100; const INGEST_CHUNK_SIZE = 5000; -const BROWSERS = ["Chrome", "Safari", "Firefox", "Edge", "Mobile Safari", "Samsung Internet"]; +const BROWSERS = [ + "Chrome", + "Safari", + "Firefox", + "Edge", + "Mobile Safari", + "Samsung Internet", +]; const DEVICES = ["Desktop", "Mobile", "Tablet"]; const OS_OPTIONS = ["Mac OS", "Windows", "iOS", "Android", "Linux"]; @@ -53,7 +60,13 @@ function generateTestEvent(videoId, orgId = "", index = 0) { const browser = randomChoice(BROWSERS); const device = randomChoice(DEVICES); const os = randomChoice(OS_OPTIONS); - const sessionId = generateSessionId(videoId, timestamp, city.name, browser, index); + const sessionId = generateSessionId( + videoId, + timestamp, + city.name, + browser, + index, + ); return { timestamp, @@ -102,7 +115,8 @@ async function tinybirdIngest({ host, token, datasource, ndjson }) { function parseDatabaseUrl(url) { if (!url) throw new Error("DATABASE_URL not found"); - if (!url.startsWith("mysql://")) throw new Error("DATABASE_URL is not a MySQL URL"); + if (!url.startsWith("mysql://")) + throw new Error("DATABASE_URL is not a MySQL URL"); const parsed = new URL(url); const config = { @@ -147,20 +161,27 @@ async function getVideoIds() { function distributeViews(videoIds, totalViews) { if (videoIds.length === 0) return []; - if (videoIds.length === 1) return [{ videoId: videoIds[0], views: totalViews }]; + if (videoIds.length === 1) + return [{ videoId: videoIds[0], views: totalViews }]; const distribution = []; let remaining = totalViews; for (let i = 0; i < videoIds.length - 1; i++) { const maxForThis = Math.floor(remaining / (videoIds.length - i)); - const views = randomInt(1, Math.max(1, Math.min(maxForThis, remaining - (videoIds.length - i - 1)))); + const views = randomInt( + 1, + Math.max(1, Math.min(maxForThis, remaining - (videoIds.length - i - 1))), + ); distribution.push({ videoId: videoIds[i], views }); remaining -= views; } if (remaining > 0) { - distribution.push({ videoId: videoIds[videoIds.length - 1], views: remaining }); + distribution.push({ + videoId: videoIds[videoIds.length - 1], + views: remaining, + }); } else { distribution.push({ videoId: videoIds[videoIds.length - 1], views: 1 }); } @@ -180,7 +201,9 @@ async function main() { console.log(`Found ${videoIds.length} video(s)`); const distribution = distributeViews(videoIds, MAX_VIEWS); - console.log(`Generating ${MAX_VIEWS} test views across ${distribution.length} video(s)...`); + console.log( + `Generating ${MAX_VIEWS} test views across ${distribution.length} video(s)...`, + ); const events = []; for (const { videoId, views } of distribution) { @@ -213,10 +236,14 @@ async function main() { ndjson, }); totalWritten += chunk.length; - console.log(`Ingested chunk ${i + 1}/${chunks.length} (${chunk.length} events)`); + console.log( + `Ingested chunk ${i + 1}/${chunks.length} (${chunk.length} events)`, + ); } - console.log(`\n✅ Successfully ingested ${totalWritten} events into Tinybird`); + console.log( + `\n✅ Successfully ingested ${totalWritten} events into Tinybird`, + ); console.log(`\nSummary:`); console.log(` Videos: ${distribution.length}`); console.log(` Total events: ${totalWritten}`); @@ -230,4 +257,3 @@ main().catch((err) => { console.error("Error:", err); process.exit(1); }); - diff --git a/scripts/analytics/setup-analytics.js b/scripts/analytics/setup-analytics.js index 43531360eb..a04bc2fc5e 100755 --- a/scripts/analytics/setup-analytics.js +++ b/scripts/analytics/setup-analytics.js @@ -16,75 +16,83 @@ const projectTinybPath = path.join(tinybirdProjectDir, ".tinyb"); const rootTinybPath = path.join(PROJECT_ROOT, ".tinyb"); const run = (command, args, options = {}) => { - const result = spawnSync(command, args, { - stdio: "inherit", - ...options, - }); - if (result.error || result.status !== 0) { - const message = result.error?.message || `Command ${command} ${args.join(" ")} failed.`; - throw new Error(message); - } + const result = spawnSync(command, args, { + stdio: "inherit", + ...options, + }); + if (result.error || result.status !== 0) { + const message = + result.error?.message || `Command ${command} ${args.join(" ")} failed.`; + throw new Error(message); + } }; const ensureTinybirdCli = () => { - try { - run(TB_BINARY, ["--version"], { stdio: "ignore" }); - } catch { - console.log("Tinybird CLI not found. Installing via pip..."); - run(DEFAULT_PYTHON, ["-m", "pip", "install", "--user", "tinybird-cli"], { stdio: "inherit" }); - console.log("Tinybird CLI installed. Re-running version check..."); - run(TB_BINARY, ["--version"], { stdio: "inherit" }); - } + try { + run(TB_BINARY, ["--version"], { stdio: "ignore" }); + } catch { + console.log("Tinybird CLI not found. Installing via pip..."); + run(DEFAULT_PYTHON, ["-m", "pip", "install", "--user", "tinybird-cli"], { + stdio: "inherit", + }); + console.log("Tinybird CLI installed. Re-running version check..."); + run(TB_BINARY, ["--version"], { stdio: "inherit" }); + } }; const ensureTinybirdLogin = () => { - if (fs.existsSync(rootTinybPath)) { - return; - } - console.log("Tinybird credentials not found. Launching `tb login`..."); - run(TB_BINARY, ["login"], { cwd: PROJECT_ROOT, stdio: "inherit" }); - if (!fs.existsSync(rootTinybPath)) { - throw new Error("Tinybird login did not create a .tinyb file. Please rerun `tb login` and try again."); - } + if (fs.existsSync(rootTinybPath)) { + return; + } + console.log("Tinybird credentials not found. Launching `tb login`..."); + run(TB_BINARY, ["login"], { cwd: PROJECT_ROOT, stdio: "inherit" }); + if (!fs.existsSync(rootTinybPath)) { + throw new Error( + "Tinybird login did not create a .tinyb file. Please rerun `tb login` and try again.", + ); + } }; const syncProjectTinyb = () => { - if (!fs.existsSync(rootTinybPath)) return; - fs.copyFileSync(rootTinybPath, projectTinybPath); + if (!fs.existsSync(rootTinybPath)) return; + fs.copyFileSync(rootTinybPath, projectTinybPath); }; const deployTinybirdProject = (auth) => { - console.log( - "⚠️ Running `tb --cloud deploy --allow-destructive-operations --wait` inside scripts/analytics/tinybird. This will synchronize the Tinybird workspace to the resources defined in that folder and remove any other datasources/pipes in the workspace." - ); - run( - TB_BINARY, - ["--cloud", "deploy", "--allow-destructive-operations", "--wait"], - { - cwd: tinybirdProjectDir, - env: { - ...process.env, - TB_HOST: auth.host, - TB_TOKEN: auth.token, - TB_LOCAL: "0", - ...(auth.userToken ? { TB_USER_TOKEN: auth.userToken } : {}), - }, - }, - ); + console.log( + "⚠️ Running `tb --cloud deploy --allow-destructive-operations --wait` inside scripts/analytics/tinybird. This will synchronize the Tinybird workspace to the resources defined in that folder and remove any other datasources/pipes in the workspace.", + ); + run( + TB_BINARY, + ["--cloud", "deploy", "--allow-destructive-operations", "--wait"], + { + cwd: tinybirdProjectDir, + env: { + ...process.env, + TB_HOST: auth.host, + TB_TOKEN: auth.token, + TB_LOCAL: "0", + ...(auth.userToken ? { TB_USER_TOKEN: auth.userToken } : {}), + }, + }, + ); }; function main() { - try { - ensureTinybirdCli(); - ensureTinybirdLogin(); - const auth = resolveTinybirdAuth(); - syncProjectTinyb(); - deployTinybirdProject(auth); - console.log("✅ Tinybird analytics resources are ready."); - } catch (error) { - console.error("❌ Failed to set up Tinybird analytics:", error instanceof Error ? error.message : error); - process.exit(1); - } + try { + ensureTinybirdCli(); + ensureTinybirdLogin(); + const auth = resolveTinybirdAuth(); + syncProjectTinyb(); + deployTinybirdProject(auth); + console.log("✅ Tinybird analytics resources are ready."); + } catch (error) { + console.error( + "❌ Failed to set up Tinybird analytics:", + error instanceof Error ? error.message : error, + ); + process.exit(1); + } } main(); diff --git a/scripts/analytics/shared.js b/scripts/analytics/shared.js index 9f61dfd133..cc176704b9 100644 --- a/scripts/analytics/shared.js +++ b/scripts/analytics/shared.js @@ -8,190 +8,204 @@ const DEFAULT_TINYBIRD_HOST = "https://api.tinybird.co"; const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", ".."); const TINYB_FILE_CANDIDATES = [ - path.join(PROJECT_ROOT, ".tinyb"), - path.join(process.cwd(), ".tinyb"), - path.join(os.homedir(), ".tinyb"), + path.join(PROJECT_ROOT, ".tinyb"), + path.join(process.cwd(), ".tinyb"), + path.join(os.homedir(), ".tinyb"), ]; const TABLE_DEFINITIONS = [ - { - name: "analytics_events", - description: "Raw analytics events generated by Cap clients.", - engine: "MergeTree", - partitionKey: "toYYYYMM(timestamp)", - sortingKey: "(tenant_id, timestamp)", - ttl: "timestamp + INTERVAL 90 DAY", - settings: "index_granularity = 8192", - columns: [ - { name: "timestamp", type: "DateTime" }, - { name: "session_id", type: "String" }, - { name: "action", type: "LowCardinality(String)" }, - { name: "version", type: "LowCardinality(String)" }, - { name: "payload", type: "String" }, - { name: "tenant_id", type: "LowCardinality(String)" }, - { name: "domain", type: "LowCardinality(String)" }, - ], - }, - { - name: "analytics_pages_mv", - description: "Aggregated page views per tenant/path/day.", - engine: "AggregatingMergeTree", - partitionKey: "toYYYYMM(date)", - sortingKey: "(tenant_id, date, pathname)", - ttl: "date + INTERVAL 90 DAY", - settings: "index_granularity = 256", - columns: [ - { name: "tenant_id", type: "LowCardinality(String)" }, - { name: "date", type: "Date" }, - { name: "pathname", type: "String" }, - { name: "location", type: "String" }, - { name: "visits", type: "AggregateFunction(uniq, String)" }, - ], - }, - { - name: "analytics_sessions_mv", - description: "Session level metadata derived from analytics events.", - engine: "ReplacingMergeTree", - partitionKey: "toYYYYMM(date)", - sortingKey: "(tenant_id, date, session_id)", - ttl: "date + INTERVAL 90 DAY", - settings: "index_granularity = 256", - columns: [ - { name: "tenant_id", type: "LowCardinality(String)" }, - { name: "date", type: "Date" }, - { name: "session_id", type: "String" }, - { name: "browser", type: "LowCardinality(String)" }, - { name: "device", type: "LowCardinality(String)" }, - ], - }, + { + name: "analytics_events", + description: "Raw analytics events generated by Cap clients.", + engine: "MergeTree", + partitionKey: "toYYYYMM(timestamp)", + sortingKey: "(tenant_id, timestamp)", + ttl: "timestamp + INTERVAL 90 DAY", + settings: "index_granularity = 8192", + columns: [ + { name: "timestamp", type: "DateTime" }, + { name: "session_id", type: "String" }, + { name: "action", type: "LowCardinality(String)" }, + { name: "version", type: "LowCardinality(String)" }, + { name: "payload", type: "String" }, + { name: "tenant_id", type: "LowCardinality(String)" }, + { name: "domain", type: "LowCardinality(String)" }, + ], + }, + { + name: "analytics_pages_mv", + description: "Aggregated page views per tenant/path/day.", + engine: "AggregatingMergeTree", + partitionKey: "toYYYYMM(date)", + sortingKey: "(tenant_id, date, pathname)", + ttl: "date + INTERVAL 90 DAY", + settings: "index_granularity = 256", + columns: [ + { name: "tenant_id", type: "LowCardinality(String)" }, + { name: "date", type: "Date" }, + { name: "pathname", type: "String" }, + { name: "location", type: "String" }, + { name: "visits", type: "AggregateFunction(uniq, String)" }, + ], + }, + { + name: "analytics_sessions_mv", + description: "Session level metadata derived from analytics events.", + engine: "ReplacingMergeTree", + partitionKey: "toYYYYMM(date)", + sortingKey: "(tenant_id, date, session_id)", + ttl: "date + INTERVAL 90 DAY", + settings: "index_granularity = 256", + columns: [ + { name: "tenant_id", type: "LowCardinality(String)" }, + { name: "date", type: "Date" }, + { name: "session_id", type: "String" }, + { name: "browser", type: "LowCardinality(String)" }, + { name: "device", type: "LowCardinality(String)" }, + ], + }, ]; const PIPE_DEFINITIONS = [ - { name: "analytics_pages_mv_pipe", targetDatasource: "analytics_pages_mv" }, - { name: "analytics_sessions_mv_pipe", targetDatasource: "analytics_sessions_mv" }, + { name: "analytics_pages_mv_pipe", targetDatasource: "analytics_pages_mv" }, + { + name: "analytics_sessions_mv_pipe", + targetDatasource: "analytics_sessions_mv", + }, ]; const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim(); -const buildSchemaLines = (table) => table.columns.map((column) => `${column.name} ${column.type}`); +const buildSchemaLines = (table) => + table.columns.map((column) => `${column.name} ${column.type}`); function formatHost(host) { - if (!host) return DEFAULT_TINYBIRD_HOST; - if (host.startsWith("http://") || host.startsWith("https://")) return host; - return `https://${host}`; + if (!host) return DEFAULT_TINYBIRD_HOST; + if (host.startsWith("http://") || host.startsWith("https://")) return host; + return `https://${host}`; } function loadTinybFile() { - for (const candidate of TINYB_FILE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) { - const raw = fs.readFileSync(candidate, "utf8"); - const data = JSON.parse(raw); - return { path: candidate, data }; - } - } catch { - // ignore malformed files and continue - } - } - return null; + for (const candidate of TINYB_FILE_CANDIDATES) { + try { + if (fs.existsSync(candidate)) { + const raw = fs.readFileSync(candidate, "utf8"); + const data = JSON.parse(raw); + return { path: candidate, data }; + } + } catch { + // ignore malformed files and continue + } + } + return null; } function resolveTinybirdAuth() { - const envHost = process.env.TINYBIRD_HOST?.trim(); - const envToken = process.env.TINYBIRD_ADMIN_TOKEN?.trim() || process.env.TINYBIRD_TOKEN?.trim(); - if (envHost && envToken) { - return { - host: formatHost(envHost), - token: envToken, - source: "env", - }; - } - - const tinyb = loadTinybFile(); - if (tinyb) { - if (!tinyb.data?.token) { - throw new Error(`Tinybird auth file at ${tinyb.path} is missing a token.`); - } - return { - host: formatHost(tinyb.data.host ?? DEFAULT_TINYBIRD_HOST), - token: tinyb.data.token, - workspaceId: tinyb.data.id, - workspaceName: tinyb.data.name, - userToken: tinyb.data.user_token, - tinybPath: tinyb.path, - source: "tinyb", - }; - } - - throw new Error( - "Tinybird credentials not found. Set TINYBIRD_TOKEN/TINYBIRD_HOST or run `tb login` to create a .tinyb file.", - ); + const envHost = process.env.TINYBIRD_HOST?.trim(); + const envToken = + process.env.TINYBIRD_ADMIN_TOKEN?.trim() || + process.env.TINYBIRD_TOKEN?.trim(); + if (envHost && envToken) { + return { + host: formatHost(envHost), + token: envToken, + source: "env", + }; + } + + const tinyb = loadTinybFile(); + if (tinyb) { + if (!tinyb.data?.token) { + throw new Error( + `Tinybird auth file at ${tinyb.path} is missing a token.`, + ); + } + return { + host: formatHost(tinyb.data.host ?? DEFAULT_TINYBIRD_HOST), + token: tinyb.data.token, + workspaceId: tinyb.data.id, + workspaceName: tinyb.data.name, + userToken: tinyb.data.user_token, + tinybPath: tinyb.path, + source: "tinyb", + }; + } + + throw new Error( + "Tinybird credentials not found. Set TINYBIRD_TOKEN/TINYBIRD_HOST or run `tb login` to create a .tinyb file.", + ); } function createTinybirdClient(authOverride) { - const auth = authOverride ?? resolveTinybirdAuth(); - - const request = async (path, init = {}) => { - const url = new URL(path, formatHost(auth.host)); - const headers = { - Authorization: `Bearer ${auth.token}`, - ...(init.headers ?? {}), - }; - let response; - try { - response = await fetch(url, { ...init, headers }); - } catch (error) { - const err = new Error(`Tinybird request failed: ${error instanceof Error ? error.message : String(error)}`); - err.cause = error; - throw err; - } - const text = await response.text(); - let payload = text; - try { - payload = text ? JSON.parse(text) : {}; - } catch { - // keep raw text when JSON parsing fails - } - - if (!response.ok) { - const message = payload?.error || payload?.message || response.statusText; - const err = new Error(`Tinybird request failed (${response.status}): ${message}`); - err.status = response.status; - err.payload = payload; - throw err; - } - - return payload; - }; - - const getResource = async (resourcePath, { allowNotFound = false } = {}) => { - try { - return await request(resourcePath); - } catch (error) { - if (allowNotFound && error.status === 404) return null; - throw error; - } - }; - - return { - host: formatHost(auth.host), - token: auth.token, - userToken: auth.userToken, - workspaceId: auth.workspaceId, - workspaceName: auth.workspaceName, - tinybPath: auth.tinybPath, - request, - getDatasource: (name, options) => getResource(`/v0/datasources/${encodeURIComponent(name)}`, options), - getPipe: (name, options) => getResource(`/v0/pipes/${encodeURIComponent(name)}`, options), - }; + const auth = authOverride ?? resolveTinybirdAuth(); + + const request = async (path, init = {}) => { + const url = new URL(path, formatHost(auth.host)); + const headers = { + Authorization: `Bearer ${auth.token}`, + ...(init.headers ?? {}), + }; + let response; + try { + response = await fetch(url, { ...init, headers }); + } catch (error) { + const err = new Error( + `Tinybird request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + err.cause = error; + throw err; + } + const text = await response.text(); + let payload = text; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + // keep raw text when JSON parsing fails + } + + if (!response.ok) { + const message = payload?.error || payload?.message || response.statusText; + const err = new Error( + `Tinybird request failed (${response.status}): ${message}`, + ); + err.status = response.status; + err.payload = payload; + throw err; + } + + return payload; + }; + + const getResource = async (resourcePath, { allowNotFound = false } = {}) => { + try { + return await request(resourcePath); + } catch (error) { + if (allowNotFound && error.status === 404) return null; + throw error; + } + }; + + return { + host: formatHost(auth.host), + token: auth.token, + userToken: auth.userToken, + workspaceId: auth.workspaceId, + workspaceName: auth.workspaceName, + tinybPath: auth.tinybPath, + request, + getDatasource: (name, options) => + getResource(`/v0/datasources/${encodeURIComponent(name)}`, options), + getPipe: (name, options) => + getResource(`/v0/pipes/${encodeURIComponent(name)}`, options), + }; } export { - PIPE_DEFINITIONS, - PROJECT_ROOT, - TABLE_DEFINITIONS, - buildSchemaLines, - createTinybirdClient, - normalizeWhitespace, - resolveTinybirdAuth, + PIPE_DEFINITIONS, + PROJECT_ROOT, + TABLE_DEFINITIONS, + buildSchemaLines, + createTinybirdClient, + normalizeWhitespace, + resolveTinybirdAuth, }; From cf1787b026545beeb4d4966b60b27b8ad65c26cd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:54:35 +0000 Subject: [PATCH 18/23] Refactor ShareVideoPage error handling and code style --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 554 +++---- apps/web/app/s/[videoId]/page.tsx | 1437 +++++++++-------- packages/web-backend/src/Videos/VideosRpcs.ts | 20 +- 3 files changed, 1015 insertions(+), 996 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index bb4a846e22..fc8a2b95ca 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -13,11 +13,11 @@ import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; import { useDashboardContext } from "../Contexts"; import { - NewFolderDialog, - SelectedCapsBar, - UploadCapButton, - UploadPlaceholderCard, - WebRecorderDialog, + NewFolderDialog, + SelectedCapsBar, + UploadCapButton, + UploadPlaceholderCard, + WebRecorderDialog, } from "./components"; import { CapCard } from "./components/CapCard/CapCard"; import { CapPagination } from "./components/CapPagination"; @@ -27,306 +27,306 @@ import Folder from "./components/Folder"; import { useUploadingStatus } from "./UploadingContext"; export type VideoData = { - id: Video.VideoId; - ownerId: string; - name: string; - createdAt: Date; - public: boolean; - totalComments: number; - totalReactions: number; - foldersData: FolderDataType[]; - sharedOrganizations: { - id: string; - name: string; - iconUrl?: ImageUpload.ImageUrl | null; - }[]; - sharedSpaces?: { - id: string; - name: string; - isOrg: boolean; - organizationId: string; - }[]; - ownerName: string; - metadata?: VideoMetadata; - hasPassword: boolean; - hasActiveUpload: boolean; + id: Video.VideoId; + ownerId: string; + name: string; + createdAt: Date; + public: boolean; + totalComments: number; + totalReactions: number; + foldersData: FolderDataType[]; + sharedOrganizations: { + id: string; + name: string; + iconUrl?: ImageUpload.ImageUrl | null; + }[]; + sharedSpaces?: { + id: string; + name: string; + isOrg: boolean; + organizationId: string; + }[]; + ownerName: string; + metadata?: VideoMetadata; + hasPassword: boolean; + hasActiveUpload: boolean; }[]; export const Caps = ({ - data, - count, - analyticsEnabled, - folders, + data, + count, + analyticsEnabled, + folders, }: { - data: VideoData; - count: number; - folders: FolderDataType[]; - analyticsEnabled: boolean; + data: VideoData; + count: number; + folders: FolderDataType[]; + analyticsEnabled: boolean; }) => { - const router = useRouter(); - const params = useSearchParams(); - const page = Number(params.get("page")) || 1; - const { user } = useDashboardContext(); - const limit = 15; - const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); - const totalPages = Math.ceil(count / limit); - const previousCountRef = useRef(0); - const [selectedCaps, setSelectedCaps] = useState([]); - const [isDraggingCap, setIsDraggingCap] = useState(false); + const router = useRouter(); + const params = useSearchParams(); + const page = Number(params.get("page")) || 1; + const { user } = useDashboardContext(); + const limit = 15; + const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); + const totalPages = Math.ceil(count / limit); + const previousCountRef = useRef(0); + const [selectedCaps, setSelectedCaps] = useState([]); + const [isDraggingCap, setIsDraggingCap] = useState(false); - const anyCapSelected = selectedCaps.length > 0; + const anyCapSelected = selectedCaps.length > 0; - const analyticsQuery = useVideosAnalyticsQuery( - data.map((video) => video.id), - analyticsEnabled, - ); - const analytics: Partial> = - analyticsQuery.data || {}; - const isLoadingAnalytics = analyticsEnabled && analyticsQuery.isLoading; + const analyticsQuery = useVideosAnalyticsQuery( + data.map((video) => video.id), + analyticsEnabled + ); + const analytics: Partial> = + analyticsQuery.data || {}; + const isLoadingAnalytics = analyticsEnabled && analyticsQuery.isLoading; - const handleCapSelection = (capId: Video.VideoId) => { - setSelectedCaps((prev) => { - const newSelection = prev.includes(capId) - ? prev.filter((id) => id !== capId) - : [...prev, capId]; + const handleCapSelection = (capId: Video.VideoId) => { + setSelectedCaps((prev) => { + const newSelection = prev.includes(capId) + ? prev.filter((id) => id !== capId) + : [...prev, capId]; - previousCountRef.current = prev.length; + previousCountRef.current = prev.length; - return newSelection; - }); - }; + return newSelection; + }); + }; - const rpc = useRpcClient() as { - VideoDelete: (id: Video.VideoId) => Effect.Effect; - }; + const rpc = useRpcClient() as { + VideoDelete: (id: Video.VideoId) => Effect.Effect; + }; - const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ - mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { - if (ids.length === 0) return { success: 0 }; + const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ + mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { + if (ids.length === 0) return { success: 0 }; - const results = yield* Effect.all( - ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), - { concurrency: 10 }, - ); + const results = yield* Effect.all( + ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), + { concurrency: 10 } + ); - const successCount = results.filter(Exit.isSuccess).length; + const successCount = results.filter(Exit.isSuccess).length; - const errorCount = ids.length - successCount; + const errorCount = ids.length - successCount; - if (successCount > 0 && errorCount > 0) { - return { success: successCount, error: errorCount }; - } else if (successCount > 0) { - return { success: successCount }; - } else { - return yield* Effect.fail( - new Error( - `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`, - ), - ); - } - }), - onMutate: (ids: Video.VideoId[]) => { - toast.loading( - `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, - ); - }, - onSuccess: (data: { success: number; error?: number }) => { - setSelectedCaps([]); - router.refresh(); - if (data.error) { - toast.success( - `Successfully deleted ${data.success} cap${ - data.success === 1 ? "" : "s" - }, but failed to delete ${data.error} cap${ - data.error === 1 ? "" : "s" - }`, - ); - } else { - toast.success( - `Successfully deleted ${data.success} cap${ - data.success === 1 ? "" : "s" - }`, - ); - } - }, - onError: (error: unknown) => { - const message = - error instanceof Error - ? error.message - : "An error occurred while deleting caps"; - toast.error(message); - }, - }); + if (successCount > 0 && errorCount > 0) { + return { success: successCount, error: errorCount }; + } else if (successCount > 0) { + return { success: successCount }; + } else { + return yield* Effect.fail( + new Error( + `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}` + ) + ); + } + }), + onMutate: (ids: Video.VideoId[]) => { + toast.loading( + `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...` + ); + }, + onSuccess: (data: { success: number; error?: number }) => { + setSelectedCaps([]); + router.refresh(); + if (data.error) { + toast.success( + `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }, but failed to delete ${data.error} cap${ + data.error === 1 ? "" : "s" + }` + ); + } else { + toast.success( + `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }` + ); + } + }, + onError: (error: unknown) => { + const message = + error instanceof Error + ? error.message + : "An error occurred while deleting caps"; + toast.error(message); + }, + }); - const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ - mutationFn: Effect.fn(function* (id: Video.VideoId) { - yield* rpc.VideoDelete(id); - }), - onSuccess: () => { - toast.success("Cap deleted successfully"); - router.refresh(); - }, - onError: (_error: unknown) => toast.error("Failed to delete cap"), - }); + const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ + mutationFn: Effect.fn(function* (id: Video.VideoId) { + yield* rpc.VideoDelete(id); + }), + onSuccess: () => { + toast.success("Cap deleted successfully"); + router.refresh(); + }, + onError: (_error: unknown) => toast.error("Failed to delete cap"), + }); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && selectedCaps.length > 0) { - setSelectedCaps([]); - } + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && selectedCaps.length > 0) { + setSelectedCaps([]); + } - if ( - (e.key === "Delete" || e.key === "Backspace") && - selectedCaps.length > 0 - ) { - if (e.key === "Backspace") { - e.preventDefault(); - } + if ( + (e.key === "Delete" || e.key === "Backspace") && + selectedCaps.length > 0 + ) { + if (e.key === "Backspace") { + e.preventDefault(); + } - if ( - !["INPUT", "TEXTAREA", "SELECT"].includes( - document.activeElement?.tagName || "", - ) - ) { - deleteCaps(selectedCaps); - } - } + if ( + !["INPUT", "TEXTAREA", "SELECT"].includes( + document.activeElement?.tagName || "" + ) + ) { + deleteCaps(selectedCaps); + } + } - if (e.key === "a" && (e.ctrlKey || e.metaKey) && data.length > 0) { - if ( - !["INPUT", "TEXTAREA", "SELECT"].includes( - document.activeElement?.tagName || "", - ) - ) { - e.preventDefault(); - setSelectedCaps(data.map((cap) => cap.id)); - } - } - }; + if (e.key === "a" && (e.ctrlKey || e.metaKey) && data.length > 0) { + if ( + !["INPUT", "TEXTAREA", "SELECT"].includes( + document.activeElement?.tagName || "" + ) + ) { + e.preventDefault(); + setSelectedCaps(data.map((cap) => cap.id)); + } + } + }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [selectedCaps, data, deleteCaps]); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedCaps, data, deleteCaps]); - useEffect(() => { - const handleDragStart = () => setIsDraggingCap(true); - const handleDragEnd = () => setIsDraggingCap(false); + useEffect(() => { + const handleDragStart = () => setIsDraggingCap(true); + const handleDragEnd = () => setIsDraggingCap(false); - window.addEventListener("dragstart", handleDragStart); - window.addEventListener("dragend", handleDragEnd); + window.addEventListener("dragstart", handleDragStart); + window.addEventListener("dragend", handleDragEnd); - return () => { - window.removeEventListener("dragstart", handleDragStart); - window.removeEventListener("dragend", handleDragEnd); - }; - }, []); + return () => { + window.removeEventListener("dragstart", handleDragStart); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); - const [isUploading, uploadingCapId] = useUploadingStatus(); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? data.filter((video) => video.id !== uploadingCapId) - : data, - [data, isUploading, uploadingCapId], - ); + const [isUploading, uploadingCapId] = useUploadingStatus(); + const visibleVideos = useMemo( + () => + isUploading && uploadingCapId + ? data.filter((video) => video.id !== uploadingCapId) + : data, + [data, isUploading, uploadingCapId] + ); - if (count === 0 && folders.length === 0) return ; + if (count === 0 && folders.length === 0) return ; - return ( -
- -
- - - -
- {folders.length > 0 && ( - <> -
-

Folders

-
-
- {folders.map((folder) => ( - - ))} -
- - )} - {visibleVideos.length > 0 && ( - <> -
-

Videos

-
+ return ( +
+ +
+ + + +
+ {folders.length > 0 && ( + <> +
+

Folders

+
+
+ {folders.map((folder) => ( + + ))} +
+ + )} + {visibleVideos.length > 0 && ( + <> +
+

Videos

+
-
- {isUploading && ( - - )} - {visibleVideos.map((video) => { - const videoAnalytics = analytics[video.id]; - return ( - { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} - userId={user?.id} - isLoadingAnalytics={isLoadingAnalytics} - isSelected={selectedCaps.includes(video.id)} - anyCapSelected={anyCapSelected} - onSelectToggle={() => handleCapSelection(video.id)} - /> - ); - })} -
- - )} - {(data.length > limit || data.length === limit || page !== 1) && ( -
- -
- )} - deleteCaps(selectedCaps)} - isDeleting={isDeletingCaps || isDeletingCap} - /> - {isDraggingCap && ( -
-
-
- -

- Drag to a space to share or folder to move -

-
-
-
- )} -
- ); +
+ {isUploading && ( + + )} + {visibleVideos.map((video) => { + const videoAnalytics = analytics[video.id]; + return ( + { + if (selectedCaps.length > 0) { + deleteCaps(selectedCaps); + } else { + deleteCap(video.id); + } + }} + userId={user?.id} + isLoadingAnalytics={isLoadingAnalytics} + isSelected={selectedCaps.includes(video.id)} + anyCapSelected={anyCapSelected} + onSelectToggle={() => handleCapSelection(video.id)} + /> + ); + })} +
+ + )} + {(data.length > limit || data.length === limit || page !== 1) && ( +
+ +
+ )} + deleteCaps(selectedCaps)} + isDeleting={isDeletingCaps || isDeletingCap} + /> + {isDraggingCap && ( +
+
+
+ +

+ Drag to a space to share or folder to move +

+
+
+
+ )} +
+ ); }; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 12de03c207..7d1200faca 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -1,33 +1,32 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - comments, - organizationMembers, - organizations, - sharedVideos, - spaces, - spaceVideos, - users, - videos, - videoUploads, + comments, + organizationMembers, + organizations, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; import { - Database, - ImageUploads, - provideOptionalAuth, - Videos, + Database, + ImageUploads, + provideOptionalAuth, + Videos, } from "@cap/web-backend"; import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy"; import { - Comment, - type ImageUpload, - type Organisation, - Policy, - type Video, + Comment, + type Organisation, + Policy, + type Video, } from "@cap/web-domain"; import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -38,8 +37,8 @@ import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; import { getVideoAnalytics } from "@/actions/videos/get-analytics"; import { - getDashboardData, - type OrganizationSettings, + getDashboardData, + type OrganizationSettings, } from "@/app/(org)/dashboard/dashboard-data"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; @@ -53,716 +52,720 @@ import { Share } from "./Share"; // Helper function to fetch shared spaces data for a video async function getSharedSpacesForVideo(videoId: Video.VideoId) { - // Fetch space-level sharing - const spaceSharing = await db() - .select({ - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where(eq(spaceVideos.videoId, videoId)); - - // Fetch organization-level sharing - const orgSharing = await db() - .select({ - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const sharedSpaces: Array<{ - id: string; - name: string; - organizationId: string; - iconUrl?: string; - }> = []; - - // Add space-level sharing - spaceSharing.forEach((space) => { - sharedSpaces.push({ - id: space.id, - name: space.name, - organizationId: space.organizationId, - iconUrl: space.iconUrl || undefined, - }); - }); - - // Add organization-level sharing - orgSharing.forEach((org) => { - sharedSpaces.push({ - id: org.id, - name: org.name, - organizationId: org.organizationId, - iconUrl: org.iconUrl || undefined, - }); - }); - - return sharedSpaces; + // Fetch space-level sharing + const spaceSharing = await db() + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where(eq(spaceVideos.videoId, videoId)); + + // Fetch organization-level sharing + const orgSharing = await db() + .select({ + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const sharedSpaces: Array<{ + id: string; + name: string; + organizationId: string; + iconUrl?: string; + }> = []; + + // Add space-level sharing + spaceSharing.forEach((space) => { + sharedSpaces.push({ + id: space.id, + name: space.name, + organizationId: space.organizationId, + iconUrl: space.iconUrl || undefined, + }); + }); + + // Add organization-level sharing + orgSharing.forEach((org) => { + sharedSpaces.push({ + id: org.id, + name: org.name, + organizationId: org.organizationId, + iconUrl: org.iconUrl || undefined, + }); + }); + + return sharedSpaces; } const ALLOWED_REFERRERS = [ - "x.com", - "twitter.com", - "facebook.com", - "fb.com", - "slack.com", - "notion.so", - "linkedin.com", + "x.com", + "twitter.com", + "facebook.com", + "fb.com", + "slack.com", + "notion.so", + "linkedin.com", ]; +function PolicyDeniedView() { + return ( +
+ +

This video is private

+

+ If you own this video, please sign in to + manage sharing. +

+
+ ); +} + +const renderPolicyDenied = (videoId: Video.VideoId) => + Effect.succeed(); + +const renderNoSuchElement = () => Effect.sync(() => notFound()); + +const getShareVideoPageCatchers = (videoId: Video.VideoId) => ({ + PolicyDenied: () => renderPolicyDenied(videoId), + NoSuchElementException: renderNoSuchElement, +}); + export async function generateMetadata( - props: PageProps<"/s/[videoId]">, + props: PageProps<"/s/[videoId]"> ): Promise { - const params = await props.params; - const videoId = params.videoId as Video.VideoId; - - const referrer = (await headers()).get("x-referrer") || ""; - const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => - referrer.includes(domain), - ); - - return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( - Effect.map( - Option.match({ - onNone: () => notFound(), - onSome: ([video]) => ({ - title: video.name + " | Cap Recording", - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - twitter: { - card: "player", - title: video.name + " | Cap Recording", - description: "Watch this video on Cap", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - ], - players: { - playerUrl: new URL( - `/s/${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - streamUrl: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - }, - }, - robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", - }), - }), - ), - Effect.catchTags({ - PolicyDenied: () => - Effect.succeed({ - title: "Cap: This video is private", - description: "This video is private and cannot be shared.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - robots: "noindex, nofollow", - }), - VerifyVideoPasswordError: () => - Effect.succeed({ - title: "Cap: Password Protected Video", - description: "This video is password protected.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Cap: Password Protected Video", - description: "This video is password protected.", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - ], - }, - robots: "noindex, nofollow", - }), - }), - provideOptionalAuth, - EffectRuntime.runPromise, - ); + const params = await props.params; + const videoId = params.videoId as Video.VideoId; + + const referrer = (await headers()).get("x-referrer") || ""; + const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => + referrer.includes(domain) + ); + + return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( + Effect.map( + Option.match({ + onNone: () => notFound(), + onSome: ([video]) => ({ + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + twitter: { + card: "player", + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + ], + players: { + playerUrl: new URL( + `/s/${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + streamUrl: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1280, + height: 720, + }, + }, + robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", + }), + }) + ), + Effect.catchTags({ + PolicyDenied: () => + Effect.succeed({ + title: "Cap: This video is private", + description: "This video is private and cannot be shared.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + robots: "noindex, nofollow", + }), + VerifyVideoPasswordError: () => + Effect.succeed({ + title: "Cap: Password Protected Video", + description: "This video is password protected.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + width: 1200, + height: 630, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Cap: Password Protected Video", + description: "This video is password protected.", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL + ).toString(), + ], + }, + robots: "noindex, nofollow", + }), + }), + provideOptionalAuth, + EffectRuntime.runPromise + ); } export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { - const params = await props.params; - const searchParams = await props.searchParams; - const videoId = params.videoId as Video.VideoId; - - return Effect.gen(function* () { - const videosPolicy = yield* VideosPolicy; - - const [video] = yield* Effect.promise(() => - db() - .select({ - id: videos.id, - name: videos.name, - orgId: videos.orgId, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - awsRegion: videos.awsRegion, - awsBucket: videos.awsBucket, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - videoSettings: videos.settings, - width: videos.width, - height: videos.height, - duration: videos.duration, - fps: videos.fps, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), - owner: users, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))), - ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); - - return Option.fromNullable(video); - }).pipe( - Effect.flatten, - Effect.map((video) => ({ needsPassword: false, video }) as const), - Effect.catchTag("VerifyVideoPasswordError", () => - Effect.succeed({ needsPassword: true } as const), - ), - Effect.map((data) => ( -
- - {!data.needsPassword && ( - - )} -
- )), - Effect.catchTags({ - PolicyDenied: () => - Effect.succeed( -
- -

- This video is private -

-

- If you own this video, please sign in{" "} - to manage sharing. -

-
, - ), - NoSuchElementException: () => Effect.sync(() => notFound()), - }), - provideOptionalAuth, - EffectRuntime.runPromise, - ); + const params = await props.params; + const searchParams = await props.searchParams; + const videoId = params.videoId as Video.VideoId; + + return Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + const [video] = yield* Effect.promise(() => + db() + .select({ + id: videos.id, + name: videos.name, + orgId: videos.orgId, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + awsRegion: videos.awsRegion, + awsBucket: videos.awsBucket, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + videoSettings: videos.settings, + width: videos.width, + height: videos.height, + duration: videos.duration, + fps: videos.fps, + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean + ), + owner: users, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))) + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + + return Option.fromNullable(video); + }).pipe( + Effect.flatten, + Effect.map((video) => ({ needsPassword: false, video } as const)), + Effect.catchTag("VerifyVideoPasswordError", () => + Effect.succeed({ needsPassword: true } as const) + ), + Effect.map((data) => ( +
+ + {!data.needsPassword && ( + + )} +
+ )), + Effect.catchTags(getShareVideoPageCatchers(videoId)), + provideOptionalAuth, + EffectRuntime.runPromise + ); } async function AuthorizedContent({ - video, - searchParams, + video, + searchParams, }: { - video: Omit< - InferSelectModel, - "folderId" | "password" | "settings" | "ownerId" - > & { - owner: InferSelectModel; - sharedOrganization: { organizationId: Organisation.OrganisationId } | null; - hasPassword: boolean; - orgSettings?: OrganizationSettings | null; - videoSettings?: OrganizationSettings | null; - }; - searchParams: { [key: string]: string | string[] | undefined }; + video: Omit< + InferSelectModel, + "folderId" | "password" | "settings" | "ownerId" + > & { + owner: InferSelectModel; + sharedOrganization: { organizationId: Organisation.OrganisationId } | null; + hasPassword: boolean; + orgSettings?: OrganizationSettings | null; + videoSettings?: OrganizationSettings | null; + }; + searchParams: { [key: string]: string | string[] | undefined }; }) { - // will have already been fetched if auth is required - const user = await getCurrentUser(); - const videoId = video.id; - - if (user && video && user.id !== video.owner.id) { - try { - await createNotification({ - type: "view", - videoId: video.id, - authorId: user.id, - }); - } catch (error) { - console.warn("Failed to create view notification:", error); - } - } - - const userId = user?.id; - const commentId = optionFromTOrFirst(searchParams.comment).pipe( - Option.map(Comment.CommentId.make), - ); - const replyId = optionFromTOrFirst(searchParams.reply).pipe( - Option.map(Comment.CommentId.make), - ); - - // Fetch spaces data for the sharing dialog - let spacesData = null; - if (user) { - try { - const dashboardData = await getDashboardData(user); - spacesData = dashboardData.spacesData; - } catch (error) { - console.error("Failed to fetch spaces data for sharing dialog:", error); - spacesData = []; - } - } - - // Fetch shared spaces data for this video - const sharedSpaces = await getSharedSpacesForVideo(videoId); - - let aiGenerationEnabled = false; - const videoOwnerQuery = await db() - .select({ - email: users.email, - stripeSubscriptionStatus: users.stripeSubscriptionStatus, - }) - .from(users) - .where(eq(users.id, video.owner.id)) - .limit(1); - - if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { - const videoOwner = videoOwnerQuery[0]; - aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); - } - - if (video.sharedOrganization?.organizationId) { - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, video.sharedOrganization.organizationId)) - .limit(1); - - if (organization[0]?.allowedEmailDomain) { - if ( - !user?.email || - !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) - ) { - console.log( - "[ShareVideoPage] Access denied - domain restriction:", - organization[0].allowedEmailDomain, - ); - return ( -
-

Access Restricted

-

- This video is only accessible to members of this organization. -

-

- Please sign in with your organization email address to access this - content. -

-
- ); - } - } - } - - if ( - video.transcriptionStatus !== "COMPLETE" && - video.transcriptionStatus !== "PROCESSING" - ) { - console.log("[ShareVideoPage] Starting transcription for video:", videoId); - await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); - - const updatedVideoQuery = await db() - .select({ - id: videos.id, - name: videos.name, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - videoSettings: videos.settings, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(eq(videos.id, videoId)) - .execute(); - - if (updatedVideoQuery[0]) { - Object.assign(video, updatedVideoQuery[0]); - console.log( - "[ShareVideoPage] Updated transcription status:", - video.transcriptionStatus, - ); - } - } - - const currentMetadata = (video.metadata as VideoMetadata) || {}; - const metadata = currentMetadata; - let initialAiData = null; - - if (metadata.summary || metadata.chapters || metadata.aiTitle) { - initialAiData = { - title: metadata.aiTitle || null, - summary: metadata.summary || null, - chapters: metadata.chapters || null, - processing: metadata.aiProcessing || false, - }; - } else if (metadata.aiProcessing) { - initialAiData = { - title: null, - summary: null, - chapters: null, - processing: true, - }; - } - - if ( - video.transcriptionStatus === "COMPLETE" && - !currentMetadata.aiProcessing && - !currentMetadata.summary && - !currentMetadata.chapters && - // !currentMetadata.generationError && - aiGenerationEnabled - ) { - try { - generateAiMetadata(videoId, video.owner.id).catch((error) => { - console.error( - `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, - error, - ); - }); - } catch (error) { - console.error( - `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, - error, - ); - } - } - - const customDomainPromise = (async () => { - if (!user) { - return { customDomain: null, domainVerified: false }; - } - const activeOrganizationId = user.activeOrganizationId; - if (!activeOrganizationId) { - return { customDomain: null, domainVerified: false }; - } - - // Fetch the active org - const orgArr = await db() - .select({ - customDomain: organizations.customDomain, - domainVerified: organizations.domainVerified, - }) - .from(organizations) - .where(eq(organizations.id, activeOrganizationId)) - .limit(1); - - const org = orgArr[0]; - if ( - org && - org.customDomain && - org.domainVerified !== null && - user.id === video.owner.id - ) { - return { customDomain: org.customDomain, domainVerified: true }; - } - return { customDomain: null, domainVerified: false }; - })(); - - const sharedOrganizationsPromise = db() - .select({ id: sharedVideos.organizationId, name: organizations.name }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const userOrganizationsPromise = (async () => { - if (!userId) return []; - - const [ownedOrganizations, memberOrganizations] = await Promise.all([ - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .where(eq(organizations.ownerId, userId)), - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .innerJoin( - organizationMembers, - eq(organizations.id, organizationMembers.organizationId), - ) - .where(eq(organizationMembers.userId, userId)), - ]); - - const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; - const uniqueOrganizationIds = new Set(); - - return allOrganizations.filter((organization) => { - if (uniqueOrganizationIds.has(organization.id)) return false; - uniqueOrganizationIds.add(organization.id); - return true; - }); - })(); - - const membersListPromise = video.sharedOrganization?.organizationId - ? db() - .select({ userId: organizationMembers.userId }) - .from(organizationMembers) - .where( - eq( - organizationMembers.organizationId, - video.sharedOrganization.organizationId, - ), - ) - : Promise.resolve([]); - - const commentsPromise = Effect.gen(function* () { - const db = yield* Database; - const imageUploads = yield* ImageUploads; - - let toplLevelCommentId = Option.none(); - - if (Option.isSome(replyId)) { - const [parentComment] = yield* db.use((db) => - db - .select({ parentCommentId: comments.parentCommentId }) - .from(comments) - .where(eq(comments.id, replyId.value)) - .limit(1), - ); - toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); - } - - const commentToBringToTheTop = Option.orElse( - toplLevelCommentId, - () => commentId, - ); - - return yield* db - .use((db) => - db - .select({ - id: comments.id, - content: comments.content, - timestamp: comments.timestamp, - type: comments.type, - authorId: comments.authorId, - videoId: comments.videoId, - createdAt: comments.createdAt, - updatedAt: comments.updatedAt, - parentCommentId: comments.parentCommentId, - authorName: users.name, - authorImage: users.image, - }) - .from(comments) - .leftJoin(users, eq(comments.authorId, users.id)) - .where(eq(comments.videoId, videoId)) - .orderBy( - Option.match(commentToBringToTheTop, { - onSome: (commentId) => - sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, - onNone: () => comments.createdAt, - }), - ), - ) - .pipe( - Effect.map((comments) => - comments.map( - Effect.fn(function* (c) { - return Object.assign(c, { - authorImage: yield* Option.fromNullable(c.authorImage).pipe( - Option.map(imageUploads.resolveImageUrl), - Effect.transposeOption, - Effect.map(Option.getOrNull), - ), - }); - }), - ), - ), - Effect.flatMap(Effect.all), - ); - }).pipe(EffectRuntime.runPromise); - - const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); - - const [ - membersList, - userOrganizations, - sharedOrganizations, - { customDomain, domainVerified }, - ] = await Promise.all([ - membersListPromise, - userOrganizationsPromise, - sharedOrganizationsPromise, - customDomainPromise, - ]); - - const videoWithOrganizationInfo = await Effect.gen(function* () { - const imageUploads = yield* ImageUploads; - - return { - ...video, - owner: { - id: video.owner.id, - name: video.owner.name, - isPro: userIsPro(video.owner), - image: video.owner.image - ? yield* imageUploads.resolveImageUrl(video.owner.image) - : null, - }, - organization: { - organizationMembers: membersList.map((member) => member.userId), - organizationId: video.sharedOrganization?.organizationId ?? undefined, - }, - sharedOrganizations: sharedOrganizations, - password: null, - folderId: null, - orgSettings: video.orgSettings || null, - settings: video.videoSettings || null, - }; - }).pipe(runPromise); - - return ( - <> -
- - - -
- - - ); + // will have already been fetched if auth is required + const user = await getCurrentUser(); + const videoId = video.id; + + if (user && video && user.id !== video.owner.id) { + try { + await createNotification({ + type: "view", + videoId: video.id, + authorId: user.id, + }); + } catch (error) { + console.warn("Failed to create view notification:", error); + } + } + + const userId = user?.id; + const commentId = optionFromTOrFirst(searchParams.comment).pipe( + Option.map(Comment.CommentId.make) + ); + const replyId = optionFromTOrFirst(searchParams.reply).pipe( + Option.map(Comment.CommentId.make) + ); + + // Fetch spaces data for the sharing dialog + let spacesData = null; + if (user) { + try { + const dashboardData = await getDashboardData(user); + spacesData = dashboardData.spacesData; + } catch (error) { + console.error("Failed to fetch spaces data for sharing dialog:", error); + spacesData = []; + } + } + + // Fetch shared spaces data for this video + const sharedSpaces = await getSharedSpacesForVideo(videoId); + + let aiGenerationEnabled = false; + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) + .from(users) + .where(eq(users.id, video.owner.id)) + .limit(1); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { + const videoOwner = videoOwnerQuery[0]; + aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); + } + + if (video.sharedOrganization?.organizationId) { + const organization = await db() + .select() + .from(organizations) + .where(eq(organizations.id, video.sharedOrganization.organizationId)) + .limit(1); + + if (organization[0]?.allowedEmailDomain) { + if ( + !user?.email || + !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) + ) { + console.log( + "[ShareVideoPage] Access denied - domain restriction:", + organization[0].allowedEmailDomain + ); + return ( +
+

Access Restricted

+

+ This video is only accessible to members of this organization. +

+

+ Please sign in with your organization email address to access this + content. +

+
+ ); + } + } + } + + if ( + video.transcriptionStatus !== "COMPLETE" && + video.transcriptionStatus !== "PROCESSING" + ) { + console.log("[ShareVideoPage] Starting transcription for video:", videoId); + await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); + + const updatedVideoQuery = await db() + .select({ + id: videos.id, + name: videos.name, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + videoSettings: videos.settings, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(eq(videos.id, videoId)) + .execute(); + + if (updatedVideoQuery[0]) { + Object.assign(video, updatedVideoQuery[0]); + console.log( + "[ShareVideoPage] Updated transcription status:", + video.transcriptionStatus + ); + } + } + + const currentMetadata = (video.metadata as VideoMetadata) || {}; + const metadata = currentMetadata; + let initialAiData = null; + + if (metadata.summary || metadata.chapters || metadata.aiTitle) { + initialAiData = { + title: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + processing: metadata.aiProcessing || false, + }; + } else if (metadata.aiProcessing) { + initialAiData = { + title: null, + summary: null, + chapters: null, + processing: true, + }; + } + + if ( + video.transcriptionStatus === "COMPLETE" && + !currentMetadata.aiProcessing && + !currentMetadata.summary && + !currentMetadata.chapters && + // !currentMetadata.generationError && + aiGenerationEnabled + ) { + try { + generateAiMetadata(videoId, video.owner.id).catch((error) => { + console.error( + `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, + error + ); + }); + } catch (error) { + console.error( + `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, + error + ); + } + } + + const customDomainPromise = (async () => { + if (!user) { + return { customDomain: null, domainVerified: false }; + } + const activeOrganizationId = user.activeOrganizationId; + if (!activeOrganizationId) { + return { customDomain: null, domainVerified: false }; + } + + // Fetch the active org + const orgArr = await db() + .select({ + customDomain: organizations.customDomain, + domainVerified: organizations.domainVerified, + }) + .from(organizations) + .where(eq(organizations.id, activeOrganizationId)) + .limit(1); + + const org = orgArr[0]; + if ( + org?.customDomain && + org.domainVerified !== null && + user.id === video.owner.id + ) { + return { customDomain: org.customDomain, domainVerified: true }; + } + return { customDomain: null, domainVerified: false }; + })(); + + const sharedOrganizationsPromise = db() + .select({ id: sharedVideos.organizationId, name: organizations.name }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const userOrganizationsPromise = (async () => { + if (!userId) return []; + + const [ownedOrganizations, memberOrganizations] = await Promise.all([ + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .where(eq(organizations.ownerId, userId)), + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .innerJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId) + ) + .where(eq(organizationMembers.userId, userId)), + ]); + + const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; + const uniqueOrganizationIds = new Set(); + + return allOrganizations.filter((organization) => { + if (uniqueOrganizationIds.has(organization.id)) return false; + uniqueOrganizationIds.add(organization.id); + return true; + }); + })(); + + const membersListPromise = video.sharedOrganization?.organizationId + ? db() + .select({ userId: organizationMembers.userId }) + .from(organizationMembers) + .where( + eq( + organizationMembers.organizationId, + video.sharedOrganization.organizationId + ) + ) + : Promise.resolve([]); + + const commentsPromise = Effect.gen(function* () { + const db = yield* Database; + const imageUploads = yield* ImageUploads; + + let toplLevelCommentId = Option.none(); + + if (Option.isSome(replyId)) { + const [parentComment] = yield* db.use((db) => + db + .select({ parentCommentId: comments.parentCommentId }) + .from(comments) + .where(eq(comments.id, replyId.value)) + .limit(1) + ); + toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); + } + + const commentToBringToTheTop = Option.orElse( + toplLevelCommentId, + () => commentId + ); + + return yield* db + .use((db) => + db + .select({ + id: comments.id, + content: comments.content, + timestamp: comments.timestamp, + type: comments.type, + authorId: comments.authorId, + videoId: comments.videoId, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + parentCommentId: comments.parentCommentId, + authorName: users.name, + authorImage: users.image, + }) + .from(comments) + .leftJoin(users, eq(comments.authorId, users.id)) + .where(eq(comments.videoId, videoId)) + .orderBy( + Option.match(commentToBringToTheTop, { + onSome: (commentId) => + sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, + onNone: () => comments.createdAt, + }) + ) + ) + .pipe( + Effect.map((comments) => + comments.map( + Effect.fn(function* (c) { + return Object.assign(c, { + authorImage: yield* Option.fromNullable(c.authorImage).pipe( + Option.map(imageUploads.resolveImageUrl), + Effect.transposeOption, + Effect.map(Option.getOrNull) + ), + }); + }) + ) + ), + Effect.flatMap(Effect.all) + ); + }).pipe(EffectRuntime.runPromise); + + const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); + + const [ + membersList, + userOrganizations, + sharedOrganizations, + { customDomain, domainVerified }, + ] = await Promise.all([ + membersListPromise, + userOrganizationsPromise, + sharedOrganizationsPromise, + customDomainPromise, + ]); + + const videoWithOrganizationInfo = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + return { + ...video, + owner: { + id: video.owner.id, + name: video.owner.name, + isPro: userIsPro(video.owner), + image: video.owner.image + ? yield* imageUploads.resolveImageUrl(video.owner.image) + : null, + }, + organization: { + organizationMembers: membersList.map((member) => member.userId), + organizationId: video.sharedOrganization?.organizationId ?? undefined, + }, + sharedOrganizations: sharedOrganizations, + password: null, + folderId: null, + orgSettings: video.orgSettings || null, + settings: video.videoSettings || null, + }; + }).pipe(runPromise); + + return ( + <> +
+ + + +
+ + + ); } diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 5bcf1ea1df..d762b90211 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -1,4 +1,4 @@ -import { InternalError, Video } from "@cap/web-domain"; +import { InternalError, Policy, Video } from "@cap/web-domain"; import { Effect, Exit, Schema, Unify } from "effect"; import { provideOptionalAuth } from "../Auth.ts"; @@ -110,7 +110,23 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( VideosGetAnalytics: (videoIds) => videos.getAnalyticsBulk(videoIds).pipe( - Effect.map((results) => results.map((result) => Unify.unify(result))), + Effect.map((results) => + results.map((result) => + Exit.mapError( + Exit.map(result, (v) => ({ count: v.count } as const)), + (error) => { + if (Schema.is(Video.NotFoundError)(error)) return error; + if (Schema.is(Policy.PolicyDeniedError)(error)) return error; + if (Schema.is(Video.VerifyVideoPasswordError)(error)) + return error; + return error as Video.NotFoundError | Policy.PolicyDeniedError | Video.VerifyVideoPasswordError; + }, + ), + ) as readonly Exit.Exit< + { readonly count: number }, + Video.NotFoundError | Policy.PolicyDeniedError | Video.VerifyVideoPasswordError + >[], + ), provideOptionalAuth, Effect.catchTag( "DatabaseError", From 4d081bd2e1b78030cdee8877fe1be5b456f4185a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:55:23 +0000 Subject: [PATCH 19/23] Refactor nav item matching to use exactMatch flag --- .../dashboard/_components/Navbar/Items.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index a24547c079..4b2b41132d 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -62,7 +62,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { { name: "Analytics", href: `/dashboard/analytics`, - ignoreParams: true, + exactMatch: true, icon: , subNav: [], }, @@ -91,8 +91,8 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [openAIDialog, setOpenAIDialog] = useState(false); const router = useRouter(); - const isPathActive = (path: string, ignoreParams: boolean = false) => - ignoreParams ? pathname === path : pathname.includes(path); + const isPathActive = (path: string, exactMatch: boolean = false) => + exactMatch ? pathname === path : pathname.includes(path); const isDomainSetupVerified = activeOrg?.organization.customDomain && @@ -282,7 +282,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { key={item.name} className="flex relative justify-center items-center mb-1.5 w-full" > - {isPathActive(item.href, item.ignoreParams) && ( + {isPathActive(item.href, item.exactMatch ?? false) && ( { toggleMobileNav={toggleMobileNav} isPathActive={isPathActive} extraText={item.extraText} - ignoreParams={item.ignoreParams ?? false} + exactMatch={item.exactMatch ?? false} />
))} @@ -401,7 +401,7 @@ const NavItem = ({ sidebarCollapsed, toggleMobileNav, isPathActive, - ignoreParams, + exactMatch, extraText, }: { name: string; @@ -413,9 +413,9 @@ const NavItem = ({ }>; sidebarCollapsed: boolean; toggleMobileNav?: () => void; - isPathActive: (path: string, ignoreParams: boolean) => boolean; + isPathActive: (path: string, exactMatch: boolean) => boolean; extraText: number | null | undefined; - ignoreParams: boolean; + exactMatch: boolean; }) => { const iconRef = useRef(null); return ( @@ -436,7 +436,7 @@ const NavItem = ({ sidebarCollapsed ? "flex justify-center items-center px-0 w-full size-9" : "px-3 py-2 w-full", - isPathActive(href, ignoreParams) + isPathActive(href, exactMatch) ? "bg-transparent pointer-events-none" : "hover:bg-gray-2", "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", From c32195960abbb99c8ec0305f17109b89db6a1dfc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:59:17 +0000 Subject: [PATCH 20/23] Refactor nav item path matching logic --- .../dashboard/_components/Navbar/Items.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 4b2b41132d..af28097175 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -62,7 +62,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { { name: "Analytics", href: `/dashboard/analytics`, - exactMatch: true, + matchChildren: true, icon: , subNav: [], }, @@ -91,8 +91,13 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [openAIDialog, setOpenAIDialog] = useState(false); const router = useRouter(); - const isPathActive = (path: string, exactMatch: boolean = false) => - exactMatch ? pathname === path : pathname.includes(path); + const isPathActive = (path: string, matchChildren: boolean = false) => { + if (matchChildren) { + return pathname === path || pathname.startsWith(`${path}/`); + } + + return pathname === path; + }; const isDomainSetupVerified = activeOrg?.organization.customDomain && @@ -282,7 +287,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { key={item.name} className="flex relative justify-center items-center mb-1.5 w-full" > - {isPathActive(item.href, item.exactMatch ?? false) && ( + {isPathActive(item.href, item.matchChildren ?? false) && ( { toggleMobileNav={toggleMobileNav} isPathActive={isPathActive} extraText={item.extraText} - exactMatch={item.exactMatch ?? false} + matchChildren={item.matchChildren ?? false} />
))} @@ -401,7 +406,7 @@ const NavItem = ({ sidebarCollapsed, toggleMobileNav, isPathActive, - exactMatch, + matchChildren, extraText, }: { name: string; @@ -413,9 +418,9 @@ const NavItem = ({ }>; sidebarCollapsed: boolean; toggleMobileNav?: () => void; - isPathActive: (path: string, exactMatch: boolean) => boolean; + isPathActive: (path: string, matchChildren: boolean) => boolean; extraText: number | null | undefined; - exactMatch: boolean; + matchChildren: boolean; }) => { const iconRef = useRef(null); return ( @@ -436,7 +441,7 @@ const NavItem = ({ sidebarCollapsed ? "flex justify-center items-center px-0 w-full size-9" : "px-3 py-2 w-full", - isPathActive(href, exactMatch) + isPathActive(href, matchChildren) ? "bg-transparent pointer-events-none" : "hover:bg-gray-2", "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", From 06d0060197c4babe68c78cafcd0ae935abfa40a3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:05:52 +0000 Subject: [PATCH 21/23] Refactor analytics and sharing components --- .../dashboard/_components/Navbar/Items.tsx | 2 +- .../components/AnalyticsDashboard.tsx | 48 +- .../analytics/components/CompareFilters.tsx | 193 --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 554 +++---- apps/web/app/s/[videoId]/Share.tsx | 10 +- apps/web/app/s/[videoId]/page.tsx | 1420 ++++++++--------- .../browsers/{maxthron.svg => maxthon.svg} | 0 packages/web-backend/src/Videos/VideosRpcs.ts | 39 +- .../pipes/analytics_sessions_mv_pipe.pipe | 9 +- 9 files changed, 1046 insertions(+), 1229 deletions(-) delete mode 100644 apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx rename apps/web/public/logos/browsers/{maxthron.svg => maxthon.svg} (100%) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index af28097175..bbd498faea 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -287,7 +287,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { key={item.name} className="flex relative justify-center items-center mb-1.5 w-full" > - {isPathActive(item.href, item.matchChildren ?? false) && ( + {isPathActive(item.href, item.matchChildren ?? false) && ( ({ + const query = useEffectQuery({ queryKey: ["dashboard-analytics", orgId, selectedSpaceId, range, capId], - queryFn: async () => { - if (!orgId) return null; - const url = new URL("/api/dashboard/analytics", window.location.origin); - url.searchParams.set("orgId", orgId); - url.searchParams.set("range", range); - if (selectedSpaceId) { - url.searchParams.set("spaceId", selectedSpaceId); - } - if (capId) { - url.searchParams.set("capId", capId); - } - const response = await fetch(url.toString(), { cache: "no-store" }); - console.log("response", response); - if (!response.ok) throw new Error("Failed to load analytics"); - return (await response.json()) as { data: OrgAnalyticsResponse }; - }, + queryFn: () => + Effect.gen(function* () { + if (!orgId) return null; + const url = new URL("/api/dashboard/analytics", window.location.origin); + url.searchParams.set("orgId", orgId); + url.searchParams.set("range", range); + if (selectedSpaceId) { + url.searchParams.set("spaceId", selectedSpaceId); + } + if (capId) { + url.searchParams.set("capId", capId); + } + const response = yield* Effect.tryPromise({ + try: () => fetch(url.toString(), { cache: "no-store" }), + catch: (cause: unknown) => cause as Error, + }); + if (!response.ok) { + return yield* Effect.fail(new Error("Failed to load analytics")); + } + return yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ data: OrgAnalyticsResponse }>, + catch: (cause: unknown) => cause as Error, + }); + }), enabled: Boolean(orgId), staleTime: 60 * 1000, }); - const analytics = query.data?.data; + const analytics = (query.data as { data: OrgAnalyticsResponse } | undefined) + ?.data; if (!orgId) { return ( diff --git a/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx b/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx deleted file mode 100644 index fa5c356097..0000000000 --- a/apps/web/app/(org)/dashboard/analytics/components/CompareFilters.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import clsx from "clsx"; -import { motion, useDragControls, useMotionValue } from "motion/react"; -import React, { useEffect, useRef, useState } from "react"; - -const filterColorMap = { - views: "bg-blue-300", - comments: "bg-green-300", - reactions: "bg-red-300", - shares: "bg-yellow-300", - downloads: "bg-purple-300", - uploads: "bg-pink-300", - deletions: "bg-gray-300", - creations: "bg-orange-300", - edits: "bg-teal-300", -} as const; - -const labelMap = { - views: "Views", - comments: "Comments", - reactions: "Reactions", - shares: "Shares", - downloads: "Downloads", - uploads: "Uploads", - deletions: "Deletions", - creations: "Creations", - edits: "Edits", -} as const; - -export type FilterValue = keyof typeof filterColorMap; - -interface CompareDataFilterItemProps { - label: string; - value: FilterValue; - isInUse: boolean; - onDragStart: () => void; - onDragEnd: (x: number, y: number) => void; - onDrag: (x: number, y: number) => void; -} - -export const CompareDataFilterItem = ({ - label, - value, - isInUse, - onDragStart, - onDragEnd, - onDrag, -}: CompareDataFilterItemProps) => { - const controls = useDragControls(); - const x = useMotionValue(0); - const y = useMotionValue(0); - const elementRef = useRef(null); - - const handleDragEnd = () => { - if (elementRef.current) { - const rect = elementRef.current.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - onDragEnd(centerX, centerY); - } - }; - - const handleDrag = () => { - if (elementRef.current) { - const rect = elementRef.current.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - onDrag(centerX, centerY); - } - }; - - if (isInUse) { - return null; - } - - return ( - controls.start(e)} - onDragStart={onDragStart} - onDrag={handleDrag} - style={{ - x, - y, - touchAction: "none", - }} - layout="position" - initial={{ opacity: 0, scale: 0.8 }} - animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} - exit={{ opacity: 0, scale: 0.8 }} - transition={{ - layout: { - type: "spring", - stiffness: 600, - damping: 35, - mass: 0.8, - }, - opacity: { duration: 0.15, ease: "easeInOut" }, - scale: { duration: 0.15, ease: "easeInOut" }, - x: { type: "spring", stiffness: 600, damping: 35 }, - y: { type: "spring", stiffness: 600, damping: 35 }, - }} - onDragEnd={handleDragEnd} - className={clsx( - "flex-1 px-2 h-6 rounded-full cursor-grab active:cursor-grabbing max-w-fit min-w-fit", - filterColorMap[value as keyof typeof filterColorMap] ?? "bg-gray-5", - )} - > -

{label}

-
- ); -}; - -interface CompareDataDroppableProps { - id: string; - droppedValue: string | null; - onRemove: () => void; - isDragging: boolean; - dragPosition: { x: number; y: number }; -} - -export const CompareDataDroppable = React.forwardRef< - HTMLDivElement, - CompareDataDroppableProps ->(({ droppedValue, onRemove, isDragging, dragPosition }, ref) => { - const [isOver, setIsOver] = useState(false); - - useEffect(() => { - if (!isDragging) { - setIsOver(false); - return; - } - - const checkIsOver = () => { - if (ref && typeof ref !== "function" && ref.current) { - const rect = ref.current.getBoundingClientRect(); - const over = - dragPosition.x >= rect.left && - dragPosition.x <= rect.right && - dragPosition.y >= rect.top && - dragPosition.y <= rect.bottom; - setIsOver(over); - } - }; - - checkIsOver(); - }, [isDragging, dragPosition, ref]); - - return ( -
- {droppedValue && ( -
-

- {labelMap[droppedValue as keyof typeof labelMap] ?? droppedValue} -

- -
- )} -
- ); -}); diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index fc8a2b95ca..bb4a846e22 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -13,11 +13,11 @@ import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; import { useDashboardContext } from "../Contexts"; import { - NewFolderDialog, - SelectedCapsBar, - UploadCapButton, - UploadPlaceholderCard, - WebRecorderDialog, + NewFolderDialog, + SelectedCapsBar, + UploadCapButton, + UploadPlaceholderCard, + WebRecorderDialog, } from "./components"; import { CapCard } from "./components/CapCard/CapCard"; import { CapPagination } from "./components/CapPagination"; @@ -27,306 +27,306 @@ import Folder from "./components/Folder"; import { useUploadingStatus } from "./UploadingContext"; export type VideoData = { - id: Video.VideoId; - ownerId: string; - name: string; - createdAt: Date; - public: boolean; - totalComments: number; - totalReactions: number; - foldersData: FolderDataType[]; - sharedOrganizations: { - id: string; - name: string; - iconUrl?: ImageUpload.ImageUrl | null; - }[]; - sharedSpaces?: { - id: string; - name: string; - isOrg: boolean; - organizationId: string; - }[]; - ownerName: string; - metadata?: VideoMetadata; - hasPassword: boolean; - hasActiveUpload: boolean; + id: Video.VideoId; + ownerId: string; + name: string; + createdAt: Date; + public: boolean; + totalComments: number; + totalReactions: number; + foldersData: FolderDataType[]; + sharedOrganizations: { + id: string; + name: string; + iconUrl?: ImageUpload.ImageUrl | null; + }[]; + sharedSpaces?: { + id: string; + name: string; + isOrg: boolean; + organizationId: string; + }[]; + ownerName: string; + metadata?: VideoMetadata; + hasPassword: boolean; + hasActiveUpload: boolean; }[]; export const Caps = ({ - data, - count, - analyticsEnabled, - folders, + data, + count, + analyticsEnabled, + folders, }: { - data: VideoData; - count: number; - folders: FolderDataType[]; - analyticsEnabled: boolean; + data: VideoData; + count: number; + folders: FolderDataType[]; + analyticsEnabled: boolean; }) => { - const router = useRouter(); - const params = useSearchParams(); - const page = Number(params.get("page")) || 1; - const { user } = useDashboardContext(); - const limit = 15; - const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); - const totalPages = Math.ceil(count / limit); - const previousCountRef = useRef(0); - const [selectedCaps, setSelectedCaps] = useState([]); - const [isDraggingCap, setIsDraggingCap] = useState(false); + const router = useRouter(); + const params = useSearchParams(); + const page = Number(params.get("page")) || 1; + const { user } = useDashboardContext(); + const limit = 15; + const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); + const totalPages = Math.ceil(count / limit); + const previousCountRef = useRef(0); + const [selectedCaps, setSelectedCaps] = useState([]); + const [isDraggingCap, setIsDraggingCap] = useState(false); - const anyCapSelected = selectedCaps.length > 0; + const anyCapSelected = selectedCaps.length > 0; - const analyticsQuery = useVideosAnalyticsQuery( - data.map((video) => video.id), - analyticsEnabled - ); - const analytics: Partial> = - analyticsQuery.data || {}; - const isLoadingAnalytics = analyticsEnabled && analyticsQuery.isLoading; + const analyticsQuery = useVideosAnalyticsQuery( + data.map((video) => video.id), + analyticsEnabled, + ); + const analytics: Partial> = + analyticsQuery.data || {}; + const isLoadingAnalytics = analyticsEnabled && analyticsQuery.isLoading; - const handleCapSelection = (capId: Video.VideoId) => { - setSelectedCaps((prev) => { - const newSelection = prev.includes(capId) - ? prev.filter((id) => id !== capId) - : [...prev, capId]; + const handleCapSelection = (capId: Video.VideoId) => { + setSelectedCaps((prev) => { + const newSelection = prev.includes(capId) + ? prev.filter((id) => id !== capId) + : [...prev, capId]; - previousCountRef.current = prev.length; + previousCountRef.current = prev.length; - return newSelection; - }); - }; + return newSelection; + }); + }; - const rpc = useRpcClient() as { - VideoDelete: (id: Video.VideoId) => Effect.Effect; - }; + const rpc = useRpcClient() as { + VideoDelete: (id: Video.VideoId) => Effect.Effect; + }; - const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ - mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { - if (ids.length === 0) return { success: 0 }; + const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ + mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { + if (ids.length === 0) return { success: 0 }; - const results = yield* Effect.all( - ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), - { concurrency: 10 } - ); + const results = yield* Effect.all( + ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), + { concurrency: 10 }, + ); - const successCount = results.filter(Exit.isSuccess).length; + const successCount = results.filter(Exit.isSuccess).length; - const errorCount = ids.length - successCount; + const errorCount = ids.length - successCount; - if (successCount > 0 && errorCount > 0) { - return { success: successCount, error: errorCount }; - } else if (successCount > 0) { - return { success: successCount }; - } else { - return yield* Effect.fail( - new Error( - `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}` - ) - ); - } - }), - onMutate: (ids: Video.VideoId[]) => { - toast.loading( - `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...` - ); - }, - onSuccess: (data: { success: number; error?: number }) => { - setSelectedCaps([]); - router.refresh(); - if (data.error) { - toast.success( - `Successfully deleted ${data.success} cap${ - data.success === 1 ? "" : "s" - }, but failed to delete ${data.error} cap${ - data.error === 1 ? "" : "s" - }` - ); - } else { - toast.success( - `Successfully deleted ${data.success} cap${ - data.success === 1 ? "" : "s" - }` - ); - } - }, - onError: (error: unknown) => { - const message = - error instanceof Error - ? error.message - : "An error occurred while deleting caps"; - toast.error(message); - }, - }); + if (successCount > 0 && errorCount > 0) { + return { success: successCount, error: errorCount }; + } else if (successCount > 0) { + return { success: successCount }; + } else { + return yield* Effect.fail( + new Error( + `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`, + ), + ); + } + }), + onMutate: (ids: Video.VideoId[]) => { + toast.loading( + `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, + ); + }, + onSuccess: (data: { success: number; error?: number }) => { + setSelectedCaps([]); + router.refresh(); + if (data.error) { + toast.success( + `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }, but failed to delete ${data.error} cap${ + data.error === 1 ? "" : "s" + }`, + ); + } else { + toast.success( + `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }`, + ); + } + }, + onError: (error: unknown) => { + const message = + error instanceof Error + ? error.message + : "An error occurred while deleting caps"; + toast.error(message); + }, + }); - const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ - mutationFn: Effect.fn(function* (id: Video.VideoId) { - yield* rpc.VideoDelete(id); - }), - onSuccess: () => { - toast.success("Cap deleted successfully"); - router.refresh(); - }, - onError: (_error: unknown) => toast.error("Failed to delete cap"), - }); + const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ + mutationFn: Effect.fn(function* (id: Video.VideoId) { + yield* rpc.VideoDelete(id); + }), + onSuccess: () => { + toast.success("Cap deleted successfully"); + router.refresh(); + }, + onError: (_error: unknown) => toast.error("Failed to delete cap"), + }); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && selectedCaps.length > 0) { - setSelectedCaps([]); - } + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && selectedCaps.length > 0) { + setSelectedCaps([]); + } - if ( - (e.key === "Delete" || e.key === "Backspace") && - selectedCaps.length > 0 - ) { - if (e.key === "Backspace") { - e.preventDefault(); - } + if ( + (e.key === "Delete" || e.key === "Backspace") && + selectedCaps.length > 0 + ) { + if (e.key === "Backspace") { + e.preventDefault(); + } - if ( - !["INPUT", "TEXTAREA", "SELECT"].includes( - document.activeElement?.tagName || "" - ) - ) { - deleteCaps(selectedCaps); - } - } + if ( + !["INPUT", "TEXTAREA", "SELECT"].includes( + document.activeElement?.tagName || "", + ) + ) { + deleteCaps(selectedCaps); + } + } - if (e.key === "a" && (e.ctrlKey || e.metaKey) && data.length > 0) { - if ( - !["INPUT", "TEXTAREA", "SELECT"].includes( - document.activeElement?.tagName || "" - ) - ) { - e.preventDefault(); - setSelectedCaps(data.map((cap) => cap.id)); - } - } - }; + if (e.key === "a" && (e.ctrlKey || e.metaKey) && data.length > 0) { + if ( + !["INPUT", "TEXTAREA", "SELECT"].includes( + document.activeElement?.tagName || "", + ) + ) { + e.preventDefault(); + setSelectedCaps(data.map((cap) => cap.id)); + } + } + }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [selectedCaps, data, deleteCaps]); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedCaps, data, deleteCaps]); - useEffect(() => { - const handleDragStart = () => setIsDraggingCap(true); - const handleDragEnd = () => setIsDraggingCap(false); + useEffect(() => { + const handleDragStart = () => setIsDraggingCap(true); + const handleDragEnd = () => setIsDraggingCap(false); - window.addEventListener("dragstart", handleDragStart); - window.addEventListener("dragend", handleDragEnd); + window.addEventListener("dragstart", handleDragStart); + window.addEventListener("dragend", handleDragEnd); - return () => { - window.removeEventListener("dragstart", handleDragStart); - window.removeEventListener("dragend", handleDragEnd); - }; - }, []); + return () => { + window.removeEventListener("dragstart", handleDragStart); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); - const [isUploading, uploadingCapId] = useUploadingStatus(); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? data.filter((video) => video.id !== uploadingCapId) - : data, - [data, isUploading, uploadingCapId] - ); + const [isUploading, uploadingCapId] = useUploadingStatus(); + const visibleVideos = useMemo( + () => + isUploading && uploadingCapId + ? data.filter((video) => video.id !== uploadingCapId) + : data, + [data, isUploading, uploadingCapId], + ); - if (count === 0 && folders.length === 0) return ; + if (count === 0 && folders.length === 0) return ; - return ( -
- -
- - - -
- {folders.length > 0 && ( - <> -
-

Folders

-
-
- {folders.map((folder) => ( - - ))} -
- - )} - {visibleVideos.length > 0 && ( - <> -
-

Videos

-
+ return ( +
+ +
+ + + +
+ {folders.length > 0 && ( + <> +
+

Folders

+
+
+ {folders.map((folder) => ( + + ))} +
+ + )} + {visibleVideos.length > 0 && ( + <> +
+

Videos

+
-
- {isUploading && ( - - )} - {visibleVideos.map((video) => { - const videoAnalytics = analytics[video.id]; - return ( - { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} - userId={user?.id} - isLoadingAnalytics={isLoadingAnalytics} - isSelected={selectedCaps.includes(video.id)} - anyCapSelected={anyCapSelected} - onSelectToggle={() => handleCapSelection(video.id)} - /> - ); - })} -
- - )} - {(data.length > limit || data.length === limit || page !== 1) && ( -
- -
- )} - deleteCaps(selectedCaps)} - isDeleting={isDeletingCaps || isDeletingCap} - /> - {isDraggingCap && ( -
-
-
- -

- Drag to a space to share or folder to move -

-
-
-
- )} -
- ); +
+ {isUploading && ( + + )} + {visibleVideos.map((video) => { + const videoAnalytics = analytics[video.id]; + return ( + { + if (selectedCaps.length > 0) { + deleteCaps(selectedCaps); + } else { + deleteCap(video.id); + } + }} + userId={user?.id} + isLoadingAnalytics={isLoadingAnalytics} + isSelected={selectedCaps.includes(video.id)} + anyCapSelected={anyCapSelected} + onSelectToggle={() => handleCapSelection(video.id)} + /> + ); + })} +
+ + )} + {(data.length > limit || data.length === limit || page !== 1) && ( +
+ +
+ )} + deleteCaps(selectedCaps)} + isDeleting={isDeletingCaps || isDeletingCap} + /> + {isDraggingCap && ( +
+
+
+ +

+ Drag to a space to share or folder to move +

+
+
+
+ )} +
+ ); }; diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 442dc05199..b23fd6379e 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -119,21 +119,16 @@ const trackVideoView = (payload: { } } - const controller = new AbortController(); - void fetch("/api/analytics/track", { method: "POST", headers: { "Content-Type": "application/json" }, body: serializedBody, - signal: controller.signal, keepalive: true, }).catch((error) => { if (error?.name !== "AbortError") { console.warn("Failed to track analytics event", error); } }); - - return () => controller.abort(); }; interface ShareProps { @@ -280,14 +275,11 @@ export const Share = ({ return; } - const dispose = trackVideoView({ + trackVideoView({ videoId: data.id, orgId: data.orgId, ownerId: data.owner.id, }); - return () => { - dispose?.(); - }; }, [data.id, data.orgId, data.owner.id, viewerId]); const shouldShowLoading = () => { diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 7d1200faca..3e5d7a9b13 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -1,32 +1,32 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - comments, - organizationMembers, - organizations, - sharedVideos, - spaces, - spaceVideos, - users, - videos, - videoUploads, + comments, + organizationMembers, + organizations, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; import { - Database, - ImageUploads, - provideOptionalAuth, - Videos, + Database, + ImageUploads, + provideOptionalAuth, + Videos, } from "@cap/web-backend"; import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy"; import { - Comment, - type Organisation, - Policy, - type Video, + Comment, + type Organisation, + Policy, + type Video, } from "@cap/web-domain"; import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -37,8 +37,8 @@ import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; import { getVideoAnalytics } from "@/actions/videos/get-analytics"; import { - getDashboardData, - type OrganizationSettings, + getDashboardData, + type OrganizationSettings, } from "@/app/(org)/dashboard/dashboard-data"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; @@ -52,720 +52,720 @@ import { Share } from "./Share"; // Helper function to fetch shared spaces data for a video async function getSharedSpacesForVideo(videoId: Video.VideoId) { - // Fetch space-level sharing - const spaceSharing = await db() - .select({ - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where(eq(spaceVideos.videoId, videoId)); - - // Fetch organization-level sharing - const orgSharing = await db() - .select({ - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const sharedSpaces: Array<{ - id: string; - name: string; - organizationId: string; - iconUrl?: string; - }> = []; - - // Add space-level sharing - spaceSharing.forEach((space) => { - sharedSpaces.push({ - id: space.id, - name: space.name, - organizationId: space.organizationId, - iconUrl: space.iconUrl || undefined, - }); - }); - - // Add organization-level sharing - orgSharing.forEach((org) => { - sharedSpaces.push({ - id: org.id, - name: org.name, - organizationId: org.organizationId, - iconUrl: org.iconUrl || undefined, - }); - }); - - return sharedSpaces; + // Fetch space-level sharing + const spaceSharing = await db() + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where(eq(spaceVideos.videoId, videoId)); + + // Fetch organization-level sharing + const orgSharing = await db() + .select({ + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const sharedSpaces: Array<{ + id: string; + name: string; + organizationId: string; + iconUrl?: string; + }> = []; + + // Add space-level sharing + spaceSharing.forEach((space) => { + sharedSpaces.push({ + id: space.id, + name: space.name, + organizationId: space.organizationId, + iconUrl: space.iconUrl || undefined, + }); + }); + + // Add organization-level sharing + orgSharing.forEach((org) => { + sharedSpaces.push({ + id: org.id, + name: org.name, + organizationId: org.organizationId, + iconUrl: org.iconUrl || undefined, + }); + }); + + return sharedSpaces; } const ALLOWED_REFERRERS = [ - "x.com", - "twitter.com", - "facebook.com", - "fb.com", - "slack.com", - "notion.so", - "linkedin.com", + "x.com", + "twitter.com", + "facebook.com", + "fb.com", + "slack.com", + "notion.so", + "linkedin.com", ]; function PolicyDeniedView() { - return ( -
- -

This video is private

-

- If you own this video, please sign in to - manage sharing. -

-
- ); + return ( +
+ +

This video is private

+

+ If you own this video, please sign in to + manage sharing. +

+
+ ); } const renderPolicyDenied = (videoId: Video.VideoId) => - Effect.succeed(); + Effect.succeed(); const renderNoSuchElement = () => Effect.sync(() => notFound()); const getShareVideoPageCatchers = (videoId: Video.VideoId) => ({ - PolicyDenied: () => renderPolicyDenied(videoId), - NoSuchElementException: renderNoSuchElement, + PolicyDenied: () => renderPolicyDenied(videoId), + NoSuchElementException: renderNoSuchElement, }); export async function generateMetadata( - props: PageProps<"/s/[videoId]"> + props: PageProps<"/s/[videoId]">, ): Promise { - const params = await props.params; - const videoId = params.videoId as Video.VideoId; - - const referrer = (await headers()).get("x-referrer") || ""; - const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => - referrer.includes(domain) - ); - - return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( - Effect.map( - Option.match({ - onNone: () => notFound(), - onSome: ([video]) => ({ - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - twitter: { - card: "player", - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - ], - players: { - playerUrl: new URL( - `/s/${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - streamUrl: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - }, - }, - robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", - }), - }) - ), - Effect.catchTags({ - PolicyDenied: () => - Effect.succeed({ - title: "Cap: This video is private", - description: "This video is private and cannot be shared.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - robots: "noindex, nofollow", - }), - VerifyVideoPasswordError: () => - Effect.succeed({ - title: "Cap: Password Protected Video", - description: "This video is password protected.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Cap: Password Protected Video", - description: "This video is password protected.", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - ], - }, - robots: "noindex, nofollow", - }), - }), - provideOptionalAuth, - EffectRuntime.runPromise - ); + const params = await props.params; + const videoId = params.videoId as Video.VideoId; + + const referrer = (await headers()).get("x-referrer") || ""; + const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => + referrer.includes(domain), + ); + + return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( + Effect.map( + Option.match({ + onNone: () => notFound(), + onSome: ([video]) => ({ + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + twitter: { + card: "player", + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + ], + players: { + playerUrl: new URL( + `/s/${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + streamUrl: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + }, + }, + robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", + }), + }), + ), + Effect.catchTags({ + PolicyDenied: () => + Effect.succeed({ + title: "Cap: This video is private", + description: "This video is private and cannot be shared.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + robots: "noindex, nofollow", + }), + VerifyVideoPasswordError: () => + Effect.succeed({ + title: "Cap: Password Protected Video", + description: "This video is password protected.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Cap: Password Protected Video", + description: "This video is password protected.", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + ], + }, + robots: "noindex, nofollow", + }), + }), + provideOptionalAuth, + EffectRuntime.runPromise, + ); } export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { - const params = await props.params; - const searchParams = await props.searchParams; - const videoId = params.videoId as Video.VideoId; - - return Effect.gen(function* () { - const videosPolicy = yield* VideosPolicy; - - const [video] = yield* Effect.promise(() => - db() - .select({ - id: videos.id, - name: videos.name, - orgId: videos.orgId, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - awsRegion: videos.awsRegion, - awsBucket: videos.awsBucket, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - videoSettings: videos.settings, - width: videos.width, - height: videos.height, - duration: videos.duration, - fps: videos.fps, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean - ), - owner: users, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))) - ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); - - return Option.fromNullable(video); - }).pipe( - Effect.flatten, - Effect.map((video) => ({ needsPassword: false, video } as const)), - Effect.catchTag("VerifyVideoPasswordError", () => - Effect.succeed({ needsPassword: true } as const) - ), - Effect.map((data) => ( -
- - {!data.needsPassword && ( - - )} -
- )), - Effect.catchTags(getShareVideoPageCatchers(videoId)), - provideOptionalAuth, - EffectRuntime.runPromise - ); + const params = await props.params; + const searchParams = await props.searchParams; + const videoId = params.videoId as Video.VideoId; + + return Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + const [video] = yield* Effect.promise(() => + db() + .select({ + id: videos.id, + name: videos.name, + orgId: videos.orgId, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + awsRegion: videos.awsRegion, + awsBucket: videos.awsBucket, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + videoSettings: videos.settings, + width: videos.width, + height: videos.height, + duration: videos.duration, + fps: videos.fps, + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean, + ), + owner: users, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + + return Option.fromNullable(video); + }).pipe( + Effect.flatten, + Effect.map((video) => ({ needsPassword: false, video }) as const), + Effect.catchTag("VerifyVideoPasswordError", () => + Effect.succeed({ needsPassword: true } as const), + ), + Effect.map((data) => ( +
+ + {!data.needsPassword && ( + + )} +
+ )), + Effect.catchTags(getShareVideoPageCatchers(videoId)), + provideOptionalAuth, + EffectRuntime.runPromise, + ); } async function AuthorizedContent({ - video, - searchParams, + video, + searchParams, }: { - video: Omit< - InferSelectModel, - "folderId" | "password" | "settings" | "ownerId" - > & { - owner: InferSelectModel; - sharedOrganization: { organizationId: Organisation.OrganisationId } | null; - hasPassword: boolean; - orgSettings?: OrganizationSettings | null; - videoSettings?: OrganizationSettings | null; - }; - searchParams: { [key: string]: string | string[] | undefined }; + video: Omit< + InferSelectModel, + "folderId" | "password" | "settings" | "ownerId" + > & { + owner: InferSelectModel; + sharedOrganization: { organizationId: Organisation.OrganisationId } | null; + hasPassword: boolean; + orgSettings?: OrganizationSettings | null; + videoSettings?: OrganizationSettings | null; + }; + searchParams: { [key: string]: string | string[] | undefined }; }) { - // will have already been fetched if auth is required - const user = await getCurrentUser(); - const videoId = video.id; - - if (user && video && user.id !== video.owner.id) { - try { - await createNotification({ - type: "view", - videoId: video.id, - authorId: user.id, - }); - } catch (error) { - console.warn("Failed to create view notification:", error); - } - } - - const userId = user?.id; - const commentId = optionFromTOrFirst(searchParams.comment).pipe( - Option.map(Comment.CommentId.make) - ); - const replyId = optionFromTOrFirst(searchParams.reply).pipe( - Option.map(Comment.CommentId.make) - ); - - // Fetch spaces data for the sharing dialog - let spacesData = null; - if (user) { - try { - const dashboardData = await getDashboardData(user); - spacesData = dashboardData.spacesData; - } catch (error) { - console.error("Failed to fetch spaces data for sharing dialog:", error); - spacesData = []; - } - } - - // Fetch shared spaces data for this video - const sharedSpaces = await getSharedSpacesForVideo(videoId); - - let aiGenerationEnabled = false; - const videoOwnerQuery = await db() - .select({ - email: users.email, - stripeSubscriptionStatus: users.stripeSubscriptionStatus, - }) - .from(users) - .where(eq(users.id, video.owner.id)) - .limit(1); - - if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { - const videoOwner = videoOwnerQuery[0]; - aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); - } - - if (video.sharedOrganization?.organizationId) { - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, video.sharedOrganization.organizationId)) - .limit(1); - - if (organization[0]?.allowedEmailDomain) { - if ( - !user?.email || - !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) - ) { - console.log( - "[ShareVideoPage] Access denied - domain restriction:", - organization[0].allowedEmailDomain - ); - return ( -
-

Access Restricted

-

- This video is only accessible to members of this organization. -

-

- Please sign in with your organization email address to access this - content. -

-
- ); - } - } - } - - if ( - video.transcriptionStatus !== "COMPLETE" && - video.transcriptionStatus !== "PROCESSING" - ) { - console.log("[ShareVideoPage] Starting transcription for video:", videoId); - await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); - - const updatedVideoQuery = await db() - .select({ - id: videos.id, - name: videos.name, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - videoSettings: videos.settings, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(eq(videos.id, videoId)) - .execute(); - - if (updatedVideoQuery[0]) { - Object.assign(video, updatedVideoQuery[0]); - console.log( - "[ShareVideoPage] Updated transcription status:", - video.transcriptionStatus - ); - } - } - - const currentMetadata = (video.metadata as VideoMetadata) || {}; - const metadata = currentMetadata; - let initialAiData = null; - - if (metadata.summary || metadata.chapters || metadata.aiTitle) { - initialAiData = { - title: metadata.aiTitle || null, - summary: metadata.summary || null, - chapters: metadata.chapters || null, - processing: metadata.aiProcessing || false, - }; - } else if (metadata.aiProcessing) { - initialAiData = { - title: null, - summary: null, - chapters: null, - processing: true, - }; - } - - if ( - video.transcriptionStatus === "COMPLETE" && - !currentMetadata.aiProcessing && - !currentMetadata.summary && - !currentMetadata.chapters && - // !currentMetadata.generationError && - aiGenerationEnabled - ) { - try { - generateAiMetadata(videoId, video.owner.id).catch((error) => { - console.error( - `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, - error - ); - }); - } catch (error) { - console.error( - `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, - error - ); - } - } - - const customDomainPromise = (async () => { - if (!user) { - return { customDomain: null, domainVerified: false }; - } - const activeOrganizationId = user.activeOrganizationId; - if (!activeOrganizationId) { - return { customDomain: null, domainVerified: false }; - } - - // Fetch the active org - const orgArr = await db() - .select({ - customDomain: organizations.customDomain, - domainVerified: organizations.domainVerified, - }) - .from(organizations) - .where(eq(organizations.id, activeOrganizationId)) - .limit(1); - - const org = orgArr[0]; - if ( - org?.customDomain && - org.domainVerified !== null && - user.id === video.owner.id - ) { - return { customDomain: org.customDomain, domainVerified: true }; - } - return { customDomain: null, domainVerified: false }; - })(); - - const sharedOrganizationsPromise = db() - .select({ id: sharedVideos.organizationId, name: organizations.name }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const userOrganizationsPromise = (async () => { - if (!userId) return []; - - const [ownedOrganizations, memberOrganizations] = await Promise.all([ - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .where(eq(organizations.ownerId, userId)), - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .innerJoin( - organizationMembers, - eq(organizations.id, organizationMembers.organizationId) - ) - .where(eq(organizationMembers.userId, userId)), - ]); - - const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; - const uniqueOrganizationIds = new Set(); - - return allOrganizations.filter((organization) => { - if (uniqueOrganizationIds.has(organization.id)) return false; - uniqueOrganizationIds.add(organization.id); - return true; - }); - })(); - - const membersListPromise = video.sharedOrganization?.organizationId - ? db() - .select({ userId: organizationMembers.userId }) - .from(organizationMembers) - .where( - eq( - organizationMembers.organizationId, - video.sharedOrganization.organizationId - ) - ) - : Promise.resolve([]); - - const commentsPromise = Effect.gen(function* () { - const db = yield* Database; - const imageUploads = yield* ImageUploads; - - let toplLevelCommentId = Option.none(); - - if (Option.isSome(replyId)) { - const [parentComment] = yield* db.use((db) => - db - .select({ parentCommentId: comments.parentCommentId }) - .from(comments) - .where(eq(comments.id, replyId.value)) - .limit(1) - ); - toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); - } - - const commentToBringToTheTop = Option.orElse( - toplLevelCommentId, - () => commentId - ); - - return yield* db - .use((db) => - db - .select({ - id: comments.id, - content: comments.content, - timestamp: comments.timestamp, - type: comments.type, - authorId: comments.authorId, - videoId: comments.videoId, - createdAt: comments.createdAt, - updatedAt: comments.updatedAt, - parentCommentId: comments.parentCommentId, - authorName: users.name, - authorImage: users.image, - }) - .from(comments) - .leftJoin(users, eq(comments.authorId, users.id)) - .where(eq(comments.videoId, videoId)) - .orderBy( - Option.match(commentToBringToTheTop, { - onSome: (commentId) => - sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, - onNone: () => comments.createdAt, - }) - ) - ) - .pipe( - Effect.map((comments) => - comments.map( - Effect.fn(function* (c) { - return Object.assign(c, { - authorImage: yield* Option.fromNullable(c.authorImage).pipe( - Option.map(imageUploads.resolveImageUrl), - Effect.transposeOption, - Effect.map(Option.getOrNull) - ), - }); - }) - ) - ), - Effect.flatMap(Effect.all) - ); - }).pipe(EffectRuntime.runPromise); - - const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); - - const [ - membersList, - userOrganizations, - sharedOrganizations, - { customDomain, domainVerified }, - ] = await Promise.all([ - membersListPromise, - userOrganizationsPromise, - sharedOrganizationsPromise, - customDomainPromise, - ]); - - const videoWithOrganizationInfo = await Effect.gen(function* () { - const imageUploads = yield* ImageUploads; - - return { - ...video, - owner: { - id: video.owner.id, - name: video.owner.name, - isPro: userIsPro(video.owner), - image: video.owner.image - ? yield* imageUploads.resolveImageUrl(video.owner.image) - : null, - }, - organization: { - organizationMembers: membersList.map((member) => member.userId), - organizationId: video.sharedOrganization?.organizationId ?? undefined, - }, - sharedOrganizations: sharedOrganizations, - password: null, - folderId: null, - orgSettings: video.orgSettings || null, - settings: video.videoSettings || null, - }; - }).pipe(runPromise); - - return ( - <> -
- - - -
- - - ); + // will have already been fetched if auth is required + const user = await getCurrentUser(); + const videoId = video.id; + + if (user && video && user.id !== video.owner.id) { + try { + await createNotification({ + type: "view", + videoId: video.id, + authorId: user.id, + }); + } catch (error) { + console.warn("Failed to create view notification:", error); + } + } + + const userId = user?.id; + const commentId = optionFromTOrFirst(searchParams.comment).pipe( + Option.map(Comment.CommentId.make), + ); + const replyId = optionFromTOrFirst(searchParams.reply).pipe( + Option.map(Comment.CommentId.make), + ); + + // Fetch spaces data for the sharing dialog + let spacesData = null; + if (user) { + try { + const dashboardData = await getDashboardData(user); + spacesData = dashboardData.spacesData; + } catch (error) { + console.error("Failed to fetch spaces data for sharing dialog:", error); + spacesData = []; + } + } + + // Fetch shared spaces data for this video + const sharedSpaces = await getSharedSpacesForVideo(videoId); + + let aiGenerationEnabled = false; + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) + .from(users) + .where(eq(users.id, video.owner.id)) + .limit(1); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { + const videoOwner = videoOwnerQuery[0]; + aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); + } + + if (video.sharedOrganization?.organizationId) { + const organization = await db() + .select() + .from(organizations) + .where(eq(organizations.id, video.sharedOrganization.organizationId)) + .limit(1); + + if (organization[0]?.allowedEmailDomain) { + if ( + !user?.email || + !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) + ) { + console.log( + "[ShareVideoPage] Access denied - domain restriction:", + organization[0].allowedEmailDomain, + ); + return ( +
+

Access Restricted

+

+ This video is only accessible to members of this organization. +

+

+ Please sign in with your organization email address to access this + content. +

+
+ ); + } + } + } + + if ( + video.transcriptionStatus !== "COMPLETE" && + video.transcriptionStatus !== "PROCESSING" + ) { + console.log("[ShareVideoPage] Starting transcription for video:", videoId); + await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); + + const updatedVideoQuery = await db() + .select({ + id: videos.id, + name: videos.name, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + videoSettings: videos.settings, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(eq(videos.id, videoId)) + .execute(); + + if (updatedVideoQuery[0]) { + Object.assign(video, updatedVideoQuery[0]); + console.log( + "[ShareVideoPage] Updated transcription status:", + video.transcriptionStatus, + ); + } + } + + const currentMetadata = (video.metadata as VideoMetadata) || {}; + const metadata = currentMetadata; + let initialAiData = null; + + if (metadata.summary || metadata.chapters || metadata.aiTitle) { + initialAiData = { + title: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + processing: metadata.aiProcessing || false, + }; + } else if (metadata.aiProcessing) { + initialAiData = { + title: null, + summary: null, + chapters: null, + processing: true, + }; + } + + if ( + video.transcriptionStatus === "COMPLETE" && + !currentMetadata.aiProcessing && + !currentMetadata.summary && + !currentMetadata.chapters && + // !currentMetadata.generationError && + aiGenerationEnabled + ) { + try { + generateAiMetadata(videoId, video.owner.id).catch((error) => { + console.error( + `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, + error, + ); + }); + } catch (error) { + console.error( + `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, + error, + ); + } + } + + const customDomainPromise = (async () => { + if (!user) { + return { customDomain: null, domainVerified: false }; + } + const activeOrganizationId = user.activeOrganizationId; + if (!activeOrganizationId) { + return { customDomain: null, domainVerified: false }; + } + + // Fetch the active org + const orgArr = await db() + .select({ + customDomain: organizations.customDomain, + domainVerified: organizations.domainVerified, + }) + .from(organizations) + .where(eq(organizations.id, activeOrganizationId)) + .limit(1); + + const org = orgArr[0]; + if ( + org?.customDomain && + org.domainVerified !== null && + user.id === video.owner.id + ) { + return { customDomain: org.customDomain, domainVerified: true }; + } + return { customDomain: null, domainVerified: false }; + })(); + + const sharedOrganizationsPromise = db() + .select({ id: sharedVideos.organizationId, name: organizations.name }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const userOrganizationsPromise = (async () => { + if (!userId) return []; + + const [ownedOrganizations, memberOrganizations] = await Promise.all([ + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .where(eq(organizations.ownerId, userId)), + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .innerJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId), + ) + .where(eq(organizationMembers.userId, userId)), + ]); + + const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; + const uniqueOrganizationIds = new Set(); + + return allOrganizations.filter((organization) => { + if (uniqueOrganizationIds.has(organization.id)) return false; + uniqueOrganizationIds.add(organization.id); + return true; + }); + })(); + + const membersListPromise = video.sharedOrganization?.organizationId + ? db() + .select({ userId: organizationMembers.userId }) + .from(organizationMembers) + .where( + eq( + organizationMembers.organizationId, + video.sharedOrganization.organizationId, + ), + ) + : Promise.resolve([]); + + const commentsPromise = Effect.gen(function* () { + const db = yield* Database; + const imageUploads = yield* ImageUploads; + + let toplLevelCommentId = Option.none(); + + if (Option.isSome(replyId)) { + const [parentComment] = yield* db.use((db) => + db + .select({ parentCommentId: comments.parentCommentId }) + .from(comments) + .where(eq(comments.id, replyId.value)) + .limit(1), + ); + toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); + } + + const commentToBringToTheTop = Option.orElse( + toplLevelCommentId, + () => commentId, + ); + + return yield* db + .use((db) => + db + .select({ + id: comments.id, + content: comments.content, + timestamp: comments.timestamp, + type: comments.type, + authorId: comments.authorId, + videoId: comments.videoId, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + parentCommentId: comments.parentCommentId, + authorName: users.name, + authorImage: users.image, + }) + .from(comments) + .leftJoin(users, eq(comments.authorId, users.id)) + .where(eq(comments.videoId, videoId)) + .orderBy( + Option.match(commentToBringToTheTop, { + onSome: (commentId) => + sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, + onNone: () => comments.createdAt, + }), + ), + ) + .pipe( + Effect.map((comments) => + comments.map( + Effect.fn(function* (c) { + return Object.assign(c, { + authorImage: yield* Option.fromNullable(c.authorImage).pipe( + Option.map(imageUploads.resolveImageUrl), + Effect.transposeOption, + Effect.map(Option.getOrNull), + ), + }); + }), + ), + ), + Effect.flatMap(Effect.all), + ); + }).pipe(EffectRuntime.runPromise); + + const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); + + const [ + membersList, + userOrganizations, + sharedOrganizations, + { customDomain, domainVerified }, + ] = await Promise.all([ + membersListPromise, + userOrganizationsPromise, + sharedOrganizationsPromise, + customDomainPromise, + ]); + + const videoWithOrganizationInfo = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + return { + ...video, + owner: { + id: video.owner.id, + name: video.owner.name, + isPro: userIsPro(video.owner), + image: video.owner.image + ? yield* imageUploads.resolveImageUrl(video.owner.image) + : null, + }, + organization: { + organizationMembers: membersList.map((member) => member.userId), + organizationId: video.sharedOrganization?.organizationId ?? undefined, + }, + sharedOrganizations: sharedOrganizations, + password: null, + folderId: null, + orgSettings: video.orgSettings || null, + settings: video.videoSettings || null, + }; + }).pipe(runPromise); + + return ( + <> +
+ + + +
+ + + ); } diff --git a/apps/web/public/logos/browsers/maxthron.svg b/apps/web/public/logos/browsers/maxthon.svg similarity index 100% rename from apps/web/public/logos/browsers/maxthron.svg rename to apps/web/public/logos/browsers/maxthon.svg diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index d762b90211..13246de6ae 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -110,22 +110,29 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( VideosGetAnalytics: (videoIds) => videos.getAnalyticsBulk(videoIds).pipe( - Effect.map((results) => - results.map((result) => - Exit.mapError( - Exit.map(result, (v) => ({ count: v.count } as const)), - (error) => { - if (Schema.is(Video.NotFoundError)(error)) return error; - if (Schema.is(Policy.PolicyDeniedError)(error)) return error; - if (Schema.is(Video.VerifyVideoPasswordError)(error)) - return error; - return error as Video.NotFoundError | Policy.PolicyDeniedError | Video.VerifyVideoPasswordError; - }, - ), - ) as readonly Exit.Exit< - { readonly count: number }, - Video.NotFoundError | Policy.PolicyDeniedError | Video.VerifyVideoPasswordError - >[], + Effect.map( + (results) => + results.map((result) => + Exit.mapError( + Exit.map(result, (v) => ({ count: v.count }) as const), + (error) => { + if (Schema.is(Video.NotFoundError)(error)) return error; + if (Schema.is(Policy.PolicyDeniedError)(error)) + return error; + if (Schema.is(Video.VerifyVideoPasswordError)(error)) + return error; + return error as + | Video.NotFoundError + | Policy.PolicyDeniedError + | Video.VerifyVideoPasswordError; + }, + ), + ) as readonly Exit.Exit< + { readonly count: number }, + | Video.NotFoundError + | Policy.PolicyDeniedError + | Video.VerifyVideoPasswordError + >[], ), provideOptionalAuth, Effect.catchTag( diff --git a/scripts/analytics/tinybird/pipes/analytics_sessions_mv_pipe.pipe b/scripts/analytics/tinybird/pipes/analytics_sessions_mv_pipe.pipe index bde6ae5e03..395973045b 100644 --- a/scripts/analytics/tinybird/pipes/analytics_sessions_mv_pipe.pipe +++ b/scripts/analytics/tinybird/pipes/analytics_sessions_mv_pipe.pipe @@ -4,13 +4,14 @@ DESCRIPTION > SQL > SELECT tenant_id, - toDate(timestamp) AS date, + toDate(anyLast(timestamp)) AS date, session_id, - coalesce(nullIf(browser, ''), 'unknown') AS browser, - coalesce(nullIf(device, ''), 'desktop') AS device, - coalesce(nullIf(os, ''), 'unknown') AS os + coalesce(anyLast(nullIf(browser, '')), 'unknown') AS browser, + coalesce(anyLast(nullIf(device, '')), 'desktop') AS device, + coalesce(anyLast(nullIf(os, '')), 'unknown') AS os FROM analytics_events WHERE action = 'page_hit' + GROUP BY tenant_id, session_id TYPE MATERIALIZED DATASOURCE analytics_sessions_mv From 70905266423f7ebf81d953ca3f4050a19089dce4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:23:31 +0000 Subject: [PATCH 22/23] Remove individual analytics page and fix Tinybird usage --- .../(org)/dashboard/analytics/s/[id]/page.tsx | 137 ------------------ apps/web/app/api/analytics/track/route.ts | 3 +- 2 files changed, 2 insertions(+), 138 deletions(-) delete mode 100644 apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx diff --git a/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx b/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx deleted file mode 100644 index 2ecb23c042..0000000000 --- a/apps/web/app/(org)/dashboard/analytics/s/[id]/page.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import Header from "../../components/Header"; -import OtherStats, { type OtherStatsData } from "../../components/OtherStats"; -import StatsChart from "../../components/StatsChart"; - -export default function AnalyticsPage() { - return ( -
-
- - -
- ); -} - -const mockData: OtherStatsData = { - countries: [ - { - countryCode: "US", - name: "United States", - views: "8,452", - comments: "100", - reactions: "100", - percentage: "34.2%", - }, - { - countryCode: "GB", - name: "United Kingdom", - views: "3,891", - comments: "100", - reactions: "100", - percentage: "15.7%", - }, - { - countryCode: "CA", - name: "Canada", - views: "2,764", - comments: "100", - reactions: "100", - percentage: "11.2%", - }, - { - countryCode: "DE", - name: "Germany", - views: "2,143", - comments: "100", - reactions: "100", - percentage: "8.7%", - }, - { - countryCode: "FR", - name: "France", - views: "1,876", - comments: "100", - reactions: "100", - percentage: "7.6%", - }, - { - countryCode: "AU", - name: "Australia", - views: "1,542", - comments: "100", - reactions: "100", - percentage: "6.2%", - }, - ], - cities: [ - { - countryCode: "US", - name: "New York", - views: "3,421", - comments: "100", - reactions: "100", - percentage: "18.7%", - }, - { - countryCode: "US", - name: "Los Angeles", - views: "2,876", - comments: "100", - reactions: "100", - percentage: "15.7%", - }, - { - countryCode: "GB", - name: "London", - views: "2,145", - comments: "100", - reactions: "100", - percentage: "11.7%", - }, - { - countryCode: "CA", - name: "Toronto", - views: "1,892", - comments: "100", - reactions: "100", - percentage: "10.3%", - }, - ], - browsers: [ - { - browser: "google-chrome", - name: "Chrome", - views: "8,452", - comments: "100", - reactions: "100", - percentage: "34.2%", - }, - ], - operatingSystems: [ - { - os: "windows", - name: "Windows", - views: "8,452", - comments: "100", - reactions: "100", - percentage: "34.2%", - }, - ], - deviceTypes: [ - { - device: "desktop", - name: "Desktop", - views: "8,452", - comments: "100", - reactions: "100", - percentage: "34.2%", - }, - ], -}; diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index db7a10142a..e02abd76b3 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -81,7 +81,8 @@ export async function POST(request: NextRequest) { return; } - const tinybird = yield* Effect.service(Tinybird); + // @ts-expect-error - Tinybird service can be yielded directly + const tinybird = yield* Tinybird; yield* tinybird.appendEvents([ { timestamp: timestamp.toISOString(), From 006a668d663a8c1e33ae825d5d26775b8137c98c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:25:30 +0000 Subject: [PATCH 23/23] Remove FiltersList component from analytics dashboard --- .../analytics/components/FiltersList.tsx | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx diff --git a/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx b/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx deleted file mode 100644 index 54302d0f11..0000000000 --- a/apps/web/app/(org)/dashboard/analytics/components/FiltersList.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { CompareDataFilterItem, type FilterValue } from "./CompareFilters"; - -interface FiltersListProps { - filters: readonly FilterValue[]; - isFilterInUse: (value: FilterValue) => boolean; - onFilterDragStart: (value: FilterValue) => void; - onFilterDragEnd: (x: number, y: number) => void; - onFilterDrag: (x: number, y: number) => void; -} - -const FILTER_LABELS: Record = { - views: "Views", - comments: "Comments", - reactions: "Reactions", - shares: "Shares", - downloads: "Downloads", - uploads: "Uploads", - deletions: "Deletions", - creations: "Creations", - edits: "Edits", -}; - -export const FiltersList = ({ - filters, - isFilterInUse, - onFilterDragStart, - onFilterDragEnd, - onFilterDrag, -}: FiltersListProps) => { - return ( -
-
-

- Filters -

-
- - - {filters.map((filter) => ( - onFilterDragStart(filter)} - onDragEnd={onFilterDragEnd} - onDrag={onFilterDrag} - /> - ))} - - -
- ); -};