@tiledev/sdk-apptile-live-selling
v0.2.0
Published
Live selling, auctions and giveaways for React Native — the Apptile live-selling gateway as a typed singleton, a Zego room engine behind a platform boundary, an assembled session state machine, and system picture-in-picture with its own config plugin. Com
Readme
@tiledev/sdk-apptile-live-selling
Live selling, auctions and giveaways for React Native — the Apptile live-selling gateway as a typed singleton, a Zego room engine, an assembled session state machine, and system picture-in-picture with its own config plugin.
Headless. It ships one component (LiveVideoTarget, because a render target is unavoidably
native). Your header, comments list, bid bar and sheets stay yours.
Storefront-agnostic. Products, cart writes and the auction checkout come from an adapter you supply, so there is no Shopify dependency.
npm i @tiledev/sdk-apptile-live-sellingPeers: react and react-native are needed by the main entry, which re-exports the React layer;
expo and zego-express-engine-reactnative stay optional — off-device the engine and PIP resolve to
no-ops.
Everything ships from the package root, including the providers, the hooks and LiveVideoTarget.
There is no ./react subpath — a subpath is reachable only through the exports map, which a
resolver that ignores it (node10, some test runners and bundlers) cannot see at all. What remains
beside the root is ./types (types alone) and a wildcard for any module in dist — /session,
/streams, /polls — the escape hatch below.
The root entry pulls in
react-native, so it no longer loads in a plain Node runtime. Driving the pure data layer from Node —sessionReducer, the wire mappers — goes through the module subpaths (@tiledev/sdk-apptile-live-selling/session), which stay free of React and react-native.
1. Add the config plugin
Every native entry live selling needs lives in a file expo prebuild regenerates, so it has to be a
plugin — an edit by hand works on the machine that made it and silently stops working in CI.
{
"expo": {
"plugins": [
["@tiledev/sdk-apptile-live-selling", {
"cameraPermission": "Used to appear on camera when you co-host a live show."
}]
]
}
}| Prop | Default | What it writes |
|---|---|---|
| pip | true | android:supportsPictureInPicture, the configChanges a PIP resize needs, UIBackgroundModes: ["audio"] |
| cameraPermission / microphonePermission | generic copy | NSCameraUsageDescription / NSMicrophoneUsageDescription — never overwrites strings your app already sets |
| capturePermissions | true | Android CAMERA + RECORD_AUDIO |
Zego's own Maven repo is added unconditionally: im.zego:express-video lives only there, and a clean
Android checkout fails at configuration time without it.
Then npx expo prebuild and run the app. The LivePip Expo module autolinks itself.
If your app already vendors a
live-pipmodule, delete it — identical module and pod names collide at link time.
2. Configure
Every viewer endpoint is anonymous: the brand companyId is the only credential, sent as
x-company-id. It is not the Shopify store id and not the Tile app id.
import { liveSelling } from '@tiledev/sdk-apptile-live-selling';
liveSelling.configure({
apiUrl: 'https://live-selling.apptile.io',
companyId: '<brand-id>',
zegoAppId: 361108744, // platform-level: the gateway signs room tokens with one Zego app
commerce, // see §4
identity,
});| Field | Required | Purpose |
|---|---|---|
| apiUrl | yes | Gateway base URL, no trailing slash |
| companyId | yes | Apptile "brand id" — the only credential |
| zegoAppId | for video | Matches the gateway, not the brand. Native engine only |
| commerce | for shopping | Resolves lots to products and sells them |
| identity | for bidding | The customer a win is attributed to |
| storage | no | AsyncStorage-shaped. Defaults to localStorage, else in-memory |
| videoCollectionId | no | The clip reel. Not scoped by the company header, so a stale id serves another tenant's clips — unset ⇒ clips.list() is [] |
| requestSource | no | X-Request-Source. Defaults to "APPTILE" |
| logger | no | { error }. Defaults to silent |
| customerAccountRequest | no | The customer's bearer transport. Only the unpaid-wins read uses it |
Pass real storage in anything you ship. Three things are lost on relaunch without it: the name a
signed-out shopper gave, the accepted auction terms, and which polls this install voted in. The last one
does damage — the gateway does not dedupe votes, so a forgotten vote lets the same device vote again.
3. Mount and render
LiveSellingProvider configures the client. LiveSessionProvider folds the engine's events and the
polled record into one renderable state — mount it above your navigator, because the Zego engine
holds exactly one room and two places usually render the same stream (a home card and the full-screen
player). Own it per screen and whichever unmounts first kills the other's video.
import {
LiveSellingProvider, LiveSessionProvider, useLiveSession, LiveVideoTarget,
} from '@tiledev/sdk-apptile-live-selling';
<LiveSellingProvider config={config}>
<LiveSessionProvider onPipRestore={() => navigate('Live')}>
<Navigator />
</LiveSessionProvider>
</LiveSellingProvider>Surfaces don't own the video, they bind to it:
function LiveScreen() {
const session = useLiveSession();
const isFocused = useIsFocused();
return (
<LiveVideoTarget active={isFocused && session.pipMode === 'none'}>
{session.status !== 'playing' && <YourPoster status={session.status} />}
</LiveVideoTarget>
);
}active is a prop rather than read from navigation focus, so the package needs no navigator
dependency — and a PIP surface drawn outside the navigator has no route to read focus from anyway.
Exactly one mounted target should have it set.
What useLiveSession() gives you
| | |
|---|---|
| status | loading · connecting · waitingForHost · playing · ended · error |
| stream | the record: title, host, products, giveaways, bidCap, reactionStyle |
| activeProduct products | resolved through your commerce adapter |
| comments reactions sold poll | the live feed; reactions and sold cards expire on their own |
| auction | biddingOpen, highestBid, nextBid, timeLeftMs, isLeading, leaderName, extended, lastWin |
| giveaway | active, entryCount, registered, result |
| guest | co-host: mode, status, slot, mutedByHost, mixerLayout |
| viewerCount elapsedMs muted pipMode viewerName | header material |
| actions | sendComment, sendReaction, addToCart, voteInPoll, placeBid, enterGiveaway, enterPip, joinAsGuest, … |
actions is identity-stable — safe in a dependency array, and deliberately so: one of the effects that
depends on it rebinds the video, which is a real startPlayingStream.
viewerName is null when nobody has said who they are. Ask before the first comment goes out rather
than publishing "Anonymous" to a room; actions.chooseViewerName(raw) remembers the answer.
capabilitiesFor(stream) returns the flag set a stream calls for (canBid, canEnterGiveaway,
showLiveBadge, …), so one screen serves an ordinary live show and an auction. An auction is a live show
plus bidding — the flags are derived, not spelled out twice.
4. Adapters
The gateway only ever speaks in bare numeric product ids, because that is what the host dashboard records. Turning one into something with a price, an image and variants is your job — and so is selling.
const commerce = {
productsByIds: async (ids) => {
const products = await shopify.products.byIds(ids.map((id) => `gid://shopify/Product/${id}`));
return products.map((p) => ({
id: p.id,
storeProductId: p.id.split('/').pop(),
title: p.title,
price: p.priceRange.min.amount,
currencyCode: p.priceRange.min.currencyCode,
imageUrl: p.images?.[0]?.url ?? null,
variants: p.variants.map((v) => ({
id: v.id, title: v.title, price: v.price.amount, available: v.availableForSale,
})),
raw: p, // carried through untouched, for your own call sites
}));
},
// FALSE for a *refused* line rather than throwing. A refused add must not be announced to the
// room — that card is what everyone else reads as "it is going".
addLine: (variantId, quantity) => shopify.cart.addLine({ merchandiseId: variantId, quantity }),
};
const identity = {
bidderId: () => customerId, // bare id; what the host echoes back on a bid
customerRef: () => `gid://shopify/Customer/${customerId}`,
customerName: () => customer.firstName,
};Only productsByIds is required. An unimplemented method disables the feature that needs it rather than
throwing: no addLine ⇒ no add-to-cart, no createAuctionCheckout ⇒ wins are reported but not payable.
raw keeps its type. Name it once and your own product comes back off the session with no cast:
const session = useLiveSession<ShopifyProduct>();
session.activeProduct?.raw?.onlineStoreUrl; // ShopifyProduct | undefined, not unknownbidderId must be the same id the host's broadcast echoes back, or isLeading compares two
different things and nobody is ever leading. Keep it distinct from the per-install viewer id, which is
what the host mutes by.
Build a disposable cart, not the shopping cart: an auction win is already a commitment at a settled price, and mixing those lines into a browsing cart lets a quantity stepper change it.
On Shopify each line carries a hidden _winningBid (or _giveawayItem) attribute that a cart
transform function on the store reads to reprice the line server-side. Each line also needs something
unique in its attributes — Shopify merges lines sharing the same merchandise and attributes, so two
identical wins collapse into one quantity-2 line priced as a single lot, and the second win is free.
5. Auctions — the two rules that matter
Nothing is optimistic. A viewer offers a bid to the round's moderator and waits; only the host's broadcast sets the high bid, the leader and the deadline. That is what makes two phones agree.
Deadlines are on the host's clock, carried as serverNowMs on every event that moves them. A phone
minutes out of sync would otherwise close a round that is still open. auction.timeLeftMs is already
corrected for the offset.
useAuctionBidGate owns the four ordered conditions in front of a bid and reports which one is current;
you render the panels. The order is the whole point:
const gate = useAuctionBidGate({
streamingId: session.stream?.streamingId ?? null,
isLoggedIn, biddingOpen: auction.biddingOpen,
variantCount: biddingProduct?.variants.length ?? 0,
placeBid: actions.placeBid,
});
// gate.blocker: 'signIn' | 'unpaidWins' | 'terms' | 'variant' | null
gate.requestBid(); // the round's minimum — the slide gesture
gate.requestBid(250); // a custom bidSigned in → nothing outstanding on another stream → rules accepted once per install → a variant chosen
(once per round, not per bid). The requested bid is held while you clear the blockers and then placed, so
finishing the last panel completes the bid they originally asked for. Call gate.refreshUnpaidWins()
from your own focus effect, so a customer who leaves to settle a tab is unblocked on return.
If you render the panels as React Native Modals, honour SHEET_HANDOFF_MS between them — presenting
one while another dismisses is a native iOS exception, not a catchable error.
6. Platform boundaries
Two pairs of files, and Metro picks by platform. TypeScript resolves the non-native one, which is why
tsc needs neither Zego nor Expo installed.
| Native (iOS/Android) | Everywhere else |
|---|---|
| engine.native.ts — the real Zego engine; owns the room, plays into a rebindable view, publishes a guest's camera, translates every Zego channel into a LiveEvent | engine.ts — no-op, isNoop: true |
| pip.native.ts — the LivePip Expo module | pip.ts — no-op, isLivePipSupported: false |
So the session never arms a handover that cannot happen, and a web bundle never evaluates the Zego entry module.
Picture-in-picture is two stages. actions.enterPip() shrinks to a surface you draw; the session
arms the OS handover in advance, so minimising the app opens a real system window with no JavaScript
involved at that moment. Coming back raises onPipRestore — navigate to your live screen, which the SDK
can't do itself from above the navigator.
7. Imperative client
For anything the session doesn't cover. liveSelling.configure() first; isReady() and companyId()
report state.
| Namespace | Methods |
|---|---|
| streams | list ongoing byId zegoToken |
| auctions | recordBid enterGiveaway hasAcceptedTerms acceptTerms unpaidStreamIds wins isBiddingBlocked |
| polls | vote loadVotedOption rememberVote forgetVote |
| replays | list segments comments incrementViews |
| clips | list |
| guest | acceptInvite leaveSlot userId |
| flags | fetch — per-company feature switches |
| viewer | id loadName cachedName saveName normaliseName |
| engine | the Zego engine (above) |
Read hooks for screens that only need data: useOngoingLive, useLiveStreams, useStreamInfo,
useReplays, useClips, useAuction.
LiveEvent is the union the engine emits — comment, reaction, sold, viewers, productChange,
poll*, biddingStarted/bidInfo/biddingClosed/auctionWon, giveaway*, the guest* co-host
signals, and an unknown fallthrough so a new host-side message is visible rather than dropped.
Subscribe via LiveSellingProvider's onEvent or liveSelling.engine.subscribe.
Scripts
| Command | What it does |
|---|---|
| npm run build | Cleans, compiles src → dist and plugin/src → plugin/build. Both are needed: app.plugin.js loads the compiled plugin so consumers never need TypeScript at prebuild |
| npm run lint | Typechecks both projects, emitting nothing |
| npm run clean | Removes dist and plugin/build |
| npm pack | The publish tarball — use it to verify dist, ios/, android/, expo-module.config.json, app.plugin.js and plugin/build all ship |
The reducer is exported on its own (sessionReducer, deriveStatus, timeLeftMs) and is pure, so it
can be driven with no room, socket or React — from Node, import it as
@tiledev/sdk-apptile-live-selling/session rather than off the root.
Gotchas
- A camera left off is invisible to Zego. No media event and no camera-state event fires; only
zero-FPS quality samples reveal it, which is why
statusbecomeswaitingForHostabout 5s in rather than instantly. - A dropped socket is not the end of a stream. Backgrounding disconnects the room and Zego recovers by itself, so only the host's explicit signal or the record ends it.
- Broadcasts are never replayed. A shadow-mute is seeded from the stream record for exactly this reason — otherwise a muted viewer gets a free message per relaunch, and an unmute is invisible until the app restarts.
- A guest and a viewer cannot both hold the room. One Zego session per engine, and publishing rights
live in the token, so co-hosting logs the viewer out and
leaveGuesttakes the room back. - Consuming via
file:or a workspace link? Hoist@types/react. The.d.tsotherwise resolves against the SDK's own nested copy, and React 18'sReactNodeisn't assignable to React 19's.
Not included
UI · a storefront · navigation · a static clip fallback (clips.list() reads only the configured
collection).
Verified against
React 19.1.0 · React Native 0.81.5 · Expo 54 — typechecked from the packed tarball, plus a Node load of the pure data layer. The native path (autolinking, the podspec, the plugin's prebuild output) has not been exercised on a device yet.
