summaryrefslogtreecommitdiff
path: root/apps/web/src/stickers
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/stickers')
-rw-r--r--apps/web/src/stickers/__tests__/sticker-id.test.ts34
-rw-r--r--apps/web/src/stickers/categories.ts7
-rw-r--r--apps/web/src/stickers/components/assets-view.tsx455
-rw-r--r--apps/web/src/stickers/index.ts317
-rw-r--r--apps/web/src/stickers/intrinsic-size.ts1
-rw-r--r--apps/web/src/stickers/providers/countries-data.ts1819
-rw-r--r--apps/web/src/stickers/providers/flags.ts202
-rw-r--r--apps/web/src/stickers/providers/index.ts24
-rw-r--r--apps/web/src/stickers/providers/logos.ts31
-rw-r--r--apps/web/src/stickers/providers/shapes.ts206
-rw-r--r--apps/web/src/stickers/registry.ts10
-rw-r--r--apps/web/src/stickers/resolver.ts20
-rw-r--r--apps/web/src/stickers/sticker-id.ts34
-rw-r--r--apps/web/src/stickers/stickers-store.ts238
-rw-r--r--apps/web/src/stickers/types.ts71
15 files changed, 3469 insertions, 0 deletions
diff --git a/apps/web/src/stickers/__tests__/sticker-id.test.ts b/apps/web/src/stickers/__tests__/sticker-id.test.ts
new file mode 100644
index 00000000..26458a7c
--- /dev/null
+++ b/apps/web/src/stickers/__tests__/sticker-id.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+
+describe("sticker-id strict mode", () => {
+ test("parses provider-prefixed IDs", () => {
+ expect(parseStickerId({ stickerId: "flags:US" })).toEqual({
+ providerId: "flags",
+ providerValue: "US",
+ });
+ expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({
+ providerId: "shapes",
+ providerValue: "circle",
+ });
+ });
+
+ test("throws for IDs without provider prefix", () => {
+ expect(() => parseStickerId({ stickerId: "home" })).toThrow();
+ });
+
+ test("throws for malformed IDs", () => {
+ expect(() => parseStickerId({ stickerId: "" })).toThrow();
+ expect(() => parseStickerId({ stickerId: "flags:" })).toThrow();
+ expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow();
+ });
+
+ test("builds sticker IDs unchanged", () => {
+ expect(
+ buildStickerId({
+ providerId: "flags",
+ providerValue: "US",
+ }),
+ ).toBe("flags:US");
+ });
+});
diff --git a/apps/web/src/stickers/categories.ts b/apps/web/src/stickers/categories.ts
new file mode 100644
index 00000000..e8a8b71b
--- /dev/null
+++ b/apps/web/src/stickers/categories.ts
@@ -0,0 +1,7 @@
+export const STICKER_CATEGORIES = {
+ all: "All",
+ // v0.4.0
+ // logos: "Logos",
+ flags: "Flags",
+ shapes: "Shapes",
+};
diff --git a/apps/web/src/stickers/components/assets-view.tsx b/apps/web/src/stickers/components/assets-view.tsx
new file mode 100644
index 00000000..aab84c12
--- /dev/null
+++ b/apps/web/src/stickers/components/assets-view.tsx
@@ -0,0 +1,455 @@
+"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useEditor } from "@/editor/use-editor";
+import { resolveStickerIntrinsicSize } from "@/stickers";
+import {
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/stickers/categories";
+import { getRegionLabel, resolveQueryToRegions } from "@/stickers";
+import { parseShapeStickerId } from "@/stickers/providers/shapes";
+import type { TimelineDragData } from "@/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
+} from "@/stickers";
+import { useStickersStore } from "@/stickers/stickers-store";
+import { cn } from "@/utils/ui";
+import {
+ HappyIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function StickersView() {
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
+
+ return (
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <Tabs
+ value={selectedCategory}
+ onValueChange={(value) => {
+ setSelectedCategory({ category: value as StickerCategory });
+ }}
+ variant="underline"
+ className="mt-2 flex min-h-0 flex-1 flex-col"
+ >
+ <TabsList aria-label="Sticker categories">
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
+ <TabsTrigger key={key} value={key}>
+ {label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </Tabs>
+ </div>
+ );
+}
+
+function StickerGrid({
+ items,
+ shouldCapSize = false,
+}: {
+ items: StickerData[];
+ shouldCapSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: shouldCapSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
+ ))}
+ </div>
+ );
+}
+
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={HappyIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-muted-foreground text-sm text-balance">{message}</p>
+ </div>
+ </div>
+ );
+}
+
+function RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
+function StickersContentView() {
+ const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
+ searchQuery,
+ searchResults,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
+
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
+ }
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
+ </div>
+ </div>
+ )}
+
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
+ )}
+ </div>
+ );
+}
+
+interface StickerItemProps {
+ item: StickerData;
+ shouldCapSize?: boolean;
+ containerClassName?: string;
+}
+
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
+ const [hasImageError, setHasImageError] = useState(false);
+
+ useEffect(() => {
+ if (!item.id) {
+ return;
+ }
+
+ setHasImageError(false);
+ }, [item.id]);
+
+ const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
+
+ const handleAdd = async () => {
+ setIsAdding(true);
+ try {
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
+ });
+
+ addToRecentStickers({ stickerId: item.id });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
+ </div>
+ );
+
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
+ return (
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={dragData}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName={containerClassName ?? "w-full"}
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/stickers/index.ts b/apps/web/src/stickers/index.ts
new file mode 100644
index 00000000..d41e277d
--- /dev/null
+++ b/apps/web/src/stickers/index.ts
@@ -0,0 +1,317 @@
+import {
+ STICKER_CATEGORIES,
+} from "@/stickers/categories";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
+import type { StickerCategory } from "@/stickers/types";
+import { stickersRegistry } from "./registry";
+import { resolveStickerId } from "./resolver";
+import { registerDefaultStickerProviders } from "./providers";
+import { parseStickerId } from "./sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "./types";
+
+const DEFAULT_BROWSE_LIMIT = 12;
+const DEFAULT_SEARCH_LIMIT = 100;
+
+function mergeSearchResults({
+ results,
+}: {
+ results: StickerSearchResult[];
+}): StickerSearchResult {
+ const deduplicatedItems = new Map<
+ string,
+ StickerSearchResult["items"][number]
+ >();
+ let total = 0;
+ let hasMore = false;
+
+ for (const result of results) {
+ total += result.total;
+ hasMore = hasMore || result.hasMore;
+ for (const item of result.items) {
+ if (!deduplicatedItems.has(item.id)) {
+ deduplicatedItems.set(item.id, item);
+ }
+ }
+ }
+
+ return {
+ items: Array.from(deduplicatedItems.values()),
+ total,
+ hasMore,
+ };
+}
+
+function getProviderByCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): StickerProvider | null {
+ if (category === "all") {
+ return null;
+ }
+
+ try {
+ return stickersRegistry.get(category);
+ } catch {
+ return null;
+ }
+}
+
+function getEmptyBrowseResult(): StickerBrowseResult {
+ return {
+ sections: [],
+ };
+}
+
+function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
+ const stickerIdParts = stickerId.split(":");
+ if (stickerIdParts.length <= 1) {
+ return stickerId;
+ }
+ return (
+ stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
+ stickerId
+ );
+}
+
+function toRecentStickerItem({
+ stickerId,
+}: {
+ stickerId: string;
+}): StickerItem | null {
+ try {
+ const { providerId } = parseStickerId({ stickerId });
+ return {
+ id: stickerId,
+ provider: providerId,
+ name: getStickerNameFromId({ stickerId }),
+ previewUrl: resolveStickerId({
+ stickerId,
+ options: { width: 64, height: 64 },
+ }),
+ metadata: {},
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function searchStickers({
+ query,
+ category,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ category: StickerCategory;
+ limit?: number;
+}): Promise<StickerSearchResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory !== "all") {
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+ return provider.search({
+ query,
+ options: { limit },
+ });
+ }
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map((provider) =>
+ provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ }),
+ ),
+ );
+
+ const fulfilledResults = settledResults
+ .filter(
+ (result): result is PromiseFulfilledResult<StickerSearchResult> =>
+ result.status === "fulfilled",
+ )
+ .map((result) => result.value);
+
+ return mergeSearchResults({
+ results: fulfilledResults,
+ });
+}
+
+export async function searchAll({
+ query,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return { sections: [] };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map(async (provider) => {
+ const result = await provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ });
+ return { provider, result };
+ }),
+ );
+
+ const sections: StickerBrowseResult["sections"] = [];
+ for (const settled of settledResults) {
+ if (settled.status !== "fulfilled") {
+ continue;
+ }
+ const { provider, result } = settled.value;
+ if (result.items.length === 0) {
+ continue;
+ }
+ const category = provider.id as StickerCategory;
+ sections.push({
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? provider.id,
+ items: result.items,
+ hasMore: result.hasMore,
+ layout: "grid",
+ });
+ }
+
+ return { sections };
+}
+
+export async function browseCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory === "all") {
+ return getEmptyBrowseResult();
+ }
+
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return getEmptyBrowseResult();
+ }
+
+ return provider.browse({ options: {} });
+}
+
+export async function browseAll({
+ recentStickers,
+ limit = DEFAULT_BROWSE_LIMIT,
+}: {
+ recentStickers: string[];
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const sections: StickerBrowseResult["sections"] = [];
+ const recentItems = recentStickers
+ .map((stickerId) => toRecentStickerItem({ stickerId }))
+ .filter((item): item is StickerItem => item !== null);
+
+ if (recentItems.length > 0) {
+ sections.push({
+ id: "recent",
+ title: "Recently used",
+ items: recentItems.slice(0, limit),
+ hasMore: recentItems.length > limit,
+ layout: "row",
+ });
+ }
+
+ const settledResults = await Promise.allSettled(
+ stickersRegistry.getAll().map(async (provider) => {
+ const browseResult = await provider.browse({
+ options: { limit },
+ });
+ const firstSection = browseResult.sections[0];
+
+ if (!firstSection || firstSection.items.length === 0) {
+ return null;
+ }
+
+ const category = provider.id as StickerCategory;
+ return {
+ ...firstSection,
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? firstSection.title,
+ layout: "row" as const,
+ action: {
+ type: "see-all" as const,
+ category,
+ sectionId: firstSection.id,
+ },
+ };
+ }),
+ );
+
+ for (const result of settledResults) {
+ if (result.status === "fulfilled" && result.value) {
+ sections.push(result.value);
+ }
+ }
+
+ return { sections };
+}
+
+export async function resolveStickerIntrinsicSize({
+ stickerId,
+}: {
+ stickerId: string;
+}): Promise<{ width: number; height: number }> {
+ const url = resolveStickerId({ stickerId });
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () =>
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
+ img.onerror = () =>
+ resolve({
+ width: STICKER_INTRINSIC_SIZE_FALLBACK,
+ height: STICKER_INTRINSIC_SIZE_FALLBACK,
+ });
+ img.src = url;
+ });
+}
+
+export { resolveStickerId };
+export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
+export type {
+ StickerBrowseResult,
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem,
+ StickerProvider,
+ StickerResolveOptions,
+ StickerSearchResult,
+} from "./types";
diff --git a/apps/web/src/stickers/intrinsic-size.ts b/apps/web/src/stickers/intrinsic-size.ts
new file mode 100644
index 00000000..43436237
--- /dev/null
+++ b/apps/web/src/stickers/intrinsic-size.ts
@@ -0,0 +1 @@
+export const STICKER_INTRINSIC_SIZE_FALLBACK = 200;
diff --git a/apps/web/src/stickers/providers/countries-data.ts b/apps/web/src/stickers/providers/countries-data.ts
new file mode 100644
index 00000000..05551699
--- /dev/null
+++ b/apps/web/src/stickers/providers/countries-data.ts
@@ -0,0 +1,1819 @@
+export const REGIONS = [
+ { id: "Western Europe", aliases: ["west europe"] },
+ { id: "Eastern Europe", aliases: ["east europe"] },
+ { id: "Northern Europe", aliases: ["north europe"] },
+ { id: "Southern Europe", aliases: ["south europe"] },
+ { id: "South Asia", aliases: ["southern asia"] },
+ { id: "Southeast Asia", aliases: ["south east asia"] },
+ { id: "East Asia", aliases: ["eastern asia", "far east"] },
+ { id: "Central Asia", aliases: [] },
+ { id: "Middle East", aliases: ["west asia"] },
+ { id: "North Africa", aliases: ["northern africa"] },
+ { id: "Sub-Saharan Africa", aliases: ["subsaharan africa"] },
+ { id: "North America", aliases: ["northern america"] },
+ { id: "South America", aliases: ["southern america", "latin america"] },
+ { id: "Central America", aliases: ["central am"] },
+ { id: "Caribbean", aliases: ["caribbean islands"] },
+ { id: "Oceania", aliases: ["pacific", "pacific islands"] },
+ { id: "Antarctica", aliases: [] },
+ { id: "Atlantic Ocean", aliases: ["atlantic"] },
+ { id: "North Atlantic", aliases: [] },
+] as const;
+
+export type RegionId = (typeof REGIONS)[number]["id"];
+
+export const REGION_GROUPS: Partial<Record<string, RegionId[]>> = {
+ europe: ["Western Europe", "Eastern Europe", "Northern Europe", "Southern Europe"],
+ asia: ["South Asia", "Southeast Asia", "East Asia", "Central Asia"],
+ africa: ["Sub-Saharan Africa", "North Africa"],
+ america: ["North America", "South America", "Central America", "Caribbean"],
+};
+
+export interface CountryRecord {
+ name: string;
+ code: string;
+ languages?: string[];
+ flag_colors?: string[];
+ region?: RegionId;
+}
+
+export const COUNTRIES: CountryRecord[] = [
+ {
+ "name": "Andorra",
+ "code": "AD",
+ "languages": ["catalan"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Arab Emirates",
+ "code": "AE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Afghanistan",
+ "code": "AF",
+ "languages": ["dari", "pashto"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Antigua and Barbuda",
+ "code": "AG",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "blue", "white", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Anguilla",
+ "code": "AI",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Albania",
+ "code": "AL",
+ "languages": ["albanian"],
+ "flag_colors": ["red", "black"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Armenia",
+ "code": "AM",
+ "languages": ["armenian"],
+ "flag_colors": ["red", "blue", "orange"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Angola",
+ "code": "AO",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "black", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Antarctica",
+ "code": "AQ",
+ "languages": [],
+ "flag_colors": [],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Argentina",
+ "code": "AR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "American Samoa",
+ "code": "AS",
+ "languages": ["english", "samoan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Austria",
+ "code": "AT",
+ "languages": ["german"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Australia",
+ "code": "AU",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Aruba",
+ "code": "AW",
+ "languages": ["dutch", "papiamento"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Aland Islands",
+ "code": "AX",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Azerbaijan",
+ "code": "AZ",
+ "languages": ["azerbaijani"],
+ "flag_colors": ["blue", "red", "green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Bosnia and Herzegovina",
+ "code": "BA",
+ "languages": ["bosnian", "croatian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Barbados",
+ "code": "BB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bangladesh",
+ "code": "BD",
+ "languages": ["bengali"],
+ "flag_colors": ["green", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Belgium",
+ "code": "BE",
+ "languages": ["french", "dutch", "german"],
+ "flag_colors": ["black", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Burkina Faso",
+ "code": "BF",
+ "languages": ["french"],
+ "flag_colors": ["red", "green", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Bulgaria",
+ "code": "BG",
+ "languages": ["bulgarian"],
+ "flag_colors": ["white", "green", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Bahrain",
+ "code": "BH",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Burundi",
+ "code": "BI",
+ "languages": ["kirundi", "french", "english"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Benin",
+ "code": "BJ",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Barthelemy",
+ "code": "BL",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bermuda",
+ "code": "BM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "North Atlantic"
+ },
+ {
+ "name": "Brunei",
+ "code": "BN",
+ "languages": ["malay"],
+ "flag_colors": ["yellow", "white", "black", "red"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Bolivia",
+ "code": "BO",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "South America"
+ },
+ {
+ "name": "Caribbean Netherlands",
+ "code": "BQ",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Brazil",
+ "code": "BR",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "blue", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Bahamas",
+ "code": "BS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bhutan",
+ "code": "BT",
+ "languages": ["dzongkha"],
+ "flag_colors": ["yellow", "orange", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Bouvet Island",
+ "code": "BV",
+ "languages": [],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Botswana",
+ "code": "BW",
+ "languages": ["english", "tswana"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Belarus",
+ "code": "BY",
+ "languages": ["belarusian", "russian"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Belize",
+ "code": "BZ",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Canada",
+ "code": "CA",
+ "languages": ["english", "french"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Cocos (Keeling) Islands",
+ "code": "CC",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "yellow", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Democratic Republic of the Congo",
+ "code": "CD",
+ "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Central African Republic",
+ "code": "CF",
+ "languages": ["french", "sango"],
+ "flag_colors": ["blue", "white", "green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Republic of the Congo",
+ "code": "CG",
+ "languages": ["french", "lingala", "kituba"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Switzerland",
+ "code": "CH",
+ "languages": ["german", "french", "italian"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Cote d'Ivoire",
+ "code": "CI",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Cook Islands",
+ "code": "CK",
+ "languages": ["english", "cook islands maori"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Chile",
+ "code": "CL",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "South America"
+ },
+ {
+ "name": "Cameroon",
+ "code": "CM",
+ "languages": ["english", "french"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "China",
+ "code": "CN",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Colombia",
+ "code": "CO",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Costa Rica",
+ "code": "CR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Central America"
+ },
+ {
+ "name": "Cuba",
+ "code": "CU",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Cape Verde",
+ "code": "CV",
+ "languages": ["portuguese"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Curacao",
+ "code": "CW",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Christmas Island",
+ "code": "CX",
+ "languages": ["english"],
+ "flag_colors": ["green", "blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Cyprus",
+ "code": "CY",
+ "languages": ["greek", "turkish"],
+ "flag_colors": ["white", "orange", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Czechia",
+ "code": "CZ",
+ "languages": ["czech"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Germany",
+ "code": "DE",
+ "languages": ["german"],
+ "flag_colors": ["black", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Djibouti",
+ "code": "DJ",
+ "languages": ["arabic", "french", "afar", "somali"],
+ "flag_colors": ["blue", "green", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Denmark",
+ "code": "DK",
+ "languages": ["danish"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Dominica",
+ "code": "DM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Dominican Republic",
+ "code": "DO",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Algeria",
+ "code": "DZ",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Ecuador",
+ "code": "EC",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Estonia",
+ "code": "EE",
+ "languages": ["estonian"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Egypt",
+ "code": "EG",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "yellow"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Western Sahara",
+ "code": "EH",
+ "languages": ["arabic", "spanish"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Eritrea",
+ "code": "ER",
+ "languages": ["tigrinya", "arabic", "english"],
+ "flag_colors": ["red", "green", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Spain",
+ "code": "ES",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Ethiopia",
+ "code": "ET",
+ "languages": ["amharic", "oromo"],
+ "flag_colors": ["green", "yellow", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "European Union",
+ "code": "EU",
+ "languages": ["english", "french", "german"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Finland",
+ "code": "FI",
+ "languages": ["finnish", "swedish"],
+ "flag_colors": ["white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Fiji",
+ "code": "FJ",
+ "languages": ["english", "fijian", "hindi"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Falkland Islands",
+ "code": "FK",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Micronesia",
+ "code": "FM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Faroe Islands",
+ "code": "FO",
+ "languages": ["faroese", "danish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "France",
+ "code": "FR",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Gabon",
+ "code": "GA",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "England",
+ "code": "GB-ENG",
+ "languages": ["english"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Northern Ireland",
+ "code": "GB-NIR",
+ "languages": ["english", "irish"],
+ "flag_colors": ["white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Scotland",
+ "code": "GB-SCT",
+ "languages": ["english", "scottish gaelic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Wales",
+ "code": "GB-WLS",
+ "languages": ["english", "welsh"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Kingdom",
+ "code": "GB",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Grenada",
+ "code": "GD",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Georgia",
+ "code": "GE",
+ "languages": ["georgian"],
+ "flag_colors": ["white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "French Guiana",
+ "code": "GF",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Guernsey",
+ "code": "GG",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Ghana",
+ "code": "GH",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Gibraltar",
+ "code": "GI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Greenland",
+ "code": "GL",
+ "languages": ["kalaallisut", "danish"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Gambia",
+ "code": "GM",
+ "languages": ["english"],
+ "flag_colors": ["red", "blue", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guinea",
+ "code": "GN",
+ "languages": ["french"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guadeloupe",
+ "code": "GP",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Equatorial Guinea",
+ "code": "GQ",
+ "languages": ["spanish", "french", "portuguese"],
+ "flag_colors": ["green", "white", "red", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Greece",
+ "code": "GR",
+ "languages": ["greek"],
+ "flag_colors": ["blue", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "South Georgia and the South Sandwich Islands",
+ "code": "GS",
+ "languages": [],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Guatemala",
+ "code": "GT",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Guam",
+ "code": "GU",
+ "languages": ["english", "chamorro"],
+ "flag_colors": ["blue", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Guinea-Bissau",
+ "code": "GW",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guyana",
+ "code": "GY",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Hong Kong",
+ "code": "HK",
+ "languages": ["cantonese", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Heard Island and McDonald Islands",
+ "code": "HM",
+ "languages": [],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Honduras",
+ "code": "HN",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Croatia",
+ "code": "HR",
+ "languages": ["croatian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Haiti",
+ "code": "HT",
+ "languages": ["haitian creole", "french"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Hungary",
+ "code": "HU",
+ "languages": ["hungarian"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Indonesia",
+ "code": "ID",
+ "languages": ["indonesian"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Ireland",
+ "code": "IE",
+ "languages": ["english", "irish"],
+ "flag_colors": ["green", "white", "orange"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Israel",
+ "code": "IL",
+ "languages": ["hebrew", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Isle of Man",
+ "code": "IM",
+ "languages": ["english", "manx"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "India",
+ "code": "IN",
+ "languages": ["hindi", "english"],
+ "flag_colors": ["orange", "white", "green", "blue"],
+ "region": "South Asia"
+ },
+ {
+ "name": "British Indian Ocean Territory",
+ "code": "IO",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Iraq",
+ "code": "IQ",
+ "languages": ["arabic", "kurdish"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iran",
+ "code": "IR",
+ "languages": ["persian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iceland",
+ "code": "IS",
+ "languages": ["icelandic"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Italy",
+ "code": "IT",
+ "languages": ["italian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Jersey",
+ "code": "JE",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Jamaica",
+ "code": "JM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Jordan",
+ "code": "JO",
+ "languages": ["arabic"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Japan",
+ "code": "JP",
+ "languages": ["japanese"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kenya",
+ "code": "KE",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Kyrgyzstan",
+ "code": "KG",
+ "languages": ["kyrgyz", "russian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Cambodia",
+ "code": "KH",
+ "languages": ["khmer"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Kiribati",
+ "code": "KI",
+ "languages": ["english", "kiribati"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Comoros",
+ "code": "KM",
+ "languages": ["comorian", "arabic", "french"],
+ "flag_colors": ["green", "yellow", "white", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Kitts and Nevis",
+ "code": "KN",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "North Korea",
+ "code": "KP",
+ "languages": ["korean"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "South Korea",
+ "code": "KR",
+ "languages": ["korean"],
+ "flag_colors": ["white", "red", "blue", "black"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kuwait",
+ "code": "KW",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white", "red", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Cayman Islands",
+ "code": "KY",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Kazakhstan",
+ "code": "KZ",
+ "languages": ["kazakh", "russian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Laos",
+ "code": "LA",
+ "languages": ["lao"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Lebanon",
+ "code": "LB",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Saint Lucia",
+ "code": "LC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Liechtenstein",
+ "code": "LI",
+ "languages": ["german"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Sri Lanka",
+ "code": "LK",
+ "languages": ["sinhala", "tamil"],
+ "flag_colors": ["yellow", "green", "orange", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Liberia",
+ "code": "LR",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lesotho",
+ "code": "LS",
+ "languages": ["sesotho", "english"],
+ "flag_colors": ["blue", "white", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lithuania",
+ "code": "LT",
+ "languages": ["lithuanian"],
+ "flag_colors": ["yellow", "green", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Luxembourg",
+ "code": "LU",
+ "languages": ["luxembourgish", "french", "german"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Latvia",
+ "code": "LV",
+ "languages": ["latvian"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Libya",
+ "code": "LY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "black", "green", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Morocco",
+ "code": "MA",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["red", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Monaco",
+ "code": "MC",
+ "languages": ["french"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Moldova",
+ "code": "MD",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Montenegro",
+ "code": "ME",
+ "languages": ["montenegrin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Martin",
+ "code": "MF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Madagascar",
+ "code": "MG",
+ "languages": ["malagasy", "french"],
+ "flag_colors": ["white", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Marshall Islands",
+ "code": "MH",
+ "languages": ["marshallese", "english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Oceania"
+ },
+ {
+ "name": "North Macedonia",
+ "code": "MK",
+ "languages": ["macedonian", "albanian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mali",
+ "code": "ML",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Myanmar",
+ "code": "MM",
+ "languages": ["burmese"],
+ "flag_colors": ["yellow", "green", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mongolia",
+ "code": "MN",
+ "languages": ["mongolian"],
+ "flag_colors": ["red", "blue", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Macao",
+ "code": "MO",
+ "languages": ["cantonese", "portuguese"],
+ "flag_colors": ["green", "white", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Northern Mariana Islands",
+ "code": "MP",
+ "languages": ["english", "chamorro", "carolinian"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Martinique",
+ "code": "MQ",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Mauritania",
+ "code": "MR",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Montserrat",
+ "code": "MS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "white", "yellow", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Malta",
+ "code": "MT",
+ "languages": ["maltese", "english"],
+ "flag_colors": ["white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mauritius",
+ "code": "MU",
+ "languages": ["english", "french", "mauritian creole"],
+ "flag_colors": ["red", "blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Maldives",
+ "code": "MV",
+ "languages": ["divehi"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Malawi",
+ "code": "MW",
+ "languages": ["english", "chichewa"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Mexico",
+ "code": "MX",
+ "languages": ["spanish"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North America"
+ },
+ {
+ "name": "Malaysia",
+ "code": "MY",
+ "languages": ["malay"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mozambique",
+ "code": "MZ",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "black", "yellow", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Namibia",
+ "code": "NA",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "green", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "New Caledonia",
+ "code": "NC",
+ "languages": ["french"],
+ "flag_colors": ["blue", "red", "green", "yellow", "black"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niger",
+ "code": "NE",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Norfolk Island",
+ "code": "NF",
+ "languages": ["english", "norfuk"],
+ "flag_colors": ["green", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Nigeria",
+ "code": "NG",
+ "languages": ["english"],
+ "flag_colors": ["green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Nicaragua",
+ "code": "NI",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Netherlands",
+ "code": "NL",
+ "languages": ["dutch"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Norway",
+ "code": "NO",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Nepal",
+ "code": "NP",
+ "languages": ["nepali"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Nauru",
+ "code": "NR",
+ "languages": ["nauruan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niue",
+ "code": "NU",
+ "languages": ["niuean", "english"],
+ "flag_colors": ["yellow", "blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "New Zealand",
+ "code": "NZ",
+ "languages": ["english", "maori"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Oman",
+ "code": "OM",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Panama",
+ "code": "PA",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Central America"
+ },
+ {
+ "name": "Peru",
+ "code": "PE",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "French Polynesia",
+ "code": "PF",
+ "languages": ["french", "tahitian"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Papua New Guinea",
+ "code": "PG",
+ "languages": ["english", "tok pisin", "hiri motu"],
+ "flag_colors": ["red", "black", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Philippines",
+ "code": "PH",
+ "languages": ["filipino", "english"],
+ "flag_colors": ["blue", "red", "white", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Pakistan",
+ "code": "PK",
+ "languages": ["urdu", "english"],
+ "flag_colors": ["green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Poland",
+ "code": "PL",
+ "languages": ["polish"],
+ "flag_colors": ["white", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Saint Pierre and Miquelon",
+ "code": "PM",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red", "yellow", "black"],
+ "region": "North America"
+ },
+ {
+ "name": "Pitcairn Islands",
+ "code": "PN",
+ "languages": ["english", "pitkern"],
+ "flag_colors": ["blue", "green", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Puerto Rico",
+ "code": "PR",
+ "languages": ["spanish", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Palestine",
+ "code": "PS",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Portugal",
+ "code": "PT",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Palau",
+ "code": "PW",
+ "languages": ["palauan", "english"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Paraguay",
+ "code": "PY",
+ "languages": ["spanish", "guarani"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Qatar",
+ "code": "QA",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Reunion",
+ "code": "RE",
+ "languages": ["french", "reunion creole"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Romania",
+ "code": "RO",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Serbia",
+ "code": "RS",
+ "languages": ["serbian"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Russia",
+ "code": "RU",
+ "languages": ["russian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Rwanda",
+ "code": "RW",
+ "languages": ["kinyarwanda", "french", "english"],
+ "flag_colors": ["blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saudi Arabia",
+ "code": "SA",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Solomon Islands",
+ "code": "SB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Seychelles",
+ "code": "SC",
+ "languages": ["seychellois creole", "english", "french"],
+ "flag_colors": ["blue", "yellow", "red", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sudan",
+ "code": "SD",
+ "languages": ["arabic", "english"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Sweden",
+ "code": "SE",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Singapore",
+ "code": "SG",
+ "languages": ["english", "malay", "mandarin", "tamil"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Saint Helena",
+ "code": "SH",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Slovenia",
+ "code": "SI",
+ "languages": ["slovenian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Svalbard and Jan Mayen",
+ "code": "SJ",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Slovakia",
+ "code": "SK",
+ "languages": ["slovak"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Sierra Leone",
+ "code": "SL",
+ "languages": ["english"],
+ "flag_colors": ["green", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "San Marino",
+ "code": "SM",
+ "languages": ["italian"],
+ "flag_colors": ["white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Senegal",
+ "code": "SN",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Somalia",
+ "code": "SO",
+ "languages": ["somali", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Suriname",
+ "code": "SR",
+ "languages": ["dutch"],
+ "flag_colors": ["green", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "South Sudan",
+ "code": "SS",
+ "languages": ["english"],
+ "flag_colors": ["black", "red", "green", "blue", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sao Tome and Principe",
+ "code": "ST",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "red", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "El Salvador",
+ "code": "SV",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Sint Maarten",
+ "code": "SX",
+ "languages": ["dutch", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Syria",
+ "code": "SY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Eswatini",
+ "code": "SZ",
+ "languages": ["swazi", "english"],
+ "flag_colors": ["blue", "red", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Turks and Caicos Islands",
+ "code": "TC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Chad",
+ "code": "TD",
+ "languages": ["french", "arabic"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "French Southern Territories",
+ "code": "TF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Togo",
+ "code": "TG",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Thailand",
+ "code": "TH",
+ "languages": ["thai"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Tajikistan",
+ "code": "TJ",
+ "languages": ["tajik"],
+ "flag_colors": ["red", "white", "green", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tokelau",
+ "code": "TK",
+ "languages": ["tokelauan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Timor-Leste",
+ "code": "TL",
+ "languages": ["tetum", "portuguese"],
+ "flag_colors": ["red", "yellow", "black", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Turkmenistan",
+ "code": "TM",
+ "languages": ["turkmen"],
+ "flag_colors": ["green", "red", "white"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tunisia",
+ "code": "TN",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Tonga",
+ "code": "TO",
+ "languages": ["tongan", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Turkey",
+ "code": "TR",
+ "languages": ["turkish"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Trinidad and Tobago",
+ "code": "TT",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Tuvalu",
+ "code": "TV",
+ "languages": ["tuvaluan", "english"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Taiwan",
+ "code": "TW",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Tanzania",
+ "code": "TZ",
+ "languages": ["swahili", "english"],
+ "flag_colors": ["green", "yellow", "black", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Ukraine",
+ "code": "UA",
+ "languages": ["ukrainian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Uganda",
+ "code": "UG",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "United States",
+ "code": "US",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "North America"
+ },
+ {
+ "name": "Uruguay",
+ "code": "UY",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Uzbekistan",
+ "code": "UZ",
+ "languages": ["uzbek"],
+ "flag_colors": ["blue", "white", "green", "red"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Vatican City",
+ "code": "VA",
+ "languages": ["italian", "latin"],
+ "flag_colors": ["yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Vincent and the Grenadines",
+ "code": "VC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "green", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Venezuela",
+ "code": "VE",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "British Virgin Islands",
+ "code": "VG",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "U.S. Virgin Islands",
+ "code": "VI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Vietnam",
+ "code": "VN",
+ "languages": ["vietnamese"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Vanuatu",
+ "code": "VU",
+ "languages": ["bislama", "english", "french"],
+ "flag_colors": ["red", "green", "black", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Wallis and Futuna",
+ "code": "WF",
+ "languages": ["french", "wallisian", "futunan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Samoa",
+ "code": "WS",
+ "languages": ["samoan", "english"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Kosovo",
+ "code": "XK",
+ "languages": ["albanian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Yemen",
+ "code": "YE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Mayotte",
+ "code": "YT",
+ "languages": ["french", "shimaore", "kibushi"],
+ "flag_colors": ["white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "South Africa",
+ "code": "ZA",
+ "languages": ["zulu", "xhosa", "afrikaans", "english"],
+ "flag_colors": ["red", "blue", "green", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zambia",
+ "code": "ZM",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "orange"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zimbabwe",
+ "code": "ZW",
+ "languages": ["english", "shona", "ndebele"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ }
+];
diff --git a/apps/web/src/stickers/providers/flags.ts b/apps/web/src/stickers/providers/flags.ts
new file mode 100644
index 00000000..0cca292f
--- /dev/null
+++ b/apps/web/src/stickers/providers/flags.ts
@@ -0,0 +1,202 @@
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+import { REGIONS, REGION_GROUPS } from "./countries-data";
+import type { CountryRecord, RegionId } from "./countries-data";
+
+const FLAGS_PROVIDER_ID = "flags";
+const DEFAULT_SEARCH_LIMIT = 100;
+const DEFAULT_FLAGS_BASE_URL = "/flags";
+
+let countriesPromise: Promise<CountryRecord[]> | null = null;
+
+function getFlagsBaseUrl(): string {
+ return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, "");
+}
+
+function buildFlagUrl({ code }: { code: string }): string {
+ const normalizedCode = code.toLowerCase();
+ return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`;
+}
+
+async function loadCountries(): Promise<CountryRecord[]> {
+ if (countriesPromise) {
+ return countriesPromise;
+ }
+
+ countriesPromise = import("./countries-data")
+ .then((m) => m.COUNTRIES)
+ .catch((error) => {
+ console.error("Failed to load countries dataset:", error);
+ return [];
+ });
+
+ return countriesPromise;
+}
+
+function toStickerItem({ country }: { country: CountryRecord }): StickerItem {
+ const normalizedCode = country.code.toUpperCase();
+ return {
+ id: buildStickerId({
+ providerId: FLAGS_PROVIDER_ID,
+ providerValue: normalizedCode,
+ }),
+ provider: FLAGS_PROVIDER_ID,
+ name: country.name,
+ previewUrl: buildFlagUrl({ code: normalizedCode }),
+ metadata: {
+ code: normalizedCode,
+ region: country.region ?? null,
+ languages: country.languages ?? [],
+ flagColors: country.flag_colors ?? [],
+ },
+ };
+}
+
+function normalizeQuery({ query }: { query: string }): string {
+ return query.trim().toLowerCase();
+}
+
+function findMatchingRegions({
+ query,
+}: {
+ query: string;
+}): (typeof REGIONS)[number][] {
+ return REGIONS.filter(
+ (r) =>
+ r.id.toLowerCase() === query ||
+ r.aliases.some((alias) => alias === query),
+ );
+}
+
+export function resolveQueryToRegions({
+ query,
+}: {
+ query: string;
+}): Set<RegionId> | null {
+ const group = REGION_GROUPS[query];
+ if (group) {
+ return new Set(group);
+ }
+
+ const matched = findMatchingRegions({ query });
+ return matched.length > 0 ? new Set(matched.map((r) => r.id)) : null;
+}
+
+export function getRegionLabel({ query }: { query: string }): string {
+ if (REGION_GROUPS[query]) {
+ return query
+ .split(" ")
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ");
+ }
+
+ const matched = findMatchingRegions({ query });
+ return matched[0]?.id ?? query;
+}
+
+function filterCountriesByQuery({
+ countries,
+ query,
+}: {
+ countries: CountryRecord[];
+ query: string;
+}): CountryRecord[] {
+ if (!query) {
+ return countries;
+ }
+
+ const regionIds = resolveQueryToRegions({ query });
+
+ if (regionIds) {
+ return countries.filter((country) => country.region && regionIds.has(country.region));
+ }
+
+ return countries.filter((country) => {
+ const normalizedName = country.name.toLowerCase();
+ const normalizedCode = country.code.toLowerCase();
+ return normalizedName.includes(query) || normalizedCode.includes(query);
+ });
+}
+
+function paginateCountries({
+ countries,
+ options,
+}: {
+ countries: CountryRecord[];
+ options?: { page?: number; limit?: number };
+}): { items: CountryRecord[]; hasMore: boolean; total: number } {
+ if (options?.limit === undefined) {
+ return { items: countries, hasMore: false, total: countries.length };
+ }
+ const page = Math.max(1, options.page ?? 1);
+ const limit = Math.max(1, options.limit);
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ return {
+ items: countries.slice(startIndex, endIndex),
+ hasMore: endIndex < countries.length,
+ total: countries.length,
+ };
+}
+
+export const flagsProvider: StickerProvider = {
+ id: FLAGS_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const countries = await loadCountries();
+ const normalizedQuery = normalizeQuery({ query });
+ const filteredCountries = filterCountriesByQuery({
+ countries,
+ query: normalizedQuery,
+ });
+ const paged = paginateCountries({
+ countries: filteredCountries,
+ options: {
+ page: 1,
+ limit: options?.limit ?? DEFAULT_SEARCH_LIMIT,
+ },
+ });
+ return {
+ items: paged.items.map((country) => toStickerItem({ country })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerBrowseResult> {
+ const countries = await loadCountries();
+ const paged = paginateCountries({ countries, options });
+ return {
+ sections: [
+ {
+ id: "all",
+ items: paged.items.map((country) => toStickerItem({ country })),
+ hasMore: paged.hasMore,
+ layout: "grid",
+ },
+ ],
+ };
+ },
+ resolveUrl({
+ stickerId,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const { providerValue } = parseStickerId({ stickerId });
+ return buildFlagUrl({ code: providerValue });
+ },
+};
diff --git a/apps/web/src/stickers/providers/index.ts b/apps/web/src/stickers/providers/index.ts
new file mode 100644
index 00000000..ca23963c
--- /dev/null
+++ b/apps/web/src/stickers/providers/index.ts
@@ -0,0 +1,24 @@
+import { stickersRegistry } from "../registry";
+import type { StickerProvider } from "@/stickers/types";
+import { flagsProvider } from "./flags";
+import { logosProvider } from "./logos";
+import { shapesProvider } from "./shapes";
+
+const defaultProviders: StickerProvider[] = [
+ logosProvider,
+ flagsProvider,
+ shapesProvider,
+];
+
+export function registerDefaultStickerProviders({
+ providersToRegister = defaultProviders,
+}: {
+ providersToRegister?: StickerProvider[];
+} = {}): void {
+ for (const provider of providersToRegister) {
+ if (stickersRegistry.has(provider.id)) {
+ continue;
+ }
+ stickersRegistry.register(provider.id, provider);
+ }
+}
diff --git a/apps/web/src/stickers/providers/logos.ts b/apps/web/src/stickers/providers/logos.ts
new file mode 100644
index 00000000..474c64da
--- /dev/null
+++ b/apps/web/src/stickers/providers/logos.ts
@@ -0,0 +1,31 @@
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const LOGOS_PROVIDER_ID = "logos";
+
+const EMPTY_SEARCH_RESULT: StickerSearchResult = {
+ items: [] as StickerItem[],
+ total: 0,
+ hasMore: false,
+};
+
+const EMPTY_BROWSE_RESULT: StickerBrowseResult = {
+ sections: [],
+};
+
+export const logosProvider: StickerProvider = {
+ id: LOGOS_PROVIDER_ID,
+ async search(): Promise<StickerSearchResult> {
+ return EMPTY_SEARCH_RESULT;
+ },
+ async browse(): Promise<StickerBrowseResult> {
+ return EMPTY_BROWSE_RESULT;
+ },
+ resolveUrl({ stickerId }: { stickerId: string }): string {
+ return stickerId;
+ },
+};
diff --git a/apps/web/src/stickers/providers/shapes.ts b/apps/web/src/stickers/providers/shapes.ts
new file mode 100644
index 00000000..e3137425
--- /dev/null
+++ b/apps/web/src/stickers/providers/shapes.ts
@@ -0,0 +1,206 @@
+import { buildGraphicPreviewUrl, buildDefaultGraphicInstance, graphicsRegistry, registerDefaultGraphics } from "@/graphics";
+import type { ParamValues } from "@/params";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const SHAPES_PROVIDER_ID = "shapes";
+
+type ShapeGraphicPreset = {
+ shapeKey: string;
+ name: string;
+ definitionId: string;
+ params?: ParamValues;
+};
+
+const LEGACY_SHAPE_PRESETS: Record<string, ShapeGraphicPreset> = {
+ square: { shapeKey: "square", name: "Square", definitionId: "rectangle" },
+ circle: { shapeKey: "circle", name: "Circle", definitionId: "ellipse" },
+ triangle: {
+ shapeKey: "triangle",
+ name: "Triangle",
+ definitionId: "polygon",
+ params: { sides: 3 },
+ },
+ hexagon: {
+ shapeKey: "hexagon",
+ name: "Hexagon",
+ definitionId: "polygon",
+ params: { sides: 6 },
+ },
+ diamond: {
+ shapeKey: "diamond",
+ name: "Diamond",
+ definitionId: "polygon",
+ params: { sides: 4 },
+ },
+ star: { shapeKey: "star", name: "Star", definitionId: "star" },
+};
+
+function getShapePresets(): ShapeGraphicPreset[] {
+ registerDefaultGraphics();
+ return graphicsRegistry.getAll().map((definition) => ({
+ shapeKey: definition.id,
+ name: definition.name,
+ definitionId: definition.id,
+ }));
+}
+
+function getShapePreset({
+ shapeKey,
+}: {
+ shapeKey: string;
+}): ShapeGraphicPreset | null {
+ return (
+ getShapePresets().find((preset) => preset.shapeKey === shapeKey) ??
+ LEGACY_SHAPE_PRESETS[shapeKey] ??
+ null
+ );
+}
+
+function getShapeParams({
+ preset,
+}: {
+ preset: ShapeGraphicPreset;
+}): ParamValues {
+ return {
+ ...buildDefaultGraphicInstance({ definitionId: preset.definitionId }).params,
+ ...preset.params,
+ };
+}
+
+export function parseShapeStickerId({
+ stickerId,
+}: {
+ stickerId: string;
+}): ShapeGraphicPreset | null {
+ try {
+ const { providerValue } = parseStickerId({ stickerId });
+ return getShapePreset({ shapeKey: providerValue });
+ } catch {
+ return null;
+ }
+}
+
+function buildShapeUrl({ shapeKey }: { shapeKey: string }): string {
+ const preset = getShapePreset({ shapeKey });
+ if (!preset) {
+ return buildGraphicPreviewUrl({ definitionId: "rectangle" });
+ }
+ return buildGraphicPreviewUrl({
+ definitionId: preset.definitionId,
+ params: getShapeParams({ preset }),
+ });
+}
+
+function toStickerItem({
+ preset,
+}: {
+ preset: ShapeGraphicPreset;
+}): StickerItem {
+ return {
+ id: buildStickerId({
+ providerId: SHAPES_PROVIDER_ID,
+ providerValue: preset.shapeKey,
+ }),
+ provider: SHAPES_PROVIDER_ID,
+ name: preset.name,
+ previewUrl: buildShapeUrl({ shapeKey: preset.shapeKey }),
+ metadata: {
+ definitionId: preset.definitionId,
+ params: preset.params ?? {},
+ },
+ };
+}
+
+function filterShapesByQuery({ query }: { query: string }): ShapeGraphicPreset[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ const presets = getShapePresets();
+ if (!normalizedQuery) {
+ return presets;
+ }
+
+ return presets.filter((preset) => {
+ const definition = graphicsRegistry.get(preset.definitionId);
+ return (
+ preset.name.toLowerCase().includes(normalizedQuery) ||
+ definition.keywords.some((keyword) =>
+ keyword.toLowerCase().includes(normalizedQuery),
+ )
+ );
+ });
+}
+
+function paginateShapes({
+ shapes,
+ options,
+}: {
+ shapes: ShapeGraphicPreset[];
+ options?: { page?: number; limit?: number };
+}): { items: ShapeGraphicPreset[]; hasMore: boolean; total: number } {
+ const page = Math.max(1, options?.page ?? 1);
+ const limit = Math.max(1, options?.limit ?? getShapePresets().length);
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ const pagedItems = shapes.slice(startIndex, endIndex);
+ return {
+ items: pagedItems,
+ hasMore: endIndex < shapes.length,
+ total: shapes.length,
+ };
+}
+
+export const shapesProvider: StickerProvider = {
+ id: SHAPES_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const filteredShapes = filterShapesByQuery({ query });
+ const paged = paginateShapes({
+ shapes: filteredShapes,
+ options: { page: 1, limit: options?.limit ?? getShapePresets().length },
+ });
+ return {
+ items: paged.items.map((preset) => toStickerItem({ preset })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerBrowseResult> {
+ const paged = paginateShapes({
+ shapes: getShapePresets(),
+ options,
+ });
+ return {
+ sections: [
+ {
+ id: "all",
+ items: paged.items.map((preset) => toStickerItem({ preset })),
+ hasMore: paged.hasMore,
+ layout: "grid",
+ },
+ ],
+ };
+ },
+ resolveUrl({
+ stickerId,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const preset = parseShapeStickerId({ stickerId });
+ return buildShapeUrl({ shapeKey: preset?.shapeKey ?? "rectangle" });
+ },
+};
diff --git a/apps/web/src/stickers/registry.ts b/apps/web/src/stickers/registry.ts
new file mode 100644
index 00000000..a7d79df0
--- /dev/null
+++ b/apps/web/src/stickers/registry.ts
@@ -0,0 +1,10 @@
+import type { StickerProvider } from "@/stickers/types";
+import { DefinitionRegistry } from "@/params/registry";
+
+export class StickersRegistry extends DefinitionRegistry<string, StickerProvider> {
+ constructor() {
+ super("sticker provider");
+ }
+}
+
+export const stickersRegistry = new StickersRegistry();
diff --git a/apps/web/src/stickers/resolver.ts b/apps/web/src/stickers/resolver.ts
new file mode 100644
index 00000000..a7825bbc
--- /dev/null
+++ b/apps/web/src/stickers/resolver.ts
@@ -0,0 +1,20 @@
+import { stickersRegistry } from "./registry";
+import { parseStickerId } from "./sticker-id";
+import { registerDefaultStickerProviders } from "./providers";
+import type { StickerResolveOptions } from "@/stickers/types";
+
+export function resolveStickerId({
+ stickerId,
+ options,
+}: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+}): string {
+ registerDefaultStickerProviders();
+
+ const parsedStickerId = parseStickerId({ stickerId });
+ return stickersRegistry.get(parsedStickerId.providerId).resolveUrl({
+ stickerId,
+ options,
+ });
+}
diff --git a/apps/web/src/stickers/sticker-id.ts b/apps/web/src/stickers/sticker-id.ts
new file mode 100644
index 00000000..cb56db4c
--- /dev/null
+++ b/apps/web/src/stickers/sticker-id.ts
@@ -0,0 +1,34 @@
+export function parseStickerId({ stickerId }: { stickerId: string }): {
+ providerId: string;
+ providerValue: string;
+} {
+ const normalizedStickerId = stickerId.trim();
+ if (!normalizedStickerId) {
+ throw new Error("Sticker ID must be a non-empty string");
+ }
+
+ const separatorIndex = normalizedStickerId.indexOf(":");
+ if (
+ separatorIndex <= 0 ||
+ separatorIndex === normalizedStickerId.length - 1
+ ) {
+ throw new Error(
+ `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`,
+ );
+ }
+
+ const providerId = normalizedStickerId.slice(0, separatorIndex).trim();
+ const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim();
+
+ return { providerId, providerValue };
+}
+
+export function buildStickerId({
+ providerId,
+ providerValue,
+}: {
+ providerId: string;
+ providerValue: string;
+}): string {
+ return `${providerId}:${providerValue}`;
+}
diff --git a/apps/web/src/stickers/stickers-store.ts b/apps/web/src/stickers/stickers-store.ts
new file mode 100644
index 00000000..aacc5a79
--- /dev/null
+++ b/apps/web/src/stickers/stickers-store.ts
@@ -0,0 +1,238 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import {
+ browseAll,
+ browseCategory,
+ searchAll,
+ searchStickers as searchStickersFromProviders,
+} from "@/stickers";
+import type { StickerBrowseResult, StickerSearchResult } from "@/stickers";
+import { STICKER_CATEGORIES } from "@/stickers/categories";
+import type { StickerCategory } from "@/stickers/types";
+import { registerDefaultStickerProviders } from "@/stickers/providers";
+import { stickersRegistry } from "@/stickers/registry";
+import { parseStickerId } from "@/stickers/sticker-id";
+
+const MAX_RECENT_STICKERS = 50;
+let browseRequestVersion = 0;
+
+function isValidStickerId(value: unknown): value is string {
+ if (typeof value !== "string") {
+ return false;
+ }
+
+ try {
+ const parsed = parseStickerId({ stickerId: value });
+ return stickersRegistry.has(parsed.providerId);
+ } catch {
+ return false;
+ }
+}
+
+function sanitizeRecentStickers({
+ recentStickers,
+}: {
+ recentStickers: unknown;
+}): string[] {
+ registerDefaultStickerProviders({});
+
+ if (!Array.isArray(recentStickers)) {
+ return [];
+ }
+
+ const sanitized: string[] = [];
+ for (const stickerId of recentStickers) {
+ if (!isValidStickerId(stickerId)) {
+ continue;
+ }
+ if (sanitized.includes(stickerId)) {
+ continue;
+ }
+ sanitized.push(stickerId);
+ if (sanitized.length >= MAX_RECENT_STICKERS) {
+ break;
+ }
+ }
+
+ return sanitized;
+}
+
+type ViewMode = "search" | "browse";
+
+interface StickersStore {
+ searchQuery: string;
+ selectedCategory: StickerCategory;
+ viewMode: ViewMode;
+ searchResults: StickerSearchResult | null;
+ browseContent: StickerBrowseResult | null;
+ recentStickers: string[];
+ isSearching: boolean;
+ isBrowsing: boolean;
+
+ setSearchQuery: ({ query }: { query: string }) => void;
+ setSelectedCategory: ({ category }: { category: StickerCategory }) => void;
+ searchStickers: ({ query }: { query: string }) => Promise<void>;
+ browseStickers: () => Promise<void>;
+ addToRecentStickers: ({ stickerId }: { stickerId: string }) => void;
+ clearRecentStickers: () => void;
+}
+
+export const useStickersStore = create<StickersStore>()(
+ persist(
+ (set, get) => ({
+ searchQuery: "",
+ selectedCategory: "all",
+ viewMode: "browse",
+
+ searchResults: null,
+ browseContent: null,
+ recentStickers: [],
+
+ isSearching: false,
+ isBrowsing: false,
+
+ setSearchQuery: ({ query }) => set({ searchQuery: query }),
+
+ setSelectedCategory: ({ category }) => {
+ set({
+ selectedCategory: category in STICKER_CATEGORIES ? category : "all",
+ browseContent: null,
+ });
+
+ const query = get().searchQuery.trim();
+ if (query) {
+ void get().searchStickers({ query });
+ return;
+ }
+
+ void get().browseStickers();
+ },
+
+ searchStickers: async ({ query }: { query: string }) => {
+ const trimmedQuery = query.trim();
+ if (!trimmedQuery) {
+ set({ searchResults: null, viewMode: "browse" });
+ await get().browseStickers();
+ return;
+ }
+
+ const category = get().selectedCategory;
+ const selectedCategory =
+ category in STICKER_CATEGORIES ? category : "all";
+
+ set({ isSearching: true, viewMode: "search" });
+ try {
+ if (selectedCategory === "all") {
+ const browseContent = await searchAll({ query: trimmedQuery });
+ set({ browseContent, searchResults: null });
+ } else {
+ const results = await searchStickersFromProviders({
+ query: trimmedQuery,
+ category: selectedCategory,
+ limit: 100,
+ });
+ set({ searchResults: results });
+ }
+ } catch (error) {
+ console.error("Search failed:", error);
+ set({ searchResults: null });
+ } finally {
+ set({ isSearching: false });
+ }
+ },
+
+ browseStickers: async () => {
+ const version = ++browseRequestVersion;
+ const category = get().selectedCategory;
+ const selectedCategory =
+ category in STICKER_CATEGORIES ? category : "all";
+
+ set({ isBrowsing: true, viewMode: "browse" });
+ try {
+ const browseContent =
+ selectedCategory === "all"
+ ? await browseAll({
+ recentStickers: get().recentStickers,
+ })
+ : await browseCategory({
+ category: selectedCategory,
+ });
+
+ if (version !== browseRequestVersion) return;
+ set({ browseContent });
+ } catch (error) {
+ if (version !== browseRequestVersion) return;
+ console.error("Browse failed:", error);
+ set({ browseContent: null });
+ } finally {
+ if (version === browseRequestVersion) {
+ set({ isBrowsing: false });
+ }
+ }
+ },
+
+ addToRecentStickers: ({ stickerId }: { stickerId: string }) => {
+ const sanitizedStickerIds = sanitizeRecentStickers({
+ recentStickers: [stickerId],
+ });
+ if (sanitizedStickerIds.length === 0) {
+ return;
+ }
+
+ set((state) => {
+ const recent = [
+ sanitizedStickerIds[0],
+ ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]),
+ ];
+ return {
+ recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
+ };
+ });
+
+ if (get().viewMode === "browse" && get().selectedCategory === "all") {
+ void get().browseStickers();
+ }
+ },
+
+ clearRecentStickers: () => {
+ set({ recentStickers: [] });
+
+ if (get().viewMode === "browse" && get().selectedCategory === "all") {
+ void get().browseStickers();
+ }
+ },
+ }),
+ {
+ name: "stickers-settings",
+ version: 1,
+ migrate: (persistedState) => {
+ if (
+ typeof persistedState === "object" &&
+ persistedState !== null &&
+ "selectedCategory" in persistedState
+ ) {
+ const typedState = persistedState as {
+ selectedCategory?: string;
+ recentStickers?: string[];
+ };
+ const category = typedState.selectedCategory ?? "all";
+ return {
+ ...typedState,
+ selectedCategory:
+ category in STICKER_CATEGORIES
+ ? (category as StickerCategory)
+ : "all",
+ recentStickers: sanitizeRecentStickers({
+ recentStickers: typedState.recentStickers ?? [],
+ }),
+ };
+ }
+ return persistedState;
+ },
+ partialize: (state) => ({
+ selectedCategory: state.selectedCategory,
+ recentStickers: state.recentStickers,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/stickers/types.ts b/apps/web/src/stickers/types.ts
new file mode 100644
index 00000000..86aa4299
--- /dev/null
+++ b/apps/web/src/stickers/types.ts
@@ -0,0 +1,71 @@
+import type { STICKER_CATEGORIES } from "@/stickers/categories";
+
+export type StickerCategory = keyof typeof STICKER_CATEGORIES;
+
+export interface StickerItem {
+ id: string;
+ provider: string;
+ name: string;
+ previewUrl: string;
+ metadata: Record<string, unknown>;
+}
+
+export interface StickerSearchResult {
+ items: StickerItem[];
+ total: number;
+ hasMore: boolean;
+}
+
+export interface StickerBrowseSection {
+ id: string;
+ title?: string;
+ items: StickerItem[];
+ hasMore?: boolean;
+ layout?: "grid" | "row";
+ action?: {
+ type: "see-all";
+ category?: StickerCategory;
+ sectionId?: string;
+ };
+}
+
+export interface StickerBrowseResult {
+ sections: StickerBrowseSection[];
+}
+
+export interface StickerProviderSearchOptions {
+ limit?: number;
+}
+
+export interface StickerProviderBrowseOptions {
+ page?: number;
+ limit?: number;
+}
+
+export interface StickerResolveOptions {
+ width?: number;
+ height?: number;
+}
+
+export interface StickerProvider {
+ id: string;
+ search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: StickerProviderSearchOptions;
+ }): Promise<StickerSearchResult>;
+ browse({
+ options,
+ }: {
+ options?: StickerProviderBrowseOptions;
+ }): Promise<StickerBrowseResult>;
+ resolveUrl({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+ }): string;
+}