@taphubhq/sdk-react
v21.1.0
Published
React bindings for the TabHub Core SDK
Downloads
7,259
Readme
@taphubhq/sdk-react
React 18/19 bindings for @taphubhq/sdk-core.
Install
pnpm add @taphubhq/sdk-react @taphubhq/sdk-core @tanstack/react-query zustand lightweight-chartsMinimum Integration
import { TaphubProvider } from '@taphubhq/sdk-react';
import { TaphubClient } from '@taphubhq/sdk-core';
const client = new TaphubClient({
agencyId: 'acme-corp',
endpoint: 'https://api.example.com',
});
function App() {
return (
<TaphubProvider client={client}>
<YourApp />
</TaphubProvider>
);
}Hook Example
import { useGame } from '@taphubhq/sdk-react';
function GameInfo({ pair }: { pair: string }) {
const { data, isLoading } = useGame(pair);
if (isLoading) return <div>Loading...</div>;
return <div>{data?.pair} — {data?.status}</div>;
}Component Example
import { BidPanel } from '@taphubhq/sdk-react';
function TradingView() {
return <BidPanel pair="BTC-USDT" />;
}Tailwind Preset
To use the bundled Tailwind preset, add it to your Tailwind config:
// tailwind.config.js
module.exports = {
presets: [require('@taphubhq/sdk-react/tailwind.preset')],
content: ['./node_modules/@taphubhq/sdk-react/dist/**/*.{js,mjs}'],
};Locales (i18n)
Runtime UI translations served by your backend's Query.locales operation,
cached in-memory, with a bundled English baseline that renders before any
network round-trip completes.
Provider config
TaphubProvider accepts three new props that drive locale lifecycle:
<TaphubProvider
client={client}
lang="en" // active language code; default 'en'
pollIntervalMs={30000} // polling interval in ms; default 30000, min 5000
localeEnabled={true} // toggle locale polling on/off; default true
>
<App />
</TaphubProvider>All three props are optional. Omitting them mounts the provider with default
locale behaviour: bundled English baseline + 30s polling against the backend
configured in TaphubClient.
Reading translations — useT
import { useT } from '@taphubhq/sdk-react';
function Greeting() {
const title = useT('home.title');
const cta = useT('home.cta', 'Get started'); // optional fallback arg
return (
<header>
<h1>{title}</h1>
<button type="button">{cta}</button>
</header>
);
}useT(key, fallback?) lookup order (always returns a non-empty string):
- Active-language translations map
- Bundled English baseline shipped with the SDK
fallbackargument if providedkeyitself (last-resort, visible-but-broken signal for missing keys)
Reading + switching language — useLocale
import { useLocale } from '@taphubhq/sdk-react';
function LangSwitcher() {
const { lang, version, status, switchLang } = useLocale();
return (
<div>
<span>Active: {lang}</span>
<span>Status: {status}</span>
<button type="button" onClick={() => switchLang('zh-TW')}>繁中</button>
<button type="button" onClick={() => switchLang('en')}>English</button>
</div>
);
}status is one of:
'bundled'— serving the shipped English baseline (cold start or just-switched language)'live'— serving translations pulled from the backend;versionis set'stale-served'— last live data still rendered, but most recent fetch failed (network,FeatureDisabled,InsufficientUpstream, etc.). Consumer can decide whether to suppress translation-dependent UI.
Bundled English source
The bundled baseline lives at packages/sdk-react/src/i18n/en.json. Refresh
it from the live Google Sheet at SDK publish time — the bundled file is the
floor that renders when backend is unreachable, so it should track the
current Sheet to avoid stale-looking English on cold start.
A helper script lives at scripts/sync-bundled-en.mjs. Run before
pnpm changeset:
SYNC_GQL_URL=https://your-backend/grid-gql \
SYNC_GQL_TOKEN=<bearer> \
SYNC_AGENCY_ID=<your-agency> \
pnpm sync:bundled-enThe script fetches Query.locales(input: { lang: "en" }), parses the
JSON-string translations, and overwrites src/i18n/en.json. It logs the
diff (added / removed / changed keys) and exits non-zero on any failure
(unreachable backend, gql error, empty response). No silent overwrites.
Failure handling
The provider never throws to the render tree. Network errors, gql errors
with extensions.code: "FeatureDisabled", "InsufficientUpstream",
"LangNotSupported", or any other rejection flip the store to
'stale-served' and keep the prior translations rendering. Upstream-side
contract: see the n26-side handoff doc docs/multilang-locales.md.
Chart
The ChartContainer component renders a lightweight-charts@^5 candlestick chart with an interactive bid grid overlay. It pulls historical candles via REST (useChartHistory) and subscribes to MQTT candle ticks via useCandleHandler, writing all state into the game store managed by TaphubProvider.
pnpm add lightweight-chartsMinimum Integration
import { ChartContainer, TaphubProvider, useSyncGameStore } from '@taphubhq/sdk-react';
import { TaphubClient } from '@taphubhq/sdk-core';
import '@taphubhq/sdk-react/theme.css';
const client = new TaphubClient({
agencyId: 'acme-corp',
endpoint: 'https://api.example.com',
mqttEndpoint: 'wss://stream.example.com/ws',
});
function TradingView({ gameId }: { gameId: string }) {
useSyncGameStore(gameId);
const placeBid = async ({ time1, time2, price1, price2, coefficient }) => {
const ok = await client.bid.place({ gameId, time1, time2, price1, price2, coefficient });
return ok;
};
return (
<div style={{ width: '100%', height: 600, position: 'relative' }}>
<ChartContainer gameId={gameId} placeBid={placeBid} />
</div>
);
}
function App() {
return (
<TaphubProvider client={client}>
<TradingView gameId="V_ETH/USD" />
</TaphubProvider>
);
}Props
| Prop | Type | Description |
|---|---|---|
| gameId | string \| undefined | Game identifier. Drives REST history fetch and MQTT subscription. |
| placeBid | (p: { time1; time2; price1; price2; coefficient }) => Promise<boolean> | Called when user clicks a cell. Return true if accepted; on false the optimistic cell is removed. |
| className | string? | Overrides default position:relative; width:100%; height:100% wrapper styles. |
| onReady | (refs: ChartContainerRefs) => void? | Fired after the grid primitive is attached. Re-fires on structural re-attach. Use for debug/E2E instrumentation (see below). |
Notes
- Parent container must have a non-zero height (e.g.
height: 600pxor flex child) — the chart fills its parent. gridConfig,constraints,acceptableBids,orders,bidAmount,userId,isDemoare read from the store viaTaphubProvider. Keep them in sync withuseSyncGameStoreor by dispatching the store actions yourself.- Listens for
windowevent'center-chart'— dispatch it from a toolbar button to re-center the chart.
Accessing Internal Refs (Debug / E2E)
Use onReady to reach the underlying IChartApi, candlestick series, and CustomGridPrimitive — useful for Playwright E2E bridges or manual instrumentation. The callback is multi-shot: it re-fires whenever the primitive is re-attached due to grid structure changes.
import { ChartContainer, type ChartContainerRefs } from '@taphubhq/sdk-react';
function TradingView({ gameId }) {
const handleReady = (refs: ChartContainerRefs) => {
window.tap = {
chart: refs.chart,
series: refs.series,
primitive: refs.primitive,
};
};
return (
<div style={{ width: '100%', height: 600, position: 'relative' }}>
<ChartContainer gameId={gameId} placeBid={placeBid} onReady={handleReady} />
</div>
);
}Note: Always refresh your references on each
onReadycall —primitiveis recreated on structural re-attach and a stale reference will silently break.
Deprecated
PriceChart, CandleGrid, useCandleGrid, useCandleGridHook, useCandleGridRecenter, useCandleGridPushCandles, and useCandleGridStateFromStore are deprecated in favour of ChartContainer and will be removed in a future release.
Grid Primitive (Advanced)
For direct control over the grid overlay, use CustomGridPrimitive:
import { CustomGridPrimitive, createCustomGridOptions, centerChart } from '@taphubhq/sdk-react';
const gridOptions = createCustomGridOptions(serverGridConfig, constraints, (cell) => {
console.log('Cell clicked:', cell);
return true;
});
series.attachPrimitive(new CustomGridPrimitive(gridOptions, initialBids));
// Center chart on current price
centerChart(chart, candles, serverGridConfig);
// Bid lifecycle
primitive.bindBidId(bidId, coords);
primitive.updateBid({ id: bidId, status: 'won', payout: '12.5', ...coords });
primitive.removeBid(coords);
primitive.addBid(otherUserBid, avatarUrl);Coefficient Calculation
The SDK now includes full GBM probability math matching the Go backend:
import { calculateCoefficientWrapper, computeCellSizeValue, computeBaseline } from '@taphubhq/sdk-react';
const coef = calculateCoefficientWrapper({
time1, time2, price1, price2,
candleTime, candleClose, volatility,
coefMults, cellSizeTime, candleSize,
});
const cellSize = computeCellSizeValue(price, volatility);
const { baseline, baselineTime } = computeBaseline(close, timeSec, cellSize, cellSizeTime, candleSize);Theming
Chart and grid colours are driven by CSS custom properties. Override them in a higher-specificity selector:
:root {
--taphub-cell-won: 10 200 50;
--taphub-grid-line: 40 40 40;
--canvas-bid-tier-0-fill: rgba(60, 122, 255, 0.3);
--canvas-bid-tier-0-border: rgba(60, 122, 255, 0.6);
--canvas-cell-won-text: rgba(255, 255, 255, 1);
}The Tailwind preset exposes matching colour tokens (bg-taphub-cell-won, text-taphub-coefficient, etc.) that reference the same CSS variables.
Bid Surface
The SDK ships BidForm, BidHistory, and BidPanel components for the bid workflow.
Minimum Integration
import { BidPanel, TaphubProvider } from '@taphubhq/sdk-react';
import { TaphubClient } from '@taphubhq/sdk-core';
const client = new TaphubClient({ agencyId: 'acme-corp', endpoint: 'https://api.example.com' });
function App() {
return (
<TaphubProvider client={client}>
<BidPanel pair="BTC-USDT" />
</TaphubProvider>
);
}Headless Composition
Use BidForm and BidHistory independently with useBidFormFromStore:
import { BidForm, useBidFormFromStore, useSyncGameStore } from '@taphubhq/sdk-react';
function MyBidUI({ pair }: { pair: string }) {
const formProps = useBidFormFromStore();
useSyncGameStore(pair);
return (
<BidForm
pair={pair}
{...formProps}
selectedCell={mySelectedCell}
onBidPlaced={(bid) => console.log('Bid placed:', bid)}
/>
);
}Syncing Realtime Events
useSyncGameStore wires MQTT configUpdate and balanceUpdate events into the store automatically:
import { useSyncGameStore } from '@taphubhq/sdk-react';
function GameView({ gameId }: { gameId: string }) {
useSyncGameStore(gameId);
// The store now reflects min/max bid amounts, balance, etc.
}Bid Result Lifecycle
useBidResult subscribes to the MQTT bid_result and balance_update channel events and drives the full win/lose lifecycle automatically:
- Won → calls
resolveOrder(id, 'won', payout)→ChartContainershows confetti on the winning cell. - Lost → calls
resolveOrder(id, 'lost')→ cell dims to inactive state. - Cancelled → no store mutation; fires
onRejectedcallback. - Balance update → syncs store balance (guards against stale debit events).
Mount it alongside ChartContainer at the page level:
import { ChartContainer, useBidResult, useSyncGameStore } from '@taphubhq/sdk-react';
function TradingView({ gameId }: { gameId: string }) {
useSyncGameStore(gameId);
useBidResult(gameId, {
onWon: ({ bidId, payout }) => {
playWinSound();
showToast(`+${payout} USDT`);
},
onLost: ({ bidId }) => {
playLossSound();
},
onRejected: ({ bidId, reason }) => {
showToast(reason ?? 'Bet rejected', { type: 'error' });
},
});
return (
<div style={{ width: '100%', height: 600, position: 'relative' }}>
<ChartContainer gameId={gameId} placeBid={placeBid} />
</div>
);
}Callbacks
All callbacks are optional. Omit any you don't need.
| Callback | Payload | When |
|---|---|---|
| onWon | { bidId, payout? } | After resolveOrder('won') is dispatched |
| onLost | { bidId } | After resolveOrder('lost') is dispatched |
| onRejected | { bidId, reason? } | On cancelled status — no store change |
Note:
reasonisundefineduntil the wire protocol carries it; the field is reserved for future use.
Callback Stability
Callback identity is captured in a ref — passing a new inline function on each render does not re-subscribe the channel listener.
Cell Size Control
The cell-size control shows the active grid cell height and, when the server suggests a better value, lets the user accept it. Three integration tiers, easiest first.
Tier 1 — CellSizeButton (self-contained)
Drop-in. Brings its own floating layer (base-ui tooltip on mouse, popover on touch) — no host floating library needed.
import { CellSizeButton } from '@taphubhq/sdk-react';
function Toolbar({ cellSize, suggested }: { cellSize: number; suggested: number | null }) {
return (
<CellSizeButton
currentValue={cellSize}
idealValue={suggested} // null = no suggestion → info-only
onAccept={() => applyCellSize(suggested!)}
onDismiss={() => dismissSuggestion()}
/>
);
}Tier 2 — compound atoms with your own floating layer
If your app already uses a floating library (e.g. shadcn / Radix), compose the face (CellSizeButtonTrigger) and the content (CellSizeUpdater) yourself. The trigger is a <span> that forwards ref + props, so the host's TooltipTrigger/PopoverTrigger can wire it.
import { CellSizeButtonTrigger, CellSizeUpdater } from '@taphubhq/sdk-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
function CellSize({ cellSize, suggested, onAccept, onDismiss }) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* style is spread last → override cursor, etc. */}
<CellSizeButtonTrigger
currentValue={cellSize}
idealValue={suggested}
style={{ cursor: 'pointer' }}
/>
</TooltipTrigger>
<TooltipContent data-taphub-scope>
<CellSizeUpdater
currentValue={cellSize}
idealValue={suggested}
onAccept={onAccept}
onDismiss={onDismiss}
/>
</TooltipContent>
</Tooltip>
);
}Wrap SDK content in
data-taphub-scopeso the SDK's theme tokens (--surface-raised,--primary, …) resolve inside your portal.
Tier 3 — split atoms (value and info icon open different surfaces)
When the value and the (i) icon must trigger separate floating surfaces — value → accept popup, icon → explanation tooltip — use the granular atoms. CellSizeButtonTrigger renders both in one span and can't separate the click targets; these can.
| Atom | Renders | Pair with |
|---|---|---|
| CellSizeValue | value face only ({cur} or shimmering {cur} → {ideal}) | accept popup → CellSizeUpdater |
| CellSizeInfoIcon | the (i) glyph only | explanation tooltip → CellSizeInfo |
| CellSizeInfo | suggestion-aware info copy | inside the info tooltip |
import {
CellSizeValue,
CellSizeInfoIcon,
CellSizeInfo,
CellSizeUpdater,
} from '@taphubhq/sdk-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
function CellSize({ cellSize, suggested, onAccept, onDismiss }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{/* value → accept popup (click) */}
<Popover>
<PopoverTrigger asChild>
<CellSizeValue currentValue={cellSize} idealValue={suggested} style={{ cursor: 'pointer' }} />
</PopoverTrigger>
<PopoverContent data-taphub-scope>
<CellSizeUpdater
currentValue={cellSize}
idealValue={suggested}
onAccept={onAccept}
onDismiss={onDismiss}
/>
</PopoverContent>
</Popover>
{/* (i) icon → explanation tooltip (hover/click). CellSizeInfo is
suggestion-aware: it appends "You can switch to {ideal}…" only when
`suggested` is a pending suggestion. */}
<Tooltip>
<TooltipTrigger asChild>
<CellSizeInfoIcon />
</TooltipTrigger>
<TooltipContent data-taphub-scope>
<CellSizeInfo currentValue={cellSize} idealValue={suggested} />
</TooltipContent>
</Tooltip>
</span>
);
}API Reference
See the OpenSpec specs for detailed requirements and scenarios.
Leaderboard and Orders
useLeaderboard
Deprecated. Use
useRankBoard. The backendleaderboardquery is deprecated in favour ofrank_leaderboardV2.
Fetches the platform leaderboard for a given period and sort column. Returns a TanStack Query result.
import { useLeaderboard } from '@taphubhq/sdk-react';
function LeaderboardTable() {
const { data, isLoading } = useLeaderboard({ period: 'weekly', sortBy: 'gain' });
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{data?.map((entry) => (
<li key={entry.userId}>{entry.rank}. {entry.username} — {entry.gain}</li>
))}
</ul>
);
}For frequently-refreshing leaderboards, set a staleTime via TanStack Query options to avoid excessive polling:
const { data } = useLeaderboard({ period: 'weekly', sortBy: 'gain' }, {
staleTime: 30_000,
});useRankBoard (v2)
Fetches the duration-scoped rank board (rank_leaderboardV2). A single-page query: you drive offset/limit, total gives "#X of N", and me is the caller's own row+rank (null when anonymous). Returns a TanStack Query result holding a RankBoard.
import { useRankBoard } from '@taphubhq/sdk-react';
function RankBoardTable() {
const [offset, setOffset] = useState(0);
const limit = 50;
const { data, isLoading } = useRankBoard({ period: 'last_7d', sort: 'pnl', limit, offset });
if (isLoading || !data) return <div>Loading...</div>;
return (
<>
<ul>
{data.entries.map((e) => (
<li key={e.userId}>{e.rank}. {e.username} — pnl {e.pnl} / vol {e.vol}</li>
))}
</ul>
{data.me && <p>You are #{data.me.rank} of {data.total} (pnl {data.me.pnl})</p>}
<button disabled={offset === 0} onClick={() => setOffset((o) => Math.max(0, o - limit))}>Prev</button>
<button disabled={offset + limit >= data.total} onClick={() => setOffset((o) => o + limit)}>Next</button>
</>
);
}For frequently-refreshing boards, set a staleTime on your QueryClient defaults (or the ['taphub', 'rankBoard'] key) to avoid excessive polling, e.g. defaultOptions: { queries: { staleTime: 30_000 } } when creating the client.
The custom period needs an hour-aligned UTC range (from/to as ISO 8601 strings); preset periods ignore range. me is populated only for authenticated callers — anon visitors get the board with me: null.
useRankBoard({
period: 'custom',
range: { from: '2026-06-01T00:00:00Z', to: '2026-06-02T00:00:00Z' },
sort: 'vol',
sortDir: 'desc',
});useActiveBids
Fetches the authenticated user's pending bids once after auth and hydrates them into useGameStore via addOrder. Use it once near your app root so pending bids survive page refresh.
import { useActiveBids } from '@taphubhq/sdk-react';
function HydratePendingBids() {
useActiveBids();
return null;
}The hook is a no-op until useGameStore.authToken is set. Errors are logged to console.error and swallowed.
useOrders
Paginates the authenticated user's order history using useInfiniteQuery. Returns { orders, hasMore, fetchNextPage, ... }.
import { useOrders } from '@taphubhq/sdk-react';
function OrdersList() {
const { orders, hasMore, fetchNextPage, isFetchingNextPage } = useOrders({ filter: 'all' });
return (
<div>
{orders.map((order) => (
<div key={order.id}>{order.status} — {order.amount}</div>
))}
{hasMore && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load more
</button>
)}
</div>
);
}useWallets / useWalletByCurrency
Read the current user's wallet balances via the user-service GraphQL endpoint. Thin wrappers around TanStack Query.
import { useWallets, useWalletByCurrency } from '@taphubhq/sdk-react';
import type { UserWallet } from '@taphubhq/sdk-react';
function BalancePanel() {
const { data: wallets, isLoading } = useWallets();
if (isLoading) return <span>Loading…</span>;
return (
<ul>
{wallets?.map((w: UserWallet) => (
<li key={w.id}>{w.currency}: {w.amount}</li>
))}
</ul>
);
}
function UsdcBalance({ currency }: { currency: string }) {
// Auto-disables when `currency` is empty / undefined.
const { data: wallet } = useWalletByCurrency(currency);
return <span>{wallet?.amount ?? '—'}</span>;
}Both hooks accept an optional { enabled?: boolean } second argument and forward the TanStack Query AbortSignal to the underlying GraphQL call so unmount cancels in-flight requests. Cache keys: ['taphub','wallets'] and ['taphub','wallet',currency].
Note:
wallet.amountis astring(preserves Decimal precision on the wire). Do not coerce blindly viaNumber(...)/parseFloat(...)for large or precision-sensitive balances — use a Decimal library (big.js,decimal.js) if exact arithmetic matters.
This hook does not invalidate on bid placement; balance shown may lag server state until next mount / window refocus. Wire your own invalidation (or layer a realtime push hook) if your UI is trading-critical.
useNetworkQuality
Subscribes to client.network (passive monitor in @taphubhq/sdk-core) and re-renders only when the committed network level OR backend health changes.
import { useNetworkQuality } from '@taphubhq/sdk-react';
function NetworkBanner() {
const { network, backend } = useNetworkQuality();
if (network === 'offline') return <Banner>Offline</Banner>;
if (backend === 'down') return <Banner>Servers having trouble</Banner>;
if (network === 'poor') return <Banner>Weak connection</Banner>;
return null;
}Returns the full NetworkQuality shape (rtt, jitter, lossRate, mqttConnected, samplesInWindow, lastUpdated) — pick the fields you need. SSR-safe: the hook returns a deterministic snapshot during server rendering. The network (good/fair/poor/offline) and backend (ok/degraded/down) signals are independent — backend incidents do not pollute the network indicator.
sortOrders
Pure helper that sorts bids: pending-first, then by time2 descending, then by createdAt descending.
import { useOrders, sortOrders } from '@taphubhq/sdk-react';
const { orders } = useOrders({ filter: 'all' });
const sorted = sortOrders(orders);