@tiledev/sdk-apptile-live-selling
v0.8.2
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-selling @tiledev/sdk-apptile-live-selling-nativeTwo packages, and an Expo app must list both. Everything native — the LivePip Expo module, the
podspec, the Kotlin, the config plugin — lives in @tiledev/sdk-apptile-live-selling-native. This
package is pure JavaScript and depends on it.
Listing it separately is not redundant, and this is the one installation mistake worth spelling out.
In a tree that contains expo and react-native, npm nests the native package at
node_modules/@tiledev/sdk-apptile-live-selling/node_modules/… rather than hoisting it. Expo's
autolinking scans the whole tree and finds it there quite happily — but its config-plugin resolver
resolves plugin names from the project root only, so expo prebuild fails outright:
PluginError: Failed to resolve plugin for module "@tiledev/sdk-apptile-live-selling-native"A direct entry is what hoists it. Reproduced in a bare project carrying nothing but expo,
react-native, react and this package.
Pin it to exactly the version this package's dependencies names — never a different one, or npm
keeps two copies of the same Expo module and the identical pod and module names collide at link time:
"@tiledev/sdk-apptile-live-selling": "^0.3.0",
"@tiledev/sdk-apptile-live-selling-native": "0.1.0"That split is not tidiness. Expo autolinks any package carrying an expo-module.config.json and
records its packageVersion in expoAutolinkingConfig, which is one of the content sources the
OTA fingerprint hashes — so a version bump of an autolinked package moves the fingerprint and needs a
new binary in the stores even when the release changed nothing but JavaScript. This package is no
longer autolinked, so its version is not hashed and a release of it ships as a code push.
What a version bump here means for you
The native dependency is pinned exactly, so the native version can only move when this package's version moves. That makes this package's own version number the signal — and it is why the direct entry above can be pinned and left alone until this package's minor changes:
| bump | what it costs you |
|---|---|
| patch — 0.3.0 → 0.3.1 | JavaScript only. Ships over the air; the OTA fingerprint does not move. |
| minor — 0.3.x → 0.4.0 | The native pin moved. A new binary is required, and the fingerprint changes. |
So take it with a caret and let patches arrive on their own:
"@tiledev/sdk-apptile-live-selling": "^0.3.0"^0.3.0 accepts every 0.3.x and refuses 0.4.0 — free JS updates, and a deliberate edit on the day
native code changes. On a minor, move the native pin to whatever this package's new dependencies
names, in the same commit. Check the fingerprint in CI anyway: it is the only thing that catches a
mistake here regardless of how the ranges are written.
Peers: react and react-native are needed by the main entry, which re-exports the React layer;
zego-express-engine-reactnative stays optional — off-device the engine resolves to a no-op, as does
picture-in-picture in the native package.
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.
The plugin ships in the native package, so name that one in app.json, not this one:
{
"expo": {
"plugins": [
["@tiledev/sdk-apptile-live-selling-native", {
"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 out of
@tiledev/sdk-apptile-live-selling-native. Its full prop table is in that package's README.
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 here, and Metro picks by platform. TypeScript resolves the non-native one, which
is why tsc needs Zego installed for neither. (The picture-in-picture pair works the same way and
lives in the native package.)
| 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 |
| react/index.ts — the real providers, hooks and video target | react/index.web.ts — real client and read hooks, idle session, View in place of the texture view |
The web file is not only about behaviour. LiveVideoTarget requires zego-express-engine-reactnative
for its texture view, and a bundler collects that require whichever branch would run it — so a web
build in a workspace without the Zego SDK installed fails to resolve it, before any of this runs.
Serving the stand-ins keeps the native entry out of a web graph altogether. What a browser can do
honestly stays real: the client, the gateway calls, and the stream/replay/clip read hooks. What it
cannot is inert — useLiveSession() reports a permanently ended session (the empty state every
surface already draws), the bid gate never blocks, and the wins tab is empty.
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.
useOngoingLive is the one hook that polls, on purpose, to make the app experience better. A
show that starts while the shopper already has the app open is worth catching, and no event reaches
the app for it unless a push is sent. So every useOngoingLive reads one app-wide store with one
timer:
- every 2 minutes while nothing is live, and every 30 minutes while a show is (the live screen has its own realtime channel, so this only has to notice the show ending);
- foreground only, and one timer per device however many screens use the hook;
- plus the event-driven reads: the first screen to mount, a return to the foreground,
refresh(), andrefreshOngoingLive()(e.g. when a push says a show has started).
It returns liveNow (the stream while it is on air, else null) for a "Live now" banner. Tune or
turn it off in the config: ongoingLivePolling: { whenIdleMs, whileLiveMs } or false.
The cost: ongoing() reads the company's stream list, so gateway load is concurrent open apps ÷
interval. 10,000 apps open at 2 minutes is about 83 requests a second; 100,000 is about 830. Raise
whenIdleMs for a large audience, or send a "show started" push and call refreshOngoingLive().
useReplays({ playableOnly: true }) keeps only replays with a playable video.
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.
Releasing
Two packages, one rule: the native version may only move when this package's minor moves.
dependencies pins it exactly ("0.1.0", no caret) so npm cannot drift it underneath you, and
consumers take this package as ^0.3.0 so a minor is the one thing their npm install will not
accept on its own.
A JavaScript-only change — anything under src/ that is not pip:
- patch-bump this package (
0.3.0→0.3.1) npm run build && npm publish
Consumers pick it up on their next install and ship it over the air. The fingerprint does not move.
A native change — Swift, Kotlin, the podspec, build.gradle, expo-module.config.json, or the
config plugin:
- bump
@tiledev/sdk-apptile-live-selling-native(0.1.0→0.1.1), build and publish it - update this package's
dependenciesto that exact version - minor-bump this package (
0.3.x→0.4.0) — never a patch, or consumers on^0.3.0take it silently and their OTA updates stop matching the binaries in the stores npm run build && npm publish- say in the release notes that a new binary is required
A documentation-only release of the native package does NOT move the pin. 0.1.1 was published
purely to correct its README; the pin here deliberately stays at 0.1.0. Moving it would have made
this a native change — consumers on ^0.3.0 take a patch automatically, so their fingerprint would
have shifted, and every one of them would have needed a new binary to fix a paragraph of prose. The
version this package pins is a statement about the native binary, not about which README is newest.
The minor bump is the whole safety mechanism. A native change released as a patch is the one mistake this layout cannot absorb: it moves every consumer's fingerprint without anyone editing anything, and the symptom is silent — over-the-air updates simply stop reaching devices.
Never publish the native package with a version this package does not pin. A consumer's tree then has two copies of the same Expo module, and identical pod and module names collide at link time.
Scripts
| Command | What it does |
|---|---|
| npm run build | Cleans and compiles src → dist |
| npm run lint | Typechecks, emitting nothing |
| npm run clean | Removes dist |
| npm pack | The publish tarball — dist, types, session and nothing native |
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.
- An app-side block rides the same mute.
actions.sendComment(text, { forceMuted: true })publishes the comment flaggedmuted, so the room drops it while the sender still sees their own echo — for a block the app decides, such as a customer tag, without telling the blocked viewer. The flag is set by the sender's client, so it is a courtesy, not enforcement: a modified client can leave it off. - 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.
