import { Button, Combobox, Field } from "@tailor-platform/app-shell"; import { ChevronDown, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useClient } from "urql"; import { graphql } from "@/graphql"; import { useT } from "@/i18n/labels"; type SelectOption = { id: string; label: string }; type SelectPage = { items: SelectOption[]; hasNextPage: boolean; endCursor: string | null }; const CurrenciesQuery = graphql(` query BPCurrencyOptions($query: CurrencyQueryInput, $after: String) { currencies(query: $query, first: 20, after: $after, order: [{ field: code, direction: Asc }]) { pageInfo { hasNextPage endCursor } edges { node { id code } } } } `); export function CurrencyField({ name, label, value, required, }: { name: string; label: string; value?: SelectOption | null; required?: boolean; }) { const t = useT(); const client = useClient(); const [selected, setSelected] = useState(value ?? null); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [after, setAfter] = useState(); const [page, setPage] = useState({ items: [], hasNextPage: false, endCursor: null }); const [fetching, setFetching] = useState(false); const [error, setError] = useState(false); const [retry, setRetry] = useState(0); useEffect(() => { if (!open) return; const controller = new AbortController(); const fetchPage = async (search: string, after: string | undefined, signal: AbortSignal) => { const result = await client .query( CurrenciesQuery, { after, query: search ? { or: [{ code: { contains: search } }, { name: { contains: search } }] } : undefined, }, { requestPolicy: "network-only", fetchOptions: { signal } }, ) .toPromise(); if (result.error) throw result.error; if (!result.data) throw new Error("No currency data returned"); const page = result.data.currencies; return { ...page.pageInfo, items: page.edges.map(({ node }) => ({ id: node.id, label: node.code })), }; }; const timer = setTimeout( () => { void fetchPage(search.trim(), after, controller.signal) .then((next) => { if (controller.signal.aborted) return; setPage((previous) => ({ ...next, items: after ? [ ...previous.items, ...next.items.filter( (item) => !previous.items.some((old) => old.id === item.id), ), ] : next.items, })); }) .catch(() => { if (!controller.signal.aborted) setError(true); }) .finally(() => { if (!controller.signal.aborted) setFetching(false); }); }, after ? 0 : 200, ); return () => { clearTimeout(timer); controller.abort(); }; }, [open, search, after, retry, client]); const more = () => { if (!fetching && !error && page.hasNextPage && page.endCursor && page.endCursor !== after) { setFetching(true); setAfter(page.endCursor); } }; return ( (required && !selected ? t("bp.selectRequired") : null)}> {label} items={page.items} value={selected} itemToStringLabel={(option) => option.label} itemToStringValue={(option) => option.id} filter={null} onValueChange={(option) => { setSelected(option); }} onInputValueChange={(input, details) => { if (details.reason !== "input-change") return; setFetching(true); setError(false); setSearch(input); setAfter(undefined); setPage({ items: [], hasNextPage: false, endCursor: null }); }} onOpenChange={(next, details) => { setOpen(next); if (next) { setError(false); if (details.reason !== "input-change") setSearch(""); setAfter(undefined); setPage({ items: [], hasNextPage: false, endCursor: null }); setFetching(true); } }} > {selected && ( )} { const node = event.currentTarget; if (node.scrollHeight - node.scrollTop - node.clientHeight < 40) more(); }} > {(option: SelectOption) => ( {option.label} )} {!fetching && !error && page.items.length === 0 && (

{t("bp.empty")}

)} {fetching && (

{t("bp.loading")}

)} {error && (

{t("bp.loadFailed")}

)} {!fetching && !error && page.hasNextPage && ( )}
{name && }
); }