@refraction-ui/astro 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { cva } from '../shared/index.ts'
2
2
 
3
3
  export const emojiPickerContainerStyles =
4
- 'w-80 rounded-lg border bg-popover text-popover-foreground shadow-md'
4
+ 'w-80 rounded-lg border bg-popover text-popover-foreground shadow-md motion-safe:animate-fade-in-scale origin-top'
5
5
 
6
6
  export const emojiPickerSearchStyles =
7
7
  'w-full border-b px-3 py-2 text-sm bg-transparent outline-none placeholder:text-muted-foreground'
@@ -10,7 +10,7 @@ export const emojiPickerCategoryBarStyles =
10
10
  'flex border-b px-1'
11
11
 
12
12
  export const emojiPickerCategoryTabVariants = cva({
13
- base: 'flex-1 py-1.5 text-center text-lg cursor-pointer hover:bg-accent rounded-md transition-colors',
13
+ base: 'flex-1 py-1.5 text-center text-lg cursor-pointer hover:bg-accent rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
14
14
  variants: {
15
15
  state: {
16
16
  active: 'bg-accent',
@@ -26,7 +26,15 @@ export const emojiPickerGridStyles =
26
26
  'grid grid-cols-8 gap-0.5 p-2 max-h-64 overflow-y-auto'
27
27
 
28
28
  export const emojiPickerEmojiButtonStyles =
29
- 'flex items-center justify-center h-8 w-8 rounded cursor-pointer text-lg hover:bg-accent transition-colors'
29
+ 'flex items-center justify-center h-8 w-8 rounded cursor-pointer text-lg transition-transform duration-100 ease-out hover:bg-accent hover:scale-125 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none motion-reduce:hover:scale-100'
30
30
 
31
31
  export const emojiPickerSectionLabelStyles =
32
32
  'px-2 py-1 text-xs font-medium text-muted-foreground'
33
+
34
+ /** The swappable category/search/sticker panel; fades on category switch. */
35
+ export const emojiPickerPanelStyles =
36
+ 'motion-safe:animate-fade-in origin-top'
37
+
38
+ /** A single sticker cell in the sticker tab. */
39
+ export const emojiPickerStickerButtonStyles =
40
+ 'flex items-center justify-center rounded-lg p-1.5 aspect-square cursor-pointer transition-transform duration-100 ease-out hover:bg-accent hover:scale-110 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none motion-reduce:hover:scale-100'
@@ -9,11 +9,29 @@ export {
9
9
  EMOJI_DATA,
10
10
  EMOJI_CATEGORIES,
11
11
  CATEGORY_LABELS,
12
+ EMOJI_COUNT_TOTAL,
13
+ SHORTCODE_ALIASES,
12
14
  getAllEmojis,
15
+ resolveShortcode,
16
+ buildShortcodeMap,
17
+ searchEmojis,
13
18
  type EmojiCategory,
14
19
  type EmojiEntry,
15
20
  } from './emoji-data.js'
16
21
 
22
+ export {
23
+ twemojiFilename,
24
+ twemojiAssetUrl,
25
+ DEFAULT_TWEMOJI_BASE_URL,
26
+ } from './twemoji.js'
27
+
28
+ export {
29
+ STARTER_STICKER_SET,
30
+ type StickerItem,
31
+ type StickerSet,
32
+ type StickerKind,
33
+ } from './stickers.js'
34
+
17
35
  export {
18
36
  emojiPickerContainerStyles,
19
37
  emojiPickerSearchStyles,
@@ -22,4 +40,6 @@ export {
22
40
  emojiPickerGridStyles,
23
41
  emojiPickerEmojiButtonStyles,
24
42
  emojiPickerSectionLabelStyles,
43
+ emojiPickerPanelStyles,
44
+ emojiPickerStickerButtonStyles,
25
45
  } from './emoji-picker.styles.js'
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Sticker seam — types + a small bundled starter pack.
3
+ *
4
+ * Stickers are host-extensible: a consumer (or a future bundled pack) supplies
5
+ * `StickerSet`s and the picker renders them in a dedicated tab. Three render
6
+ * paths are supported so the API is ready for animated content without any
7
+ * heavy dependency in the core:
8
+ * - `kind: 'svg'` — inline SVG markup (can self-animate via SMIL, no JS).
9
+ * - `kind: 'image'` — a URL to a static or animated (WebP/GIF/APNG) asset.
10
+ * - `kind: 'lottie'`— a URL/JSON handed to a host-provided Lottie player.
11
+ *
12
+ * The starter pack below ships a handful of simple, consistent flat stickers
13
+ * (128×128, one visual language) so the tab is not empty out of the box, and
14
+ * proves the animated render path with a self-animating SMIL heart — no Lottie
15
+ * runtime required.
16
+ */
17
+
18
+ export type StickerKind = 'svg' | 'image' | 'lottie'
19
+
20
+ export interface StickerItem {
21
+ /** Stable id (unique within its set). */
22
+ id: string
23
+ /** Accessible label / alt text. */
24
+ label: string
25
+ kind: StickerKind
26
+ /**
27
+ * The sticker payload: inline SVG markup (`svg`), an asset URL (`image`), or
28
+ * a Lottie JSON URL or serialized object (`lottie`).
29
+ */
30
+ source: string
31
+ /** Search keywords. */
32
+ keywords?: string[]
33
+ /** True when the sticker animates (drives reduced-motion handling). */
34
+ animated?: boolean
35
+ }
36
+
37
+ export interface StickerSet {
38
+ /** Stable id (unique across sets). */
39
+ id: string
40
+ /** Human-readable set name (tab tooltip). */
41
+ label: string
42
+ /** A representative emoji/char for the set's tab icon. */
43
+ icon?: string
44
+ stickers: StickerItem[]
45
+ }
46
+
47
+ const svg = (inner: string): string =>
48
+ `<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">${inner}</svg>`
49
+
50
+ /**
51
+ * Refraction starter sticker pack — a tasteful, consistent flat set. The
52
+ * `pulse-heart` sticker self-animates via SMIL to demonstrate the animated
53
+ * render path; the rest are static.
54
+ */
55
+ export const STARTER_STICKER_SET: StickerSet = {
56
+ id: 'refraction-starter',
57
+ label: 'Refraction',
58
+ icon: '⭐',
59
+ stickers: [
60
+ {
61
+ id: 'pulse-heart',
62
+ label: 'Pulsing heart',
63
+ kind: 'svg',
64
+ animated: true,
65
+ keywords: ['heart', 'love', 'like'],
66
+ source: svg(
67
+ `<g fill="#ff5a7a"><path d="M64 112C34 90 16 72 16 50a26 26 0 0 1 48-14 26 26 0 0 1 48 14c0 22-18 40-48 62Z">` +
68
+ `<animateTransform attributeName="transform" type="scale" additive="sum" values="1;1.08;1" dur="1s" repeatCount="indefinite" calcMode="spline" keySplines="0.4 0 0.2 1;0.4 0 0.2 1" keyTimes="0;0.5;1"/>` +
69
+ `</path></g>`,
70
+ ),
71
+ },
72
+ {
73
+ id: 'star',
74
+ label: 'Gold star',
75
+ kind: 'svg',
76
+ keywords: ['star', 'favorite', 'award'],
77
+ source: svg(
78
+ `<path fill="#ffc93c" d="M64 14l14 30 33 4-24 23 6 33-29-16-29 16 6-33-24-23 33-4z"/>`,
79
+ ),
80
+ },
81
+ {
82
+ id: 'thumbs-up',
83
+ label: 'Thumbs up',
84
+ kind: 'svg',
85
+ keywords: ['thumbsup', 'like', 'yes', 'approve'],
86
+ source: svg(
87
+ `<g fill="#4c9aff"><rect x="20" y="56" width="20" height="50" rx="5"/>` +
88
+ `<path d="M48 56l16-34c9 0 14 7 12 18l-3 14h26c8 0 13 8 10 16l-9 26c-2 6-8 10-14 10H48Z"/></g>`,
89
+ ),
90
+ },
91
+ {
92
+ id: 'party',
93
+ label: 'Party popper',
94
+ kind: 'svg',
95
+ keywords: ['party', 'celebrate', 'tada', 'confetti'],
96
+ source: svg(
97
+ `<path fill="#ff8a3d" d="M18 110L52 44l32 32z"/>` +
98
+ `<circle cx="92" cy="30" r="6" fill="#ff5a7a"/><circle cx="110" cy="52" r="5" fill="#ffc93c"/>` +
99
+ `<circle cx="70" cy="20" r="5" fill="#4c9aff"/><circle cx="104" cy="82" r="6" fill="#5ad19a"/>`,
100
+ ),
101
+ },
102
+ {
103
+ id: 'smiley',
104
+ label: 'Smiley blob',
105
+ kind: 'svg',
106
+ keywords: ['happy', 'smile', 'face'],
107
+ source: svg(
108
+ `<circle cx="64" cy="64" r="46" fill="#ffd23f"/>` +
109
+ `<circle cx="48" cy="56" r="6" fill="#3a2d00"/><circle cx="80" cy="56" r="6" fill="#3a2d00"/>` +
110
+ `<path d="M44 78a24 24 0 0 0 40 0" stroke="#3a2d00" stroke-width="6" fill="none" stroke-linecap="round"/>`,
111
+ ),
112
+ },
113
+ {
114
+ id: 'fire',
115
+ label: 'Fire',
116
+ kind: 'svg',
117
+ keywords: ['fire', 'hot', 'lit'],
118
+ source: svg(
119
+ `<path fill="#ff6b35" d="M64 12c8 22-14 26-14 46a14 14 0 0 0 28 0c0-6-2-10-2-14 10 6 16 18 16 30a28 28 0 0 1-56 0c0-30 20-40 28-62Z"/>` +
120
+ `<path fill="#ffd23f" d="M64 62c4 8-6 10-6 20a10 10 0 0 0 20 0c0-8-8-12-14-20Z"/>`,
121
+ ),
122
+ },
123
+ {
124
+ id: 'check',
125
+ label: 'Check',
126
+ kind: 'svg',
127
+ keywords: ['check', 'done', 'ok', 'yes'],
128
+ source: svg(
129
+ `<circle cx="64" cy="64" r="46" fill="#5ad19a"/>` +
130
+ `<path d="M42 66l14 14 30-32" stroke="#fff" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
131
+ ),
132
+ },
133
+ {
134
+ id: 'heart-eyes',
135
+ label: 'Love it',
136
+ kind: 'svg',
137
+ keywords: ['love', 'heart', 'adore'],
138
+ source: svg(
139
+ `<circle cx="64" cy="64" r="46" fill="#ffd23f"/>` +
140
+ `<path fill="#ff5a7a" d="M40 50c5-6 13-2 13 4 0 5-6 9-13 14-7-5-13-9-13-14 0-6 8-10 13-4Z"/>` +
141
+ `<path fill="#ff5a7a" d="M88 50c5-6 13-2 13 4 0 5-6 9-13 14-7-5-13-9-13-14 0-6 8-10 13-4Z"/>` +
142
+ `<path d="M44 84a24 24 0 0 0 40 0" stroke="#3a2d00" stroke-width="6" fill="none" stroke-linecap="round"/>`,
143
+ ),
144
+ },
145
+ ],
146
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Twemoji asset resolution — maps a native emoji glyph to its Twemoji SVG
3
+ * filename/URL. This is the data half of the `emojiRenderer` seam: it lets the
4
+ * React/Astro adapters render a UNIFORM emoji set (one look on every OS) instead
5
+ * of per-platform native glyphs, WhatsApp-style, by lazily loading one small SVG
6
+ * per glyph from a CDN (or a self-hosted mirror) — nothing is bundled eagerly.
7
+ *
8
+ * Twemoji graphics are © Twitter / the Twemoji contributors, licensed CC-BY 4.0.
9
+ * Consumers rendering with Twemoji must preserve that attribution (see the
10
+ * package README / NOTICE).
11
+ *
12
+ * The filename algorithm mirrors upstream `twemoji.toCodePoint`: surrogate
13
+ * pairs are combined to codepoints, and the U+FE0F variation selector is
14
+ * stripped from non-ZWJ sequences (Twemoji names files without it).
15
+ */
16
+
17
+ /** Default CDN base for Twemoji SVG assets (jsDelivr, pinned). */
18
+ export const DEFAULT_TWEMOJI_BASE_URL =
19
+ 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/'
20
+
21
+ const ZWJ = '‍'
22
+ const VARIATION_SELECTOR_16 = /️/g
23
+
24
+ /** Combine surrogate pairs into dash-joined lowercase hex codepoints. */
25
+ function toCodePoint(input: string): string {
26
+ const parts: string[] = []
27
+ let pending = 0
28
+ let i = 0
29
+ while (i < input.length) {
30
+ const c = input.charCodeAt(i++)
31
+ if (pending) {
32
+ parts.push((0x10000 + ((pending - 0xd800) << 10) + (c - 0xdc00)).toString(16))
33
+ pending = 0
34
+ } else if (c >= 0xd800 && c <= 0xdbff) {
35
+ pending = c
36
+ } else {
37
+ parts.push(c.toString(16))
38
+ }
39
+ }
40
+ return parts.join('-')
41
+ }
42
+
43
+ /**
44
+ * Twemoji asset filename (without extension) for a glyph, e.g. `1f600`,
45
+ * `1f1fa-1f1f8`, `2764` (heart, FE0F stripped).
46
+ */
47
+ export function twemojiFilename(emoji: string): string {
48
+ // Keep FE0F only for ZWJ sequences, matching upstream Twemoji naming.
49
+ const normalized = emoji.indexOf(ZWJ) < 0 ? emoji.replace(VARIATION_SELECTOR_16, '') : emoji
50
+ return toCodePoint(normalized)
51
+ }
52
+
53
+ /** Full Twemoji SVG URL for a glyph. */
54
+ export function twemojiAssetUrl(
55
+ emoji: string,
56
+ baseUrl: string = DEFAULT_TWEMOJI_BASE_URL,
57
+ ): string {
58
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
59
+ return `${base}${twemojiFilename(emoji)}.svg`
60
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Country type and registry for i18n support.
3
+ */
4
+
5
+ export interface Country {
6
+ code: string
7
+ name: string
8
+ flag: string
9
+ defaultLanguages: string[]
10
+ timezone?: string
11
+ }
12
+
13
+ export const COUNTRIES: Record<string, Country> = {
14
+ // North America
15
+ 'US': { code: 'US', name: 'United States', flag: '🇺🇸', defaultLanguages: ['en'], timezone: 'America/New_York' },
16
+ 'CA': { code: 'CA', name: 'Canada', flag: '🇨🇦', defaultLanguages: ['en', 'fr'], timezone: 'America/Toronto' },
17
+ 'MX': { code: 'MX', name: 'Mexico', flag: '🇲🇽', defaultLanguages: ['es'], timezone: 'America/Mexico_City' },
18
+
19
+ // UK & Ireland
20
+ 'GB': { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', defaultLanguages: ['en'], timezone: 'Europe/London' },
21
+ 'IE': { code: 'IE', name: 'Ireland', flag: '🇮🇪', defaultLanguages: ['en'], timezone: 'Europe/Dublin' },
22
+
23
+ // Oceania
24
+ 'AU': { code: 'AU', name: 'Australia', flag: '🇦🇺', defaultLanguages: ['en'], timezone: 'Australia/Sydney' },
25
+ 'NZ': { code: 'NZ', name: 'New Zealand', flag: '🇳🇿', defaultLanguages: ['en'], timezone: 'Pacific/Auckland' },
26
+
27
+ // South Asia
28
+ 'IN': { code: 'IN', name: 'India', flag: '🇮🇳', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kolkata' },
29
+ 'PK': { code: 'PK', name: 'Pakistan', flag: '🇵🇰', defaultLanguages: ['ur', 'en'], timezone: 'Asia/Karachi' },
30
+ 'BD': { code: 'BD', name: 'Bangladesh', flag: '🇧🇩', defaultLanguages: ['bn'], timezone: 'Asia/Dhaka' },
31
+ 'LK': { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰', defaultLanguages: ['ta', 'en'], timezone: 'Asia/Colombo' },
32
+ 'NP': { code: 'NP', name: 'Nepal', flag: '🇳🇵', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kathmandu' },
33
+
34
+ // Western Europe
35
+ 'FR': { code: 'FR', name: 'France', flag: '🇫🇷', defaultLanguages: ['fr'], timezone: 'Europe/Paris' },
36
+ 'DE': { code: 'DE', name: 'Germany', flag: '🇩🇪', defaultLanguages: ['de'], timezone: 'Europe/Berlin' },
37
+ 'ES': { code: 'ES', name: 'Spain', flag: '🇪🇸', defaultLanguages: ['es'], timezone: 'Europe/Madrid' },
38
+ 'IT': { code: 'IT', name: 'Italy', flag: '🇮🇹', defaultLanguages: ['it'], timezone: 'Europe/Rome' },
39
+ 'PT': { code: 'PT', name: 'Portugal', flag: '🇵🇹', defaultLanguages: ['pt'], timezone: 'Europe/Lisbon' },
40
+ 'NL': { code: 'NL', name: 'Netherlands', flag: '🇳🇱', defaultLanguages: ['nl'], timezone: 'Europe/Amsterdam' },
41
+ 'BE': { code: 'BE', name: 'Belgium', flag: '🇧🇪', defaultLanguages: ['nl', 'fr', 'de'], timezone: 'Europe/Brussels' },
42
+ 'CH': { code: 'CH', name: 'Switzerland', flag: '🇨🇭', defaultLanguages: ['de', 'fr', 'it'], timezone: 'Europe/Zurich' },
43
+ 'AT': { code: 'AT', name: 'Austria', flag: '🇦🇹', defaultLanguages: ['de'], timezone: 'Europe/Vienna' },
44
+
45
+ // Nordic
46
+ 'SE': { code: 'SE', name: 'Sweden', flag: '🇸🇪', defaultLanguages: ['sv'], timezone: 'Europe/Stockholm' },
47
+ 'NO': { code: 'NO', name: 'Norway', flag: '🇳🇴', defaultLanguages: ['no'], timezone: 'Europe/Oslo' },
48
+ 'DK': { code: 'DK', name: 'Denmark', flag: '🇩🇰', defaultLanguages: ['da'], timezone: 'Europe/Copenhagen' },
49
+ 'FI': { code: 'FI', name: 'Finland', flag: '🇫🇮', defaultLanguages: ['fi'], timezone: 'Europe/Helsinki' },
50
+
51
+ // Eastern Europe
52
+ 'PL': { code: 'PL', name: 'Poland', flag: '🇵🇱', defaultLanguages: ['pl'], timezone: 'Europe/Warsaw' },
53
+ 'CZ': { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿', defaultLanguages: ['cs'], timezone: 'Europe/Prague' },
54
+ 'RO': { code: 'RO', name: 'Romania', flag: '🇷🇴', defaultLanguages: ['ro'], timezone: 'Europe/Bucharest' },
55
+ 'HU': { code: 'HU', name: 'Hungary', flag: '🇭🇺', defaultLanguages: ['hu'], timezone: 'Europe/Budapest' },
56
+ 'BG': { code: 'BG', name: 'Bulgaria', flag: '🇧🇬', defaultLanguages: ['bg'], timezone: 'Europe/Sofia' },
57
+ 'HR': { code: 'HR', name: 'Croatia', flag: '🇭🇷', defaultLanguages: ['hr'], timezone: 'Europe/Zagreb' },
58
+ 'SK': { code: 'SK', name: 'Slovakia', flag: '🇸🇰', defaultLanguages: ['sk'], timezone: 'Europe/Bratislava' },
59
+ 'SI': { code: 'SI', name: 'Slovenia', flag: '🇸🇮', defaultLanguages: ['sl'], timezone: 'Europe/Ljubljana' },
60
+ 'RS': { code: 'RS', name: 'Serbia', flag: '🇷🇸', defaultLanguages: ['sr'], timezone: 'Europe/Belgrade' },
61
+ 'UA': { code: 'UA', name: 'Ukraine', flag: '🇺🇦', defaultLanguages: ['uk'], timezone: 'Europe/Kyiv' },
62
+ 'RU': { code: 'RU', name: 'Russia', flag: '🇷🇺', defaultLanguages: ['ru'], timezone: 'Europe/Moscow' },
63
+ 'TR': { code: 'TR', name: 'Turkey', flag: '🇹🇷', defaultLanguages: ['tr'], timezone: 'Europe/Istanbul' },
64
+ 'GR': { code: 'GR', name: 'Greece', flag: '🇬🇷', defaultLanguages: ['el'], timezone: 'Europe/Athens' },
65
+
66
+ // East Asia
67
+ 'JP': { code: 'JP', name: 'Japan', flag: '🇯🇵', defaultLanguages: ['ja'], timezone: 'Asia/Tokyo' },
68
+ 'KR': { code: 'KR', name: 'South Korea', flag: '🇰🇷', defaultLanguages: ['ko'], timezone: 'Asia/Seoul' },
69
+ 'CN': { code: 'CN', name: 'China', flag: '🇨🇳', defaultLanguages: ['zh'], timezone: 'Asia/Shanghai' },
70
+ 'TW': { code: 'TW', name: 'Taiwan', flag: '🇹🇼', defaultLanguages: ['zh'], timezone: 'Asia/Taipei' },
71
+
72
+ // Southeast Asia
73
+ 'TH': { code: 'TH', name: 'Thailand', flag: '🇹🇭', defaultLanguages: ['th'], timezone: 'Asia/Bangkok' },
74
+ 'VN': { code: 'VN', name: 'Vietnam', flag: '🇻🇳', defaultLanguages: ['vi'], timezone: 'Asia/Ho_Chi_Minh' },
75
+ 'ID': { code: 'ID', name: 'Indonesia', flag: '🇮🇩', defaultLanguages: ['id'], timezone: 'Asia/Jakarta' },
76
+ 'MY': { code: 'MY', name: 'Malaysia', flag: '🇲🇾', defaultLanguages: ['ms', 'en'], timezone: 'Asia/Kuala_Lumpur' },
77
+ 'PH': { code: 'PH', name: 'Philippines', flag: '🇵🇭', defaultLanguages: ['en'], timezone: 'Asia/Manila' },
78
+ 'SG': { code: 'SG', name: 'Singapore', flag: '🇸🇬', defaultLanguages: ['en', 'ms', 'zh'], timezone: 'Asia/Singapore' },
79
+
80
+ // South America
81
+ 'BR': { code: 'BR', name: 'Brazil', flag: '🇧🇷', defaultLanguages: ['pt'], timezone: 'America/Sao_Paulo' },
82
+ 'AR': { code: 'AR', name: 'Argentina', flag: '🇦🇷', defaultLanguages: ['es'], timezone: 'America/Argentina/Buenos_Aires' },
83
+ 'CO': { code: 'CO', name: 'Colombia', flag: '🇨🇴', defaultLanguages: ['es'], timezone: 'America/Bogota' },
84
+ 'CL': { code: 'CL', name: 'Chile', flag: '🇨🇱', defaultLanguages: ['es'], timezone: 'America/Santiago' },
85
+
86
+ // Middle East & North Africa
87
+ 'SA': { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦', defaultLanguages: ['ar'], timezone: 'Asia/Riyadh' },
88
+ 'AE': { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪', defaultLanguages: ['ar', 'en'], timezone: 'Asia/Dubai' },
89
+ 'IL': { code: 'IL', name: 'Israel', flag: '🇮🇱', defaultLanguages: ['he'], timezone: 'Asia/Jerusalem' },
90
+ 'EG': { code: 'EG', name: 'Egypt', flag: '🇪🇬', defaultLanguages: ['ar'], timezone: 'Africa/Cairo' },
91
+ }
92
+
93
+ /**
94
+ * Get a country by its code.
95
+ */
96
+ export function getCountry(code: string): Country | undefined {
97
+ return COUNTRIES[code]
98
+ }
99
+
100
+ /**
101
+ * Get the flag emoji for a country code.
102
+ * Returns empty string if the code is not found.
103
+ */
104
+ export function getCountryFlag(code: string): string {
105
+ return COUNTRIES[code]?.flag ?? ''
106
+ }
107
+
108
+ /**
109
+ * Get the display name for a country code.
110
+ * Returns empty string if the code is not found.
111
+ */
112
+ export function getCountryName(code: string): string {
113
+ return COUNTRIES[code]?.name ?? ''
114
+ }
115
+
116
+ /**
117
+ * Get default languages for a country.
118
+ * Returns empty array if the country is not found.
119
+ */
120
+ export function getDefaultLanguagesForCountry(code: string): string[] {
121
+ return COUNTRIES[code]?.defaultLanguages ?? []
122
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Region detection utilities.
3
+ * Detects user country and language from browser/environment signals.
4
+ */
5
+
6
+ /** Mapping of IANA timezone identifiers to ISO country codes */
7
+ export const TIMEZONE_TO_COUNTRY: Record<string, string> = {
8
+ // United States
9
+ 'America/New_York': 'US',
10
+ 'America/Chicago': 'US',
11
+ 'America/Denver': 'US',
12
+ 'America/Los_Angeles': 'US',
13
+ 'America/Phoenix': 'US',
14
+ 'America/Anchorage': 'US',
15
+ 'Pacific/Honolulu': 'US',
16
+ 'America/Detroit': 'US',
17
+ 'America/Indiana/Indianapolis': 'US',
18
+ 'America/Boise': 'US',
19
+
20
+ // Canada
21
+ 'America/Toronto': 'CA',
22
+ 'America/Vancouver': 'CA',
23
+ 'America/Edmonton': 'CA',
24
+ 'America/Winnipeg': 'CA',
25
+ 'America/Halifax': 'CA',
26
+ 'America/St_Johns': 'CA',
27
+
28
+ // Mexico
29
+ 'America/Mexico_City': 'MX',
30
+ 'America/Cancun': 'MX',
31
+ 'America/Tijuana': 'MX',
32
+
33
+ // UK & Ireland
34
+ 'Europe/London': 'GB',
35
+ 'Europe/Dublin': 'IE',
36
+
37
+ // Western Europe
38
+ 'Europe/Paris': 'FR',
39
+ 'Europe/Berlin': 'DE',
40
+ 'Europe/Madrid': 'ES',
41
+ 'Europe/Rome': 'IT',
42
+ 'Europe/Lisbon': 'PT',
43
+ 'Europe/Amsterdam': 'NL',
44
+ 'Europe/Brussels': 'BE',
45
+ 'Europe/Zurich': 'CH',
46
+ 'Europe/Vienna': 'AT',
47
+
48
+ // Nordic
49
+ 'Europe/Stockholm': 'SE',
50
+ 'Europe/Oslo': 'NO',
51
+ 'Europe/Copenhagen': 'DK',
52
+ 'Europe/Helsinki': 'FI',
53
+
54
+ // Eastern Europe
55
+ 'Europe/Warsaw': 'PL',
56
+ 'Europe/Prague': 'CZ',
57
+ 'Europe/Bucharest': 'RO',
58
+ 'Europe/Budapest': 'HU',
59
+ 'Europe/Sofia': 'BG',
60
+ 'Europe/Zagreb': 'HR',
61
+ 'Europe/Bratislava': 'SK',
62
+ 'Europe/Ljubljana': 'SI',
63
+ 'Europe/Belgrade': 'RS',
64
+ 'Europe/Kyiv': 'UA',
65
+ 'Europe/Moscow': 'RU',
66
+ 'Europe/Istanbul': 'TR',
67
+ 'Europe/Athens': 'GR',
68
+
69
+ // South Asia
70
+ 'Asia/Kolkata': 'IN',
71
+ 'Asia/Calcutta': 'IN',
72
+ 'Asia/Karachi': 'PK',
73
+ 'Asia/Dhaka': 'BD',
74
+ 'Asia/Colombo': 'LK',
75
+ 'Asia/Kathmandu': 'NP',
76
+
77
+ // East Asia
78
+ 'Asia/Tokyo': 'JP',
79
+ 'Asia/Seoul': 'KR',
80
+ 'Asia/Shanghai': 'CN',
81
+ 'Asia/Hong_Kong': 'CN',
82
+ 'Asia/Taipei': 'TW',
83
+
84
+ // Southeast Asia
85
+ 'Asia/Bangkok': 'TH',
86
+ 'Asia/Ho_Chi_Minh': 'VN',
87
+ 'Asia/Jakarta': 'ID',
88
+ 'Asia/Kuala_Lumpur': 'MY',
89
+ 'Asia/Manila': 'PH',
90
+ 'Asia/Singapore': 'SG',
91
+
92
+ // Oceania
93
+ 'Australia/Sydney': 'AU',
94
+ 'Australia/Melbourne': 'AU',
95
+ 'Australia/Brisbane': 'AU',
96
+ 'Australia/Perth': 'AU',
97
+ 'Australia/Adelaide': 'AU',
98
+ 'Pacific/Auckland': 'NZ',
99
+
100
+ // South America
101
+ 'America/Sao_Paulo': 'BR',
102
+ 'America/Rio_Branco': 'BR',
103
+ 'America/Argentina/Buenos_Aires': 'AR',
104
+ 'America/Bogota': 'CO',
105
+ 'America/Santiago': 'CL',
106
+
107
+ // Middle East
108
+ 'Asia/Riyadh': 'SA',
109
+ 'Asia/Dubai': 'AE',
110
+ 'Asia/Jerusalem': 'IL',
111
+ 'Africa/Cairo': 'EG',
112
+ }
113
+
114
+ /** Mapping of BCP-47 locale strings to ISO country codes */
115
+ export const LOCALE_TO_COUNTRY: Record<string, string> = {
116
+ 'en-US': 'US',
117
+ 'en-GB': 'GB',
118
+ 'en-AU': 'AU',
119
+ 'en-CA': 'CA',
120
+ 'en-NZ': 'NZ',
121
+ 'en-IE': 'IE',
122
+ 'en-IN': 'IN',
123
+ 'en-SG': 'SG',
124
+ 'en-PH': 'PH',
125
+ 'en-MY': 'MY',
126
+ 'en-ZA': 'ZA',
127
+ 'hi-IN': 'IN',
128
+ 'te-IN': 'IN',
129
+ 'ta-IN': 'IN',
130
+ 'ur-PK': 'PK',
131
+ 'pa-IN': 'IN',
132
+ 'bn-IN': 'IN',
133
+ 'bn-BD': 'BD',
134
+ 'gu-IN': 'IN',
135
+ 'mr-IN': 'IN',
136
+ 'kn-IN': 'IN',
137
+ 'ml-IN': 'IN',
138
+ 'fr-FR': 'FR',
139
+ 'fr-CA': 'CA',
140
+ 'fr-BE': 'BE',
141
+ 'fr-CH': 'CH',
142
+ 'es-ES': 'ES',
143
+ 'es-MX': 'MX',
144
+ 'es-AR': 'AR',
145
+ 'es-CO': 'CO',
146
+ 'es-CL': 'CL',
147
+ 'de-DE': 'DE',
148
+ 'de-AT': 'AT',
149
+ 'de-CH': 'CH',
150
+ 'it-IT': 'IT',
151
+ 'it-CH': 'CH',
152
+ 'pt-PT': 'PT',
153
+ 'pt-BR': 'BR',
154
+ 'nl-NL': 'NL',
155
+ 'nl-BE': 'BE',
156
+ 'sv-SE': 'SE',
157
+ 'da-DK': 'DK',
158
+ 'nb-NO': 'NO',
159
+ 'nn-NO': 'NO',
160
+ 'fi-FI': 'FI',
161
+ 'pl-PL': 'PL',
162
+ 'cs-CZ': 'CZ',
163
+ 'ro-RO': 'RO',
164
+ 'hu-HU': 'HU',
165
+ 'bg-BG': 'BG',
166
+ 'hr-HR': 'HR',
167
+ 'sk-SK': 'SK',
168
+ 'sl-SI': 'SI',
169
+ 'sr-RS': 'SR',
170
+ 'uk-UA': 'UA',
171
+ 'el-GR': 'GR',
172
+ 'ru-RU': 'RU',
173
+ 'ja-JP': 'JP',
174
+ 'ko-KR': 'KR',
175
+ 'zh-CN': 'CN',
176
+ 'zh-TW': 'TW',
177
+ 'zh-HK': 'CN',
178
+ 'ar-SA': 'SA',
179
+ 'ar-AE': 'AE',
180
+ 'ar-EG': 'EG',
181
+ 'he-IL': 'IL',
182
+ 'tr-TR': 'TR',
183
+ 'th-TH': 'TH',
184
+ 'vi-VN': 'VN',
185
+ 'id-ID': 'ID',
186
+ 'ms-MY': 'MY',
187
+ }
188
+
189
+ /**
190
+ * Detect the user's country from environment signals.
191
+ * Strategy: timezone mapping -> navigator.languages locale mapping -> fallback 'US'
192
+ */
193
+ export function detectCountry(): string {
194
+ // 1. Try timezone mapping
195
+ try {
196
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
197
+ if (tz && TIMEZONE_TO_COUNTRY[tz]) {
198
+ return TIMEZONE_TO_COUNTRY[tz]
199
+ }
200
+ } catch {
201
+ // Intl may not be available
202
+ }
203
+
204
+ // 2. Try navigator.languages locale mapping
205
+ try {
206
+ if (typeof navigator !== 'undefined') {
207
+ const languages = navigator.languages
208
+ if (languages && languages.length > 0) {
209
+ for (const locale of languages) {
210
+ if (LOCALE_TO_COUNTRY[locale]) {
211
+ return LOCALE_TO_COUNTRY[locale]
212
+ }
213
+ }
214
+ }
215
+ }
216
+ } catch {
217
+ // navigator may not be available
218
+ }
219
+
220
+ // 3. Fallback
221
+ return 'US'
222
+ }
223
+
224
+ /**
225
+ * Detect the user's preferred language from navigator.languages.
226
+ * Returns the first language from navigator.languages, or 'en' as fallback.
227
+ */
228
+ export function detectLanguage(): string {
229
+ try {
230
+ if (typeof navigator !== 'undefined') {
231
+ const languages = (navigator as any).languages
232
+ if (languages && languages.length > 0) {
233
+ return languages[0]
234
+ }
235
+ if ((navigator as any).language) {
236
+ return (navigator as any).language
237
+ }
238
+ }
239
+ } catch {
240
+ // navigator may not be available
241
+ }
242
+ return 'en'
243
+ }