diff --git a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx index 5fb7f46ecad..156792ec7e5 100644 --- a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx +++ b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx @@ -12,11 +12,14 @@ import type React from "react"; import { useId, useState } from "react"; import type { Address } from "thirdweb"; import { defineChain } from "thirdweb/chains"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { cn } from "../../../../lib/utils"; +import { TokenSelector } from "@/components/ui/TokenSelector"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import type { TokenMetadata } from "@/lib/types"; import { CollapsibleSection } from "../../sign-in/components/CollapsibleSection"; import { ColorFormGroup } from "../../sign-in/components/ColorFormGroup"; import type { BridgeComponentsPlaygroundOptions } from "../components/types"; @@ -39,17 +42,27 @@ export function LeftSection(props: { })); }; - const [tokenAddress, setTokenAddress] = useState( - payOptions.buyTokenAddress || "", - ); + // Shared state for chain and token selection (used by both Buy and Checkout modes) + const [selectedChain, setSelectedChain] = useState(() => { + return payOptions.buyTokenChain?.id; + }); + + const [selectedToken, setSelectedToken] = useState< + { chainId: number; address: string } | undefined + >(() => { + if (payOptions.buyTokenAddress && payOptions.buyTokenChain?.id) { + return { + address: payOptions.buyTokenAddress, + chainId: payOptions.buyTokenChain.id, + }; + } + return undefined; + }); const payModeId = useId(); const buyTokenAmountId = useId(); - const buyTokenChainId = useId(); - const tokenAddressId = useId(); const sellerAddressId = useId(); const paymentAmountId = useId(); - const directPaymentChainId = useId(); const modalTitleId = useId(); const modalTitleIconId = useId(); const modalDescriptionId = useId(); @@ -57,6 +70,37 @@ export function LeftSection(props: { const cryptoPaymentId = useId(); const cardPaymentId = useId(); + const handleChainChange = (chainId: number) => { + setSelectedChain(chainId); + // Clear token selection when chain changes + setSelectedToken(undefined); + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAddress: undefined, + buyTokenChain: defineChain(chainId), // Clear token when chain changes + }, + })); + }; + + const handleTokenChange = (token: TokenMetadata) => { + const newSelectedToken = { + address: token.address, + chainId: token.chainId, + }; + setSelectedToken(newSelectedToken); + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAddress: token.address as Address, + }, + })); + }; + return (
- {/* Conditional form fields based on selected mode */} -
- {/* Fund Wallet Mode Options */} + {/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */} + {(!payOptions.widget || + payOptions.widget === "buy" || + payOptions.widget === "checkout") && ( +
+ {/* Chain selection */} +
+ + +
+ + {/* Token selection - only show if chain is selected */} + {selectedChain && ( +
+ + +
+ )} +
+ )} + + {/* Mode-specific form fields */} +
+ {/* Buy Mode - Amount and Payment Methods */} {(!payOptions.widget || payOptions.widget === "buy") && (
-
-
- - - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAmount: e.target.value, - }, - })) - } - placeholder="0.01" - value={payOptions.buyTokenAmount || ""} - /> -
+
+ + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAmount: e.target.value, + }, + })) + } + placeholder="0.01" + value={payOptions.buyTokenAmount || ""} + /> +
- {/* Chain selection */} -
- - { - const chainId = Number.parseInt(e.target.value); - if (!Number.isNaN(chainId)) { - const chain = defineChain(chainId); + {/* Payment Methods */} +
+ +
+
+ { setOptions((v) => ({ ...v, payOptions: { ...v.payOptions, - buyTokenChain: chain, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), + "crypto", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), }, })); - } - }} - placeholder="1 (Ethereum)" - type="text" - value={payOptions.buyTokenChain?.id || ""} - /> -
-
- - {/* Token selection for fund_wallet mode */} -
-
-
-
- - { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAddress: e.target.value as Address, - }, - })); - }} - placeholder="0x..." - value={payOptions.buyTokenAddress} - /> -
+ }} + /> +
- - {/* Payment Methods */} -
- -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - "crypto", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - }, - })); - }} - /> - -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - "card", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - }, - })); - }} - /> - -
-
+
+ { + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + "card", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + }, + })); + }} + /> +
)} - {/* Direct Payment Mode Options */} + {/* Checkout Mode - Seller Address, Price and Payment Methods */} {payOptions.widget === "checkout" && (
@@ -250,125 +273,80 @@ export function LeftSection(props: { />
-
-
- - - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAmount: e.target.value, - }, - })) - } - placeholder="0.01" - value={payOptions.buyTokenAmount || ""} - /> -
+
+ + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAmount: e.target.value, + }, + })) + } + placeholder="0.01" + value={payOptions.buyTokenAmount || ""} + /> +
- {/* Chain selection */} -
- - { - const chainId = Number.parseInt(e.target.value); - if (!Number.isNaN(chainId)) { - const chain = defineChain(chainId); + {/* Payment Methods */} +
+ +
+
+ { setOptions((v) => ({ ...v, payOptions: { ...v.payOptions, - buyTokenChain: chain, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), + "crypto", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), }, })); - } - }} - placeholder="1 (Ethereum)" - type="number" - value={payOptions.buyTokenChain?.id || ""} - /> -
-
- - {/* Token selection for direct_payment mode - shares state with fund_wallet mode */} -
-
-
-
- - setTokenAddress(e.target.value)} - placeholder="0x..." - value={tokenAddress} - /> -
+ }} + /> +
- - {/* Payment Methods */} -
- -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - "crypto", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - }, - })); - }} - /> - -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - "card", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - }, - })); - }} - /> - -
-
+
+ { + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + "card", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + }, + })); + }} + /> +
diff --git a/apps/playground-web/src/app/token-selector-demo/page.tsx b/apps/playground-web/src/app/token-selector-demo/page.tsx new file mode 100644 index 00000000000..dbeceb2e44d --- /dev/null +++ b/apps/playground-web/src/app/token-selector-demo/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { arbitrum, base, ethereum } from "thirdweb/chains"; +import { PageLayout } from "@/components/blocks/APIHeader"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { TokenSelector } from "@/components/ui/TokenSelector"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import type { TokenMetadata } from "@/lib/types"; + +export default function TokenSelectorDemo() { + const [selectedToken, setSelectedToken] = useState< + { chainId: number; address: string } | undefined + >(undefined); + + const [selectedChain, setSelectedChain] = useState(ethereum.id); + + const chains = [ + { id: ethereum.id, name: "Ethereum" }, + { id: base.id, name: "Base" }, + { id: arbitrum.id, name: "Arbitrum" }, + ]; + + return ( + + +
+
+

Select a Chain

+ +
+ +
+

Select a Token

+
+ { + setSelectedToken({ + address: token.address, + chainId: token.chainId, + }); + }} + placeholder="Select a token" + selectedToken={selectedToken} + /> +
+
+ + {selectedToken && ( +
+

Selected Token

+
+

+ Chain ID: {selectedToken.chainId} +

+

+ Address: {selectedToken.address} +

+
+
+ )} +
+
+
+ ); +} diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx index 828034f16ce..ce5c77ec17f 100644 --- a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx +++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { useAllChainsData } from "../../app/hooks/chains"; +import { SelectWithSearch } from "../ui/select-with-search"; import { ChainIcon } from "./ChainIcon"; import { MultiSelect } from "./multi-select"; @@ -127,3 +128,115 @@ export function MultiNetworkSelector(props: { /> ); } + +export function SingleNetworkSelector(props: { + chainId: number | undefined; + onChange: (chainId: number) => void; + className?: string; + popoverContentClassName?: string; + // if specified - only these chains will be shown + chainIds?: number[]; + side?: "left" | "right" | "top" | "bottom"; + disableChainId?: boolean; + align?: "center" | "start" | "end"; + disableTestnets?: boolean; + placeholder?: string; +}) { + const { allChains, idToChain } = useAllChainsData().data; + + const chainsToShow = useMemo(() => { + let chains = allChains; + + if (props.disableTestnets) { + chains = chains.filter((chain) => !chain.testnet); + } + + if (props.chainIds) { + const chainIdSet = new Set(props.chainIds); + chains = chains.filter((chain) => chainIdSet.has(chain.chainId)); + } + + return chains; + }, [allChains, props.chainIds, props.disableTestnets]); + + const options = useMemo(() => { + return chainsToShow.map((chain) => { + return { + label: cleanChainName(chain.name), + value: String(chain.chainId), + }; + }); + }, [chainsToShow]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return false; + } + + if (Number.isInteger(Number.parseInt(searchValue))) { + return String(chain.chainId).startsWith(searchValue); + } + return chain.name.toLowerCase().includes(searchValue.toLowerCase()); + }, + [idToChain], + ); + + const renderOption = useCallback( + (option: Option) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return option.label; + } + + return ( +
+ + + {cleanChainName(chain.name)} + + + {!props.disableChainId && ( + + Chain ID + {chain.chainId} + + )} +
+ ); + }, + [idToChain, props.disableChainId], + ); + + const isLoadingChains = allChains.length === 0; + + return ( + { + props.onChange(Number(chainId)); + }} + options={options} + overrideSearchFn={searchFn} + placeholder={ + isLoadingChains + ? "Loading Chains..." + : props.placeholder || "Select Chain" + } + popoverContentClassName={props.popoverContentClassName} + renderOption={renderOption} + searchPlaceholder="Search by Name or Chain ID" + showCheck={false} + side={props.side} + value={props.chainId?.toString()} + /> + ); +} diff --git a/apps/playground-web/src/components/ui/TokenSelector.tsx b/apps/playground-web/src/components/ui/TokenSelector.tsx new file mode 100644 index 00000000000..f1d55616de9 --- /dev/null +++ b/apps/playground-web/src/components/ui/TokenSelector.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { CoinsIcon } from "lucide-react"; +import { useCallback, useMemo } from "react"; +import { + getAddress, + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, +} from "thirdweb"; +import { shortenAddress } from "thirdweb/utils"; +import { Badge } from "@/components/ui/badge"; +import { Img } from "@/components/ui/Img"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTokensData } from "@/hooks/useTokensData"; +import type { TokenMetadata } from "@/lib/types"; +import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils"; +import { useAllChainsData } from "../../app/hooks/chains"; + +const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + +export function TokenSelector(props: { + selectedToken: { chainId: number; address: string } | undefined; + onChange: (token: TokenMetadata) => void; + className?: string; + chainId?: number; + disableAddress?: boolean; + placeholder?: string; + client: ThirdwebClient; + disabled?: boolean; + enabled?: boolean; + addNativeTokenIfMissing: boolean; +}) { + const tokensQuery = useTokensData({ + chainId: props.chainId, + enabled: props.enabled, + }); + + const { idToChain } = useAllChainsData().data; + + const tokens = useMemo(() => { + if (!tokensQuery.data) { + return []; + } + + if (props.addNativeTokenIfMissing) { + const hasNativeToken = tokensQuery.data.some( + (token) => token.address === checksummedNativeTokenAddress, + ); + + if (!hasNativeToken && props.chainId) { + return [ + { + address: checksummedNativeTokenAddress, + chainId: props.chainId, + decimals: 18, + name: + idToChain.get(props.chainId)?.nativeCurrency.name ?? + "Native Token", + symbol: + idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH", + } satisfies TokenMetadata, + ...tokensQuery.data, + ]; + } + } + return tokensQuery.data; + }, [ + tokensQuery.data, + props.chainId, + props.addNativeTokenIfMissing, + idToChain, + ]); + + const addressChainToToken = useMemo(() => { + const value = new Map(); + for (const token of tokens) { + value.set(`${token.chainId}:${token.address}`, token); + } + return value; + }, [tokens]); + + const selectedValue = props.selectedToken + ? `${props.selectedToken.chainId}:${props.selectedToken.address}` + : undefined; + + const renderTokenOption = useCallback( + (token: TokenMetadata) => { + const resolvedSrc = token.iconUri + ? replaceIpfsUrl(token.iconUri, props.client) + : fallbackChainIcon; + + return ( +
+ + } + key={resolvedSrc} + loading="lazy" + skeleton={ +
+ } + src={resolvedSrc} + /> + {token.symbol} + + + {!props.disableAddress && ( + + Address + {shortenAddress(token.address, 4)} + + )} +
+ ); + }, + [props.disableAddress, props.client], + ); + + return ( + + ); +} diff --git a/apps/playground-web/src/components/ui/select-with-search.tsx b/apps/playground-web/src/components/ui/select-with-search.tsx new file mode 100644 index 00000000000..c4182109e5a --- /dev/null +++ b/apps/playground-web/src/components/ui/select-with-search.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import React, { useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; +import { cn } from "@/lib/utils"; + +interface SelectWithSearchProps + extends React.ButtonHTMLAttributes { + options: { + label: string; + value: string; + }[]; + value: string | undefined; + onValueChange: (value: string) => void; + placeholder: string; + searchPlaceholder?: string; + className?: string; + overrideSearchFn?: ( + option: { value: string; label: string }, + searchTerm: string, + ) => boolean; + renderOption?: (option: { value: string; label: string }) => React.ReactNode; + popoverContentClassName?: string; + side?: "left" | "right" | "top" | "bottom"; + align?: "center" | "start" | "end"; + closeOnSelect?: boolean; + showCheck?: boolean; +} + +export const SelectWithSearch = React.forwardRef< + HTMLButtonElement, + SelectWithSearchProps +>( + ( + { + options, + onValueChange, + placeholder, + className, + value, + renderOption, + overrideSearchFn, + popoverContentClassName, + searchPlaceholder, + closeOnSelect, + showCheck = true, + ...props + }, + ref, + ) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(""); + const selectedOption = useMemo( + () => options.find((option) => option.value === value), + [options, value], + ); + + const optionsToShow = useMemo(() => { + const filteredOptions: { + label: string; + value: string; + }[] = []; + + const searchValLowercase = searchValue.toLowerCase(); + + for (const option of options) { + if (overrideSearchFn) { + if (overrideSearchFn(option, searchValLowercase)) { + filteredOptions.push(option); + } + } else { + if (option.label.toLowerCase().includes(searchValLowercase)) { + filteredOptions.push(option); + } + } + } + + return filteredOptions; + }, [options, searchValue, overrideSearchFn]); + + const popoverElRef = useRef(null); + + return ( + + + + + + setIsPopoverOpen(false)} + ref={popoverElRef} + side={props.side} + sideOffset={10} + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + width: "var(--radix-popover-trigger-width)", + }} + > +
+ {/* Search */} +
+ { + setSearchValue(e.target.value); + const scrollContainer = + popoverElRef.current?.querySelector("[data-scrollable]"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + }); + } + }} + placeholder={searchPlaceholder || "Search"} + value={searchValue} + /> + +
+ + + {/* List */} +
+ {optionsToShow.length === 0 && ( +
+ No results found +
+ )} + + {optionsToShow.map((option) => { + const isSelected = value === option.value; + return ( + + ); + })} +
+
+
+
+
+ ); + }, +); + +SelectWithSearch.displayName = "SelectWithSearch"; diff --git a/apps/playground-web/src/components/ui/select.tsx b/apps/playground-web/src/components/ui/select.tsx index 61bced4bad5..d2587e781c3 100644 --- a/apps/playground-web/src/components/ui/select.tsx +++ b/apps/playground-web/src/components/ui/select.tsx @@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef< >(({ className, children, chevronClassName, ...props }, ref) => ( span]:line-clamp-1", + "flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:w-full", className, )} ref={ref} @@ -119,7 +119,7 @@ const SelectItem = React.forwardRef< >(({ className, children, ...props }, ref) => ( span]:w-full", className, )} ref={ref} diff --git a/apps/playground-web/src/hooks/useTokensData.ts b/apps/playground-web/src/hooks/useTokensData.ts new file mode 100644 index 00000000000..4cab3dec344 --- /dev/null +++ b/apps/playground-web/src/hooks/useTokensData.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { TokenMetadata } from "@/lib/types"; + +async function fetchTokensFromApi(chainId?: number) { + const domain = process.env.NEXT_PUBLIC_BRIDGE_URL; + const url = new URL(`https://${domain}/v1/tokens`); + + if (chainId) { + url.searchParams.append("chainId", String(chainId)); + } + url.searchParams.append("limit", "1000"); + + const res = await fetch(url.toString(), { + headers: { + "Content-Type": "application/json", + "x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "", + }, + }); + + if (!res.ok) { + throw new Error("Failed to fetch tokens"); + } + + const json = await res.json(); + if (json.error) { + throw new Error(json.error.message); + } + + return json.data as Array; +} + +export function useTokensData({ + chainId, + enabled = true, +}: { + chainId?: number; + enabled?: boolean; +}) { + return useQuery({ + enabled, + queryFn: () => fetchTokensFromApi(chainId), + queryKey: ["tokens", chainId], // 2 minutes + staleTime: 1000 * 60 * 2, + }); +} diff --git a/apps/playground-web/src/lib/types.ts b/apps/playground-web/src/lib/types.ts new file mode 100644 index 00000000000..608f81ce14d --- /dev/null +++ b/apps/playground-web/src/lib/types.ts @@ -0,0 +1,8 @@ +export type TokenMetadata = { + name: string; + symbol: string; + address: string; + decimals: number; + chainId: number; + iconUri?: string; +}; diff --git a/apps/playground-web/src/lib/utils.ts b/apps/playground-web/src/lib/utils.ts index 365058cebd7..3e622502982 100644 --- a/apps/playground-web/src/lib/utils.ts +++ b/apps/playground-web/src/lib/utils.ts @@ -1,6 +1,30 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import type { ThirdwebClient } from "thirdweb"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function replaceIpfsUrl(url: string, client: ThirdwebClient): string { + if (!url) return ""; + + // Use thirdweb's IPFS gateway if the URL is an IPFS URL + if (url.startsWith("ipfs://")) { + const hash = url.replace("ipfs://", ""); + return `${ + client.config?.storage?.gatewayUrl || "https://ipfs.io/ipfs/" + }${hash}`; + } + + // If it's already an HTTP URL, return as-is + if (url.startsWith("http")) { + return url; + } + + // Fallback to original URL + return url; +} + +export const fallbackChainIcon = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iMTYiIGZpbGw9IiM5Q0EzQUYiLz4KPHN2ZyB4PSI2IiB5PSI2IiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IiNGRkZGRkYiLz4KPHN2Zz4KPHN2Zz4K";