@tiledev/sdk-apptile-live-selling 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +452 -34
- package/dist/auctionComments.d.ts +25 -0
- package/dist/auctionComments.d.ts.map +1 -0
- package/dist/auctionComments.js +28 -0
- package/dist/auctionComments.js.map +1 -0
- package/dist/auctionRules.d.ts +76 -0
- package/dist/auctionRules.d.ts.map +1 -0
- package/dist/auctionRules.js +106 -0
- package/dist/auctionRules.js.map +1 -0
- package/dist/auctions.d.ts.map +1 -1
- package/dist/auctions.js +19 -10
- package/dist/auctions.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +123 -23
- package/dist/client.js.map +1 -1
- package/dist/commentHistory.d.ts +66 -0
- package/dist/commentHistory.d.ts.map +1 -0
- package/dist/commentHistory.js +125 -0
- package/dist/commentHistory.js.map +1 -0
- package/dist/engine.native.d.ts.map +1 -1
- package/dist/engine.native.js +17 -845
- package/dist/engine.native.js.map +1 -1
- package/dist/giveawayPrize.d.ts +13 -0
- package/dist/giveawayPrize.d.ts.map +1 -0
- package/dist/giveawayPrize.js +18 -0
- package/dist/giveawayPrize.js.map +1 -0
- package/dist/giveawayPrizeLines.d.ts +67 -0
- package/dist/giveawayPrizeLines.d.ts.map +1 -0
- package/dist/giveawayPrizeLines.js +48 -0
- package/dist/giveawayPrizeLines.js.map +1 -0
- package/dist/giveawayRoster.d.ts +53 -0
- package/dist/giveawayRoster.d.ts.map +1 -0
- package/dist/giveawayRoster.js +93 -0
- package/dist/giveawayRoster.js.map +1 -0
- package/dist/heldComments.d.ts +29 -0
- package/dist/heldComments.d.ts.map +1 -0
- package/dist/heldComments.js +61 -0
- package/dist/heldComments.js.map +1 -0
- package/dist/hiddenShows.d.ts +34 -0
- package/dist/hiddenShows.d.ts.map +1 -0
- package/dist/hiddenShows.js +42 -0
- package/dist/hiddenShows.js.map +1 -0
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -3
- package/dist/index.js.map +1 -1
- package/dist/liveHaptics.d.ts +7 -0
- package/dist/liveHaptics.d.ts.map +1 -0
- package/dist/liveHaptics.js +11 -0
- package/dist/liveHaptics.js.map +1 -0
- package/dist/liveHaptics.native.d.ts +7 -0
- package/dist/liveHaptics.native.d.ts.map +1 -0
- package/dist/liveHaptics.native.js +12 -0
- package/dist/liveHaptics.native.js.map +1 -0
- package/dist/liveShow.d.ts +58 -0
- package/dist/liveShow.d.ts.map +1 -0
- package/dist/liveShow.js +55 -0
- package/dist/liveShow.js.map +1 -0
- package/dist/phoneTaps.d.ts +25 -0
- package/dist/phoneTaps.d.ts.map +1 -0
- package/dist/phoneTaps.js +47 -0
- package/dist/phoneTaps.js.map +1 -0
- package/dist/pipWindow.d.ts +85 -0
- package/dist/pipWindow.d.ts.map +1 -0
- package/dist/pipWindow.js +88 -0
- package/dist/pipWindow.js.map +1 -0
- package/dist/polls.d.ts.map +1 -1
- package/dist/polls.js +3 -0
- package/dist/polls.js.map +1 -1
- package/dist/react/LiveSellingProvider.d.ts +10 -1
- package/dist/react/LiveSellingProvider.d.ts.map +1 -1
- package/dist/react/LiveSellingProvider.js +6 -2
- package/dist/react/LiveSellingProvider.js.map +1 -1
- package/dist/react/LiveVideoTarget.d.ts.map +1 -1
- package/dist/react/LiveVideoTarget.js +41 -3
- package/dist/react/LiveVideoTarget.js.map +1 -1
- package/dist/react/clocks.d.ts +29 -0
- package/dist/react/clocks.d.ts.map +1 -0
- package/dist/react/clocks.js +124 -0
- package/dist/react/clocks.js.map +1 -0
- package/dist/react/index.d.ts +7 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +15 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.web.d.ts +9 -2
- package/dist/react/index.web.d.ts.map +1 -1
- package/dist/react/index.web.js +27 -4
- package/dist/react/index.web.js.map +1 -1
- package/dist/react/startSignIn.d.ts +3 -0
- package/dist/react/startSignIn.d.ts.map +1 -0
- package/dist/react/startSignIn.js +19 -0
- package/dist/react/startSignIn.js.map +1 -0
- package/dist/react/useAuctionBidGate.d.ts +54 -4
- package/dist/react/useAuctionBidGate.d.ts.map +1 -1
- package/dist/react/useAuctionBidGate.js +169 -25
- package/dist/react/useAuctionBidGate.js.map +1 -1
- package/dist/react/useAuctionWins.d.ts +9 -1
- package/dist/react/useAuctionWins.d.ts.map +1 -1
- package/dist/react/useAuctionWins.js +9 -3
- package/dist/react/useAuctionWins.js.map +1 -1
- package/dist/react/useGiveawayEntry.d.ts +59 -0
- package/dist/react/useGiveawayEntry.d.ts.map +1 -0
- package/dist/react/useGiveawayEntry.js +170 -0
- package/dist/react/useGiveawayEntry.js.map +1 -0
- package/dist/react/useGiveawayPrize.d.ts +12 -0
- package/dist/react/useGiveawayPrize.d.ts.map +1 -0
- package/dist/react/useGiveawayPrize.js +53 -0
- package/dist/react/useGiveawayPrize.js.map +1 -0
- package/dist/react/useHiddenShows.d.ts +10 -0
- package/dist/react/useHiddenShows.d.ts.map +1 -0
- package/dist/react/useHiddenShows.js +30 -0
- package/dist/react/useHiddenShows.js.map +1 -0
- package/dist/react/useLiveSession.d.ts +7 -3
- package/dist/react/useLiveSession.d.ts.map +1 -1
- package/dist/react/useLiveSession.js +522 -157
- package/dist/react/useLiveSession.js.map +1 -1
- package/dist/react/useLiveShow.d.ts +3 -0
- package/dist/react/useLiveShow.d.ts.map +1 -0
- package/dist/react/useLiveShow.js +21 -0
- package/dist/react/useLiveShow.js.map +1 -0
- package/dist/react/useLiveStreams.d.ts.map +1 -1
- package/dist/react/useLiveStreams.js +6 -2
- package/dist/react/useLiveStreams.js.map +1 -1
- package/dist/react/useOngoingLive.d.ts +1 -0
- package/dist/react/useOngoingLive.d.ts.map +1 -1
- package/dist/react/useOngoingLive.js +7 -1
- package/dist/react/useOngoingLive.js.map +1 -1
- package/dist/react/useReplays.d.ts +12 -0
- package/dist/react/useReplays.d.ts.map +1 -1
- package/dist/react/useReplays.js +56 -24
- package/dist/react/useReplays.js.map +1 -1
- package/dist/react/useStreamInfo.d.ts +13 -2
- package/dist/react/useStreamInfo.d.ts.map +1 -1
- package/dist/react/useStreamInfo.js +31 -5
- package/dist/react/useStreamInfo.js.map +1 -1
- package/dist/replayPages.d.ts +31 -0
- package/dist/replayPages.d.ts.map +1 -0
- package/dist/replayPages.js +34 -0
- package/dist/replayPages.js.map +1 -0
- package/dist/replays.d.ts +7 -0
- package/dist/replays.d.ts.map +1 -1
- package/dist/replays.js +52 -22
- package/dist/replays.js.map +1 -1
- package/dist/session.d.ts +39 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +209 -22
- package/dist/session.js.map +1 -1
- package/dist/sessionTypes.d.ts +190 -14
- package/dist/sessionTypes.d.ts.map +1 -1
- package/dist/sessionTypes.js +2 -1
- package/dist/sessionTypes.js.map +1 -1
- package/dist/shopifyCommerce.d.ts +149 -0
- package/dist/shopifyCommerce.d.ts.map +1 -0
- package/dist/shopifyCommerce.js +123 -0
- package/dist/shopifyCommerce.js.map +1 -0
- package/dist/shopifyIdentity.d.ts +51 -0
- package/dist/shopifyIdentity.d.ts.map +1 -0
- package/dist/shopifyIdentity.js +52 -0
- package/dist/shopifyIdentity.js.map +1 -0
- package/dist/shopifyIds.d.ts +9 -0
- package/dist/shopifyIds.d.ts.map +1 -0
- package/dist/shopifyIds.js +18 -0
- package/dist/shopifyIds.js.map +1 -0
- package/dist/showLinks.d.ts +24 -0
- package/dist/showLinks.d.ts.map +1 -0
- package/dist/showLinks.js +23 -0
- package/dist/showLinks.js.map +1 -0
- package/dist/showLots.d.ts +28 -0
- package/dist/showLots.d.ts.map +1 -0
- package/dist/showLots.js +51 -0
- package/dist/showLots.js.map +1 -0
- package/dist/showNames.d.ts +45 -0
- package/dist/showNames.d.ts.map +1 -0
- package/dist/showNames.js +74 -0
- package/dist/showNames.js.map +1 -0
- package/dist/streams.d.ts +27 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +84 -21
- package/dist/streams.js.map +1 -1
- package/dist/types.d.ts +214 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/dist/wire.d.ts +31 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +25 -0
- package/dist/wire.js.map +1 -1
- package/dist/zego.d.ts +3 -1
- package/dist/zego.d.ts.map +1 -1
- package/dist/zego.js +1 -1
- package/dist/zegoEngine.d.ts +168 -0
- package/dist/zegoEngine.d.ts.map +1 -0
- package/dist/zegoEngine.js +1052 -0
- package/dist/zegoEngine.js.map +1 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@ its own config plugin.
|
|
|
8
8
|
native). Your header, comments list, bid bar and sheets stay yours.
|
|
9
9
|
|
|
10
10
|
**Storefront-agnostic.** Products, cart writes and the auction checkout come from an adapter you
|
|
11
|
-
supply
|
|
11
|
+
supply. An app on `@tiledev/sdk-shopify` can take that adapter ready-made (§4, "On Shopify"), along
|
|
12
|
+
with the cart's giveaway-prize helpers. They are built on the app's own sdk-shopify client, so this
|
|
13
|
+
package loads none of sdk-shopify.
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
16
|
npm i @tiledev/sdk-apptile-live-selling @tiledev/sdk-apptile-live-selling-native
|
|
@@ -69,13 +71,16 @@ mistake here regardless of how the ranges are written.
|
|
|
69
71
|
|
|
70
72
|
Peers: `react` and `react-native` are needed by the main entry, which re-exports the React layer;
|
|
71
73
|
`zego-express-engine-reactnative` stays optional — off-device the engine resolves to a no-op, as does
|
|
72
|
-
picture-in-picture in the native package.
|
|
74
|
+
picture-in-picture in the native package. `@tiledev/sdk-shopify` (`>=0.10.0`) is required since 0.9.0:
|
|
75
|
+
the Shopify adapter and prize helpers (§4) are built for it, though the app passes its own client in
|
|
76
|
+
and nothing of sdk-shopify is loaded from here.
|
|
73
77
|
|
|
74
|
-
**Everything ships from the package root**, including the providers, the hooks
|
|
75
|
-
There is no `./react` subpath — a subpath is reachable only through
|
|
76
|
-
resolver that ignores it (node10, some test runners and bundlers) cannot see
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
**Everything ships from the package root**, including the providers, the hooks, `LiveVideoTarget` and
|
|
79
|
+
the Shopify helpers. There is no `./react` or `./shopify` subpath — a subpath is reachable only through
|
|
80
|
+
the `exports` map, which a resolver that ignores it (node10, some test runners and bundlers) cannot see
|
|
81
|
+
at all, and an app testing a local build would need its Metro and Jest redirects changed for one
|
|
82
|
+
(decided 2026-10-06, Head of Engineering). What remains beside the root is `./types` (types alone) and
|
|
83
|
+
a wildcard for any module in `dist` — `/session`, `/streams`, `/polls` — the escape hatch below.
|
|
79
84
|
|
|
80
85
|
> The root entry pulls in `react-native`, so it no longer loads in a plain Node runtime. Driving the
|
|
81
86
|
> pure data layer from Node — `sessionReducer`, the wire mappers — goes through the module subpaths
|
|
@@ -121,11 +126,13 @@ Every viewer endpoint is **anonymous**: the brand `companyId` is the only creden
|
|
|
121
126
|
`x-company-id`. It is not the Shopify store id and not the Tile app id.
|
|
122
127
|
|
|
123
128
|
```ts
|
|
129
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
124
130
|
import { liveSelling } from '@tiledev/sdk-apptile-live-selling';
|
|
125
131
|
|
|
126
132
|
liveSelling.configure({
|
|
127
133
|
apiUrl: 'https://live-selling.apptile.io',
|
|
128
134
|
companyId: '<brand-id>',
|
|
135
|
+
storage: AsyncStorage, // required since 0.9.0; `window.localStorage` on the web
|
|
129
136
|
zegoAppId: 361108744, // platform-level: the gateway signs room tokens with one Zego app
|
|
130
137
|
commerce, // see §4
|
|
131
138
|
identity,
|
|
@@ -136,18 +143,48 @@ liveSelling.configure({
|
|
|
136
143
|
|---|---|---|
|
|
137
144
|
| `apiUrl` | yes | Gateway base URL, no trailing slash |
|
|
138
145
|
| `companyId` | yes | Apptile "brand id" — the only credential |
|
|
146
|
+
| `storage` | yes | Since 0.9.0. AsyncStorage-shaped: `AsyncStorage` on a phone, `window.localStorage` on the web. `configure` throws without it |
|
|
139
147
|
| `zegoAppId` | for video | Matches the gateway, not the brand. Native engine only |
|
|
140
148
|
| `commerce` | for shopping | Resolves lots to products and sells them |
|
|
141
149
|
| `identity` | for bidding | The customer a win is attributed to |
|
|
142
|
-
| `storage` | no | AsyncStorage-shaped. Defaults to `localStorage`, else in-memory |
|
|
143
150
|
| `videoCollectionId` | no | The clip reel. **Not** scoped by the company header, so a stale id serves another tenant's clips — unset ⇒ `clips.list()` is `[]` |
|
|
144
151
|
| `requestSource` | no | `X-Request-Source`. Defaults to `"APPTILE"` |
|
|
145
|
-
| `logger` | no | `{ error }`. Defaults to silent |
|
|
152
|
+
| `logger` | no | `{ error, warn? }`. Defaults to silent. `warn` gets handled-data reports; without it they go to `error` |
|
|
146
153
|
| `customerAccountRequest` | no | The customer's bearer transport. Only the unpaid-wins read uses it |
|
|
154
|
+
| `requestTimeoutMs` | no | Since 0.9.0. How long any gateway (or replay-comment CDN) request may take before it fails with a `LiveSellingTimeoutError`. Defaults to `10000`; zero, negative or non-numbers mean the default |
|
|
155
|
+
| `offAirGraceMs` | no | Since 0.9.0. How long the session waits, with no live record and not reconnecting, before `status` becomes `ended`. Defaults to `4000` |
|
|
156
|
+
| `hiddenShows` | no | Since 0.9.0. `{ titleIncludes, visibleToEmailDomains }`: shows to keep from shoppers, such as a team's rehearsals. Below |
|
|
147
157
|
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
**Timeouts (0.9.0).** Every request is time-boxed — the fetch and reading its body — and a request
|
|
159
|
+
that runs out fails with `LiveSellingTimeoutError` (`url`, `timeoutMs`), never a bare `AbortError`.
|
|
160
|
+
An HTTP failure is still a `LiveSellingError` with its `status`. Before 0.9.0 a request on a dead
|
|
161
|
+
connection never settled, so a join waiting on its room token hung for as long as the OS kept the
|
|
162
|
+
socket open.
|
|
163
|
+
|
|
164
|
+
**Hidden shows (0.9.0).** A team rehearses on the store's real gateway, which has no "draft" setting.
|
|
165
|
+
Name the rehearsals and who may see them, and pass the signed-in viewer's email to the provider:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
hiddenShows: { titleIncludes: ['apptiletest'], visibleToEmailDomains: ['apptile.io'] },
|
|
169
|
+
|
|
170
|
+
<LiveSellingProvider config={config} viewerEmail={customer?.email ?? null}>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A title containing any word, in any case, is hidden from everyone but an email ending `@` + a domain
|
|
174
|
+
(`host@apptile.io.example.com` is not `apptile.io`). For a viewer who may not see it, the show doesn't
|
|
175
|
+
exist: `useReplays`, `useOngoingLive`, `useLiveStreams` and `useStreamInfo` leave it out, the session
|
|
176
|
+
never holds its record **or joins its room**, and `streams.destinationFor` sends its link to the list
|
|
177
|
+
of shows. The imperative reads (`streams.list`, `ongoing`, `byId`, `replays.list`) stay the gateway's.
|
|
178
|
+
`viewerEmail` is a prop so a sign-in reaches every hook at once; `useHiddenShows()` gives the rule for
|
|
179
|
+
an app's own lists, and the pure `isHiddenFrom` / `withoutHiddenShows` take it as arguments. It hides
|
|
180
|
+
shows in the app only: anyone with the company id can still ask the gateway for them.
|
|
181
|
+
|
|
182
|
+
**`storage` is required (0.9.0).** It keeps three things across relaunches: the name a signed-out
|
|
183
|
+
shopper gave, the accepted auction terms, and which polls this install voted in. Losing the last one
|
|
150
184
|
does damage — **the gateway does not dedupe votes**, so a forgotten vote lets the same device vote again.
|
|
185
|
+
Until 0.9.0 it was optional, with an in-memory store on a phone, and two of the three apps on this
|
|
186
|
+
package shipped without it (each found on a phone, not in review), so it is now a compile error to
|
|
187
|
+
leave it out. A test that wants nothing kept passes an in-memory store of its own.
|
|
151
188
|
|
|
152
189
|
## 3. Mount and render
|
|
153
190
|
|
|
@@ -194,23 +231,168 @@ Exactly one mounted target should have it set.
|
|
|
194
231
|
| `status` | `loading` · `connecting` · `waitingForHost` · `playing` · `ended` · `error` |
|
|
195
232
|
| `stream` | the record: title, host, products, giveaways, `bidCap`, `reactionStyle` |
|
|
196
233
|
| `activeProduct` `products` | resolved through your commerce adapter |
|
|
197
|
-
| `comments` `reactions` `sold` `poll` | the live feed; reactions and
|
|
198
|
-
| `auction` | `biddingOpen`, `highestBid`, `nextBid`, `
|
|
199
|
-
| `giveaway` | `active`, `entryCount`, `registered`, `result` |
|
|
234
|
+
| `comments` `reactions` `sold` `cartAnnouncements` `poll` | the live feed; reactions, sold cards and cart cards expire on their own (each at its lifetime, by one timeout) |
|
|
235
|
+
| `auction` | `biddingOpen`, `highestBid`, `nextBid`, `closesAt`, `isLeading`, `leaderName`, `extended`, `lastWin` (with `isYou`), `wonLotIds` |
|
|
236
|
+
| `giveaway` | `active`, `entryCount`, `registered`, `result` (`winnerName`, `entrantNames`, `entrantNamesReady`, `youEntered`, `isYou`) |
|
|
237
|
+
| `pinnedComment` | `{ id, text }` the host pinned, or null; always null once `status` is `ended` |
|
|
200
238
|
| `guest` | co-host: `mode`, `status`, `slot`, `mutedByHost`, `mixerLayout` |
|
|
201
|
-
| `viewerCount` `
|
|
202
|
-
| `actions` | `sendComment`, `sendReaction`, `addToCart`, `voteInPoll`, `placeBid`, `enterGiveaway`, `enterPip`, `joinAsGuest`, … |
|
|
239
|
+
| `viewerCount` `muted` `pipMode` `viewerName` | header material |
|
|
240
|
+
| `actions` | `sendComment`, `sendReaction`, `addToCart`, `voteInPoll`, `placeBid`, `enterGiveaway`, `enterPip`, `joinAsGuest`, … `placeBid` resolves `{ sent }` and `enterGiveaway` `{ entered }`, with a `reason` when not (0.9.0) |
|
|
203
241
|
|
|
204
242
|
`actions` is identity-stable — safe in a dependency array, and deliberately so: one of the effects that
|
|
205
243
|
depends on it rebinds the video, which is a real `startPlayingStream`.
|
|
206
244
|
|
|
245
|
+
### The session never ticks; the clocks are hooks (0.9.0)
|
|
246
|
+
|
|
247
|
+
The session changes only when something happens (a room event, a record read, an action's answer) and
|
|
248
|
+
is the same object until one of its fields does. It holds **no clock**: a session that ticks draws every
|
|
249
|
+
component that reads it on every tick, and the session is app-wide (before 0.9.0 that was every reader,
|
|
250
|
+
5 times a second during an auction round). It holds the moments instead:
|
|
251
|
+
|
|
252
|
+
- `auction.closesAt`: when the round closes, **on this phone's clock** (ms, like `Date.now()`); null
|
|
253
|
+
with no round open. It moves only when the host moves the deadline.
|
|
254
|
+
- `stream.startedAtMs`: when the show started.
|
|
255
|
+
|
|
256
|
+
A component that shows a clock counts to one of them itself, and only that component is drawn as it
|
|
257
|
+
ticks:
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
function RoundClock({ closesAt }: { closesAt: number | null }) {
|
|
261
|
+
const timeLeftMs = useAuctionCountdown(closesAt); // ms left, ≥ 0
|
|
262
|
+
return <Text>{Math.ceil(timeLeftMs / 1000)}s</Text>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function ShowClock({ startedAt }: { startedAt: number | null }) {
|
|
266
|
+
const elapsedMs = useShowClock(startedAt); // ms since the start, ≥ 0
|
|
267
|
+
return <Text>{Math.floor(elapsedMs / 60_000)} min</Text>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The screen passes the moments down and is never drawn on a clock:
|
|
271
|
+
<RoundClock closesAt={session.auction.closesAt} />
|
|
272
|
+
<ShowClock startedAt={session.stream?.startedAtMs ?? null} />
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Both wake once a second, a few milliseconds after the second they show changes, so every second is
|
|
276
|
+
shown once (a free-running 1 s timer shows one twice and skips another). Both stop with nothing to
|
|
277
|
+
count: the countdown at zero or with no round, the show's clock with no start time. They take a number
|
|
278
|
+
rather than reading the session, so a tile can call them with its props, and they work outside the
|
|
279
|
+
provider and on the web.
|
|
280
|
+
|
|
207
281
|
`viewerName` is `null` when nobody has said who they are. Ask before the first comment goes out rather
|
|
208
282
|
than publishing "Anonymous" to a room; `actions.chooseViewerName(raw)` remembers the answer.
|
|
209
283
|
|
|
284
|
+
### The show a screen is about (0.9.0)
|
|
285
|
+
|
|
286
|
+
The session is app-wide and follows one show. A screen opened for a named show pins it
|
|
287
|
+
(`holdStream(id)`), and for a moment after that the session still holds the last show's record. Ask
|
|
288
|
+
`useLiveShow` rather than reading `session.stream`:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const { show, status, onAir, capabilities } = useLiveShow(route.params.streamId);
|
|
292
|
+
// show: the session's record once it is this show's, else null
|
|
293
|
+
// status: 'connecting' until then, then the session's ('loading' reads as 'connecting');
|
|
294
|
+
// 'ended' at once when the session has settled on nothing
|
|
295
|
+
// onAir: playing, or waiting for the host: when the room's controls show
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`liveShowFor(session, streamingId)` is the same, pure. On the web it is always `ended`, with no show.
|
|
299
|
+
|
|
300
|
+
**A card that plays the show** (a muted preview on a home rail or a list of shows) claims the picture
|
|
301
|
+
only when `canPlayInCard({ session, show, screenOnShow: useIsFocused() })`: the session is in that
|
|
302
|
+
show's room and playing, no bubble or window has the picture, and the card's screen is on show.
|
|
303
|
+
|
|
304
|
+
**Where a link goes:** `await liveSelling.streams.destinationFor(streamingId, { viewerEmail })` reads the
|
|
305
|
+
record once and answers `{ destination, show, readError }`. `destination` is `{ to: 'livePlayer' }` (on
|
|
306
|
+
air), `{ to: 'replay', feedId }` (ended with a recording) or `{ to: 'showList' }` (anything else, a
|
|
307
|
+
hidden show included); map them to your screens. Most pushes about a show are opened after it, so this
|
|
308
|
+
is how one lands on the recording. A recording is remembered for the app's run; never throws; a failed
|
|
309
|
+
read comes back in `readError`, for you to report.
|
|
310
|
+
|
|
311
|
+
**The lots:** `product.sellId` (the lot number) and `product.baseBidPrice` (an auction's opening bid)
|
|
312
|
+
are joined on from the record; `featuredFirst(products, featuredId)` lifts the featured one to the front
|
|
313
|
+
of a list, the rest in the host's order.
|
|
314
|
+
|
|
315
|
+
### Names are never emails (0.9.0)
|
|
316
|
+
|
|
317
|
+
The room shows each viewer's name to everyone, so an email must never reach a screen. Every name the
|
|
318
|
+
SDK hands out has been through `publicName`: comments and the room's history, cart cards, reactions,
|
|
319
|
+
the auction's leader and winner, the giveaway's winner and entrants, a replay's chat, and the events
|
|
320
|
+
themselves. An email keeps its part before the @, an "@handle" stays whole, a real name is unchanged.
|
|
321
|
+
So does the name it publishes for this viewer.
|
|
322
|
+
|
|
323
|
+
- `viewerNameFor(customer)` is the name to pass as `viewerName`: first and last name, else the first
|
|
324
|
+
name, else the email's handle; null when signed out. `createShopifyLiveIdentity`'s `customerName`
|
|
325
|
+
uses it.
|
|
326
|
+
- `firstNameOf(name)` is the first word, for a card with no room for more.
|
|
327
|
+
|
|
328
|
+
### What the session does on its own (0.9.0)
|
|
329
|
+
|
|
330
|
+
These used to be workarounds in each app's live screen (Freckled Poppy's, mostly). They are the
|
|
331
|
+
session's job now; delete the app-side copies when you upgrade.
|
|
332
|
+
|
|
333
|
+
- **Comment history on join.** The last 40 lines the room said before this device arrived
|
|
334
|
+
(`/live-streaming/comments?streaming_id=`) are put in front of `comments`, once per show. The
|
|
335
|
+
gateway's duplicate rows are collapsed; anything the room has already delivered is not repeated;
|
|
336
|
+
the host's stored `"You"` is shown as the host (`stream.hostName`); this device's earlier lines are
|
|
337
|
+
marked `isOwn` by the name it comments under. Muted lines are left out.
|
|
338
|
+
- **The first comment carries the right name (L6).** `chooseViewerName` gives the name to the engine
|
|
339
|
+
at once, so the comment that prompted for it goes out under it, not as "Anonymous". A comment a
|
|
340
|
+
signed-in shopper sends before their name has loaded is **held** and sent the moment it lands —
|
|
341
|
+
`sendComment` resolves then — or after 10 s at the latest. Don't hold it yourself.
|
|
342
|
+
- **Pinned comment.** `session.pinnedComment` joins the record's `pinned_comment` (arrival), every
|
|
343
|
+
`PINNED_COMMENT_CHANGED` broadcast (now the typed `pinnedCommentChanged` event, not `unknown`), and
|
|
344
|
+
a fresh record read on every reconnect. Hidden once the show has ended.
|
|
345
|
+
- **Lot numbers.** When the host features a lot (`PRODUCT_CHANGE`) the record is read again, so a
|
|
346
|
+
`sellId` the gateway issues at show time reaches `product.sellId` without an app-side
|
|
347
|
+
`refreshStreamRecords()`. Skipped when the record already has the lot's number.
|
|
348
|
+
- **Nothing on air.** With a read finished, nothing live in it, and the room not reconnecting,
|
|
349
|
+
`status` becomes `ended` after `offAirGraceMs` (4 s). It used to stay `loading` for ever. A forced
|
|
350
|
+
read (`refreshStreamRecords()`, e.g. on screen focus) restarts the wait, so a stale record cannot end
|
|
351
|
+
a show that has just started.
|
|
352
|
+
- **The giveaway reveal's roster (ENG-705).** `giveaway_end` arrives before the gateway writes the
|
|
353
|
+
entrants onto the record, so the session asks the record again every 700 ms for up to 5 s and keeps
|
|
354
|
+
the first list with names. Wait for `giveaway.result.entrantNamesReady` before rolling the reveal;
|
|
355
|
+
the list never changes after it turns true.
|
|
356
|
+
- **A giveaway or poll that has ended stays ended (0.9.0).** A giveaway this phone watched end isn't
|
|
357
|
+
opened again by a record read taken before the gateway wrote the end (known by its lot and its start,
|
|
358
|
+
so the same product given away again later still shows). A poll on the record is raised only while
|
|
359
|
+
its `status` is `"active"` (or absent). A giveaway's `variantPrice` and `variantImageUrl` are the
|
|
360
|
+
record's `variant_price` and `variant_image`; before 0.9.0 the price was never read.
|
|
361
|
+
- **Android picture-in-picture.** A window the session armed becomes `pipMode: 'system'` even when the
|
|
362
|
+
player let go of the handover in the same instant, so the window's close button stops playback and
|
|
363
|
+
its expand button raises `onPipRestore`. A device with no picture-in-picture is reported once, as a
|
|
364
|
+
warning, and never armed again (L21).
|
|
365
|
+
- **The system window gives the picture back where it took it from.** A window opened from your
|
|
366
|
+
in-app bubble goes back to the bubble (`pipMode` `'inapp'`, no `onPipRestore`); one opened from a
|
|
367
|
+
full-screen player goes back to `'none'` and raises `onPipRestore`. Either way in one render, so a
|
|
368
|
+
surface that claims on `'none'` never grabs the picture on the way back to the bubble. On iOS, coming
|
|
369
|
+
back to the app with the window still up (by the app's icon rather than the window's expand) does the
|
|
370
|
+
same and takes the window down. A window is offered only while the show is `playing`, and once up it
|
|
371
|
+
stays armed whatever the status does. **Closing** it stops the show, which then stays stopped until
|
|
372
|
+
the app is back in the foreground: a surface that claims in between (the full-screen player still
|
|
373
|
+
focused behind the window) no longer starts it again behind the app. The same holds after the
|
|
374
|
+
`PIP_GRANT_MS` stop when no window came.
|
|
375
|
+
- **`LiveVideoTarget`** restates its mute on every status change (a card mounted before the engine
|
|
376
|
+
existed used to leave the host audible) and takes the picture again when the app returns to the
|
|
377
|
+
foreground (ENG-580, a blank surface after a comment link) — no app-side "reclaim" toggle needed.
|
|
378
|
+
- **`holdStream(streamingId)`** pins the session to a named show at once, replacing any earlier hold.
|
|
379
|
+
Pass the id your route carries (a push, a deep link): one call replaces the release-then-hold pair
|
|
380
|
+
that re-pinned the old show when both ran in one commit (DEL-2882). `holdStream()` with no id still
|
|
381
|
+
pins whatever is on screen.
|
|
382
|
+
|
|
210
383
|
`capabilitiesFor(stream)` returns the flag set a stream calls for (`canBid`, `canEnterGiveaway`,
|
|
211
384
|
`showLiveBadge`, …), so one screen serves an ordinary live show and an auction. An auction is a live show
|
|
212
385
|
plus bidding — the flags are derived, not spelled out twice.
|
|
213
386
|
|
|
387
|
+
### Taps on the phone (0.9.1)
|
|
388
|
+
|
|
389
|
+
`liveHaptics.tick()` as each name rolls past in a giveaway draw, `liveHaptics.knock()` as a bidding war
|
|
390
|
+
starts, `liveHaptics.success()` when the shopper wins the draw or the auction. Pass expo-haptics to
|
|
391
|
+
`configure` (`haptics: Haptics`, from `import * as Haptics from 'expo-haptics'`, a native module) and call
|
|
392
|
+
them where your tiles draw those moments. Without it, and always on the web, they do nothing; a failed tap
|
|
393
|
+
is dropped; ticks are capped at 20 a second. The package doesn't depend on expo-haptics (an optional
|
|
394
|
+
`require` wouldn't bundle in an app without it).
|
|
395
|
+
|
|
214
396
|
## 4. Adapters
|
|
215
397
|
|
|
216
398
|
The gateway only ever speaks in bare numeric product ids, because that is what the host dashboard
|
|
@@ -265,20 +447,133 @@ what the host mutes by.
|
|
|
265
447
|
Build a **disposable** cart, not the shopping cart: an auction win is already a commitment at a settled
|
|
266
448
|
price, and mixing those lines into a browsing cart lets a quantity stepper change it.
|
|
267
449
|
|
|
268
|
-
On Shopify each line carries a hidden `_winningBid` (
|
|
269
|
-
transform** function on the store reads to reprice
|
|
270
|
-
|
|
271
|
-
|
|
450
|
+
On Shopify each line carries a hidden `_winningBid` attribute (a prize: the store's prize key, such as
|
|
451
|
+
`_isGiveawayItem` or `_giveawayItem`) that a **cart transform** function on the store reads to reprice
|
|
452
|
+
the line server-side. Each line also needs something unique in its attributes (`_winIndex`) — Shopify
|
|
453
|
+
merges lines sharing the same merchandise *and* attributes, so two identical wins collapse into one
|
|
454
|
+
quantity-2 line priced as a single lot, and the second win is free. The cart itself carries
|
|
455
|
+
`source_name`, `source_type: "auction"` and `auction_id`, which the order webhook matches;
|
|
456
|
+
`createShopifyLiveCommerce` (below) builds exactly this.
|
|
457
|
+
|
|
458
|
+
**The wins can come from more than one show.** The bid gate stops a customer bidding on a second stream
|
|
459
|
+
while the first is unpaid, but nothing gates a giveaway, so a shopper can win one on a later show. Since
|
|
460
|
+
0.9.0 `auctions.wins()` and `useAuctionWins` return every unpaid stream's lots, and each item names its
|
|
461
|
+
own `streamingId`. `auctionId` is still one stream, the first with something payable: put it on the cart
|
|
462
|
+
as `auction_id`, as before, and build one cart for every item, because a checkout opens one cart. The
|
|
463
|
+
host's order webhook matches that one `auction_id`, so lots from another stream may stay listed as
|
|
464
|
+
unpaid until the host clears them too.
|
|
272
465
|
</details>
|
|
273
466
|
|
|
467
|
+
### On Shopify (0.9.0)
|
|
468
|
+
|
|
469
|
+
For an app whose storefront is `@tiledev/sdk-shopify` (`>=0.10.0`, a required peer). These build the
|
|
470
|
+
adapters above from the app's own sdk-shopify client and customer, and give the cart its
|
|
471
|
+
giveaway-prize helpers. They are plain functions that load nothing of sdk-shopify: the app passes its
|
|
472
|
+
own `shopify` client and customer in, so the copy of sdk-shopify the app configured is the one used.
|
|
473
|
+
(The Tile preview, or an app testing an unpublished build, can hold two copies, and the other one has
|
|
474
|
+
no store and no customer.) The few fields they read are declared here (`ShopifyProductsAndCart`,
|
|
475
|
+
`ShopifyProductForLive`, `ShopifyCustomerSession`, …), and the app's compiler checks sdk-shopify's real
|
|
476
|
+
objects against them where it passes them in.
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
480
|
+
import {
|
|
481
|
+
LiveSellingProvider, createShopifyLiveCommerce, createShopifyLiveIdentity,
|
|
482
|
+
} from '@tiledev/sdk-apptile-live-selling';
|
|
483
|
+
import { shopify, useCustomer } from '@tiledev/sdk-shopify';
|
|
484
|
+
|
|
485
|
+
function AppLiveSellingProvider({ children }) {
|
|
486
|
+
const customer = useCustomer();
|
|
487
|
+
const latestCustomer = useRef(customer);
|
|
488
|
+
latestCustomer.current = customer;
|
|
489
|
+
|
|
490
|
+
const config = useMemo(() => {
|
|
491
|
+
const signedIn = createShopifyLiveIdentity(() => latestCustomer.current);
|
|
492
|
+
return {
|
|
493
|
+
apiUrl, companyId, zegoAppId,
|
|
494
|
+
storage: AsyncStorage,
|
|
495
|
+
identity: signedIn.identity,
|
|
496
|
+
customerAccountRequest: signedIn.customerAccountRequest,
|
|
497
|
+
commerce: createShopifyLiveCommerce({
|
|
498
|
+
shopify,
|
|
499
|
+
cartSourceName: 'apptile-mobile-app-builder',
|
|
500
|
+
prizeWriteKey: '_isGiveawayItem', // the key YOUR store's cart transform reads; no default
|
|
501
|
+
buyer: signedIn.buyer,
|
|
502
|
+
imageTransform: { maxWidth: 600 },
|
|
503
|
+
}),
|
|
504
|
+
};
|
|
505
|
+
}, []);
|
|
506
|
+
return <LiveSellingProvider config={config}>{children}</LiveSellingProvider>;
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**`createShopifyLiveCommerce(options)`** returns `{ productsByIds, createAuctionCheckout }`. Add your
|
|
511
|
+
own `addLine` beside them. Each product's `raw` is the sdk-shopify `Product` it was read as; read it
|
|
512
|
+
typed with `useAuctionWins<Product>()` / `useLiveSession<Product>()`.
|
|
513
|
+
|
|
514
|
+
| Option | Required | What it is |
|
|
515
|
+
|---|---|---|
|
|
516
|
+
| `shopify` | yes | The app's sdk-shopify client (`shopify`): its `products.byIds` and `cart.create` |
|
|
517
|
+
| `cartSourceName` | yes | `source_name` on the wins cart |
|
|
518
|
+
| `prizeWriteKey` | yes | The line attribute this store's cart transform reads for a prize. No default: a wrong one charges a winner full price |
|
|
519
|
+
| `buyer` | yes | Who pays: `{ customerAccessToken, email? }`, or null for a guest cart. `createShopifyLiveIdentity(...).buyer` |
|
|
520
|
+
| `imageTransform` | no | The products' photo size, passed to `products.byIds` |
|
|
521
|
+
| `extraLineAttributes` | no | `(win) => ShopifyAttribute[]`: more attributes on each win's line, after the adapter's own (a final-sale stamp, say) |
|
|
522
|
+
| `extraCartAttributes` | no | `(lines) => ShopifyAttribute[]`: more attributes on the cart, after the adapter's own (an attribution record, say) |
|
|
523
|
+
|
|
524
|
+
- `productsByIds(ids)`: one `products.byIds` read. A bare id becomes a product GID, a missing product is
|
|
525
|
+
left out, and each `LiveProduct` keeps the Shopify `Product` on `raw`. Each variant carries its own
|
|
526
|
+
photo, when it has one (`imageUrl`, 0.9.0).
|
|
527
|
+
- `createAuctionCheckout(wins, auctionId)`: the contract above. A cart of its own; each win a line of
|
|
528
|
+
one stamped `_winningBid` (or `prizeWriteKey: "true"`) and `_winIndex`; the giveaway discount codes;
|
|
529
|
+
`source_name`, `source_type: "auction"`, `auction_id`; the buyer when there is one (a guest cart when
|
|
530
|
+
there isn't, or reading it fails). Each win is charged on the variant `useAuctionWins` shows: the
|
|
531
|
+
recorded one when it belongs to the product, else the first that can be sold, else the first. Throws
|
|
532
|
+
when there is nothing to check out or none of the wins can be sold.
|
|
533
|
+
|
|
534
|
+
**`createShopifyLiveIdentity(readCustomer)`** takes a function that returns sdk-shopify's
|
|
535
|
+
`useCustomer()` result as it is now (keep it in a ref, as above), and calls it each time the SDK asks.
|
|
536
|
+
It returns:
|
|
537
|
+
|
|
538
|
+
- `identity`: `bidderId`, the customer's bare numeric id, and `customerRef`, their GID, both only for a
|
|
539
|
+
Shopify sign-in (the winner's record is on the Customer Account API, so an email-and-password
|
|
540
|
+
session can't bid); `customerName`, first and last name; `customerEmail`.
|
|
541
|
+
- `customerAccountRequest`: the customer's own `request`.
|
|
542
|
+
- `buyer`: their access token and email, for `createShopifyLiveCommerce`; null when signed out.
|
|
543
|
+
|
|
544
|
+
**The cart's giveaway prizes.** The live-selling service adds a won prize to the winner's shopping cart,
|
|
545
|
+
stamped, and the store's cart transform makes it free.
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import { isGiveawayPrizeLine, prizeLinePrice, useAuctionWins } from '@tiledev/sdk-apptile-live-selling';
|
|
549
|
+
|
|
550
|
+
const { giveawayVariantIds } = useAuctionWins(); // prizes the host put in with no stamp
|
|
551
|
+
const prize = isGiveawayPrizeLine(line, { keys: ['_isGiveawayItem'] }) ||
|
|
552
|
+
giveawayVariantIds.has(line.merchandise.id);
|
|
553
|
+
const { nowPerUnit, costsNothing, repriced } = prizeLinePrice(line);
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- `isGiveawayPrizeLine(line, { keys? })`: stamped with any of `keys`, with any value but `"false"`.
|
|
557
|
+
`keys` defaults to `GIVEAWAY_PRIZE_KEYS`, both spellings in use (`_isGiveawayItem`, which the service
|
|
558
|
+
writes, and `_giveawayItem`, which some auction carts write). A store that reads one passes it.
|
|
559
|
+
Reading a line as a prize takes its stepper away, so a prize line can't be removed or changed.
|
|
560
|
+
- **The stamp proves a prize, never a price.** The store's repricing may not have run (lines charged
|
|
561
|
+
in full have been seen), so show what the line costs. `prizeLinePrice(line)`: the line's total
|
|
562
|
+
shared over its quantity (`nowPerUnit`), because Shopify zeroes the total and leaves
|
|
563
|
+
`amountPerQuantity` at the full price; `costsNothing` when the total is 0; `repriced` when it is less
|
|
564
|
+
than the unit price, for the unit price struck through.
|
|
565
|
+
- `useAuctionWins().giveawayVariantIds`: the variants this customer won as prizes (empty on the web).
|
|
566
|
+
|
|
274
567
|
## 5. Auctions — the two rules that matter
|
|
275
568
|
|
|
276
569
|
**Nothing is optimistic.** A viewer *offers* a bid to the round's moderator and waits; only the host's
|
|
277
570
|
broadcast sets the high bid, the leader and the deadline. That is what makes two phones agree.
|
|
278
571
|
|
|
279
572
|
**Deadlines are on the host's clock**, carried as `serverNowMs` on every event that moves them. A phone
|
|
280
|
-
minutes out of sync would otherwise close a round that is still open. `auction.
|
|
281
|
-
|
|
573
|
+
minutes out of sync would otherwise close a round that is still open. `auction.closesAt` is already
|
|
574
|
+
moved onto the phone's clock (`endsAtMs − serverOffsetMs`), so `useAuctionCountdown(auction.closesAt)`
|
|
575
|
+
counts the host's time. If the host goes quiet, the session closes the round itself 1 s past the
|
|
576
|
+
deadline: a slow last-instant bid, which also extends the clock, still lands first.
|
|
282
577
|
|
|
283
578
|
`useAuctionBidGate` owns the four ordered conditions in front of a bid and reports which one is current;
|
|
284
579
|
you render the panels. The order is the whole point:
|
|
@@ -289,21 +584,89 @@ const gate = useAuctionBidGate({
|
|
|
289
584
|
isLoggedIn, biddingOpen: auction.biddingOpen,
|
|
290
585
|
variantCount: biddingProduct?.variants.length ?? 0,
|
|
291
586
|
placeBid: actions.placeBid,
|
|
587
|
+
// 0.9.0, all optional:
|
|
588
|
+
atAmountShown: true, round: auction, // a bid goes out at the amount shown, or not at all
|
|
589
|
+
startSignIn, // the gate signs the shopper in, then comes back to the bid
|
|
590
|
+
signInLoading, // signed in, the bidder still loading: hold, start no sign-in
|
|
591
|
+
screenOnShow: isFocused, // back on show still signed out: the held bid is dropped
|
|
292
592
|
});
|
|
293
593
|
|
|
294
594
|
// gate.blocker: 'signIn' | 'unpaidWins' | 'terms' | 'variant' | null
|
|
295
595
|
gate.requestBid(); // the round's minimum — the slide gesture
|
|
296
596
|
gate.requestBid(250); // a custom bid
|
|
597
|
+
gate.refusal; // { id, reason: 'outbid' | 'overCap', amount } | null (atAmountShown)
|
|
598
|
+
gate.bidOnItsWay; // true for BID_ON_ITS_WAY_MS (1.2 s) after a bid goes
|
|
297
599
|
```
|
|
298
600
|
|
|
299
601
|
Signed in → nothing outstanding on another stream → rules accepted once per install → a variant chosen
|
|
300
602
|
(once per round, not per bid). The requested bid is held while you clear the blockers and then placed, so
|
|
301
|
-
finishing the last panel completes the bid they originally asked for.
|
|
302
|
-
|
|
603
|
+
finishing the last panel completes the bid they originally asked for. A held bid belongs to its round:
|
|
604
|
+
when bidding closes it is dropped and its panel closed. Call `gate.refreshUnpaidWins()` from your own
|
|
605
|
+
focus effect, so a customer who leaves to settle a tab is unblocked on return.
|
|
606
|
+
|
|
607
|
+
- **`atAmountShown`** (with `round: session.auction`): `requestBid()` holds the next bid as it is at the
|
|
608
|
+
tap, not as it will be once the panels close, and the bid gets a last look at the round as it goes
|
|
609
|
+
(`bidRefusal`: open with time left, at most the ceiling, at least the next bid). A closed round sends
|
|
610
|
+
nothing; outbid or over the ceiling sends nothing and sets `refusal` (`amount`: the next bid to beat,
|
|
611
|
+
or the ceiling) for you to show.
|
|
612
|
+
- **`startSignIn`**: the gate never stops at `signIn`. It holds the bid and starts your sign-in; once
|
|
613
|
+
`isLoggedIn`, if bidding on that lot is still open, it reads the unpaid wins fresh and goes through the
|
|
614
|
+
gate again from there. Dropped when the lot closes, when another bid is asked for, or when
|
|
615
|
+
`screenOnShow` turns true again with the shopper signed out (your Sign In page closed). A sign-in that
|
|
616
|
+
never leaves the screen keeps it until the lot closes. Without `startSignIn` the gate stops at `signIn`.
|
|
617
|
+
- **`actions.placeBid`** says whether the bid went: `{ sent: true }`, or `{ sent: false, reason }`
|
|
618
|
+
(`noModerator` is L3, below).
|
|
303
619
|
|
|
304
620
|
If you render the panels as React Native `Modal`s, honour `SHEET_HANDOFF_MS` between them — presenting
|
|
305
621
|
one while another dismisses is a native iOS exception, not a catchable error.
|
|
306
622
|
|
|
623
|
+
**The round's other rules (0.9.0), pure:**
|
|
624
|
+
|
|
625
|
+
- `auction.lastWin.isYou`: the shopper led the round as it closed and the winning bid is theirs
|
|
626
|
+
(`isYourWin`). The room names the winner only by name; this is the best a phone can tell.
|
|
627
|
+
- `lotStateFor(storeProductId, auction, show)`: `'open'` while a round is open on the lot, `'closed'` once
|
|
628
|
+
won (the record's `winnerCount`, or `auction.wonLotIds`, the lots this phone saw won this show), else
|
|
629
|
+
`'upcoming'`.
|
|
630
|
+
- The custom bid sheet: `quickBids(nextBid, bidCap)` (the next bid rounded up, plus 5, 20, 60 and 100,
|
|
631
|
+
none over the ceiling) and `winChance(amount, nextBid)` (`'veryHigh'`: 50 over or double; `'good'`: 20
|
|
632
|
+
over or half again; `'medium'`: anything over; else `'tooLow'`).
|
|
633
|
+
- The chat: `classifyComment(text)` → `{ kind: 'winner' }` | `{ kind: 'bid', label: 'Bid ', amount:
|
|
634
|
+
'$64.00' }` | `{ kind: 'plain' }`, for the room's bid and winner lines, which arrive as plain text.
|
|
635
|
+
|
|
636
|
+
### Giveaways (0.9.0)
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
const entry = useGiveawayEntry({
|
|
640
|
+
session, // useLiveSession()
|
|
641
|
+
canEnter: onAir && capabilities.canEnterGiveaway, // useLiveShow(streamId)
|
|
642
|
+
isLoggedIn, startSignIn,
|
|
643
|
+
screenOnShow: isFocused && !signInSheetUp,
|
|
644
|
+
cartId,
|
|
645
|
+
});
|
|
646
|
+
// entry.open, entry.sheetOpen, entry.openSheet(), entry.closeSheet()
|
|
647
|
+
// entry.enter(typedName?), entry.entering, entry.entryFailed
|
|
648
|
+
// entry.signInToEnter() back here signed in, the sheet opens again
|
|
649
|
+
// entry.draw, entry.youWon, entry.drawDone() the draw once its roster is ready; never one from before
|
|
650
|
+
// this screen opened
|
|
651
|
+
|
|
652
|
+
const { product, variant, readError } = useGiveawayPrize<Product>(entry.open);
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
- **Signing in to enter:** `signInToEnter()` starts your sign-in (close the sheet first: it is a
|
|
656
|
+
`Modal`). Once `isLoggedIn` and `screenOnShow`, the sheet opens again if the giveaway is still open and
|
|
657
|
+
not drawn; the shopper taps Enter themselves. Back on show still signed out, nothing reopens.
|
|
658
|
+
- **The entry:** `actions.enterGiveaway` resolves `{ entered: true }` once the host took it, else
|
|
659
|
+
`{ entered: false, reason }`; `entryFailed` says so. With a typed name (`enter(name)`), the name is
|
|
660
|
+
saved first and the entry goes once the session holds it.
|
|
661
|
+
- **"You won":** `giveaway.result.isYou`, by the customer id the record names on this draw's giveaway
|
|
662
|
+
(`LiveGiveaway.winnerId`) when a read has it, else among those who entered by name, spaces and capitals
|
|
663
|
+
aside (`sameRoomName`). `youEntered` keeps the entry past the draw.
|
|
664
|
+
- **The prize:** one `productsByIds` read through your adapter when the prize changes, cleared first;
|
|
665
|
+
`variant` is the one given away. The record's name, variant, price and photo stand in until it
|
|
666
|
+
answers. `readError` is yours to report.
|
|
667
|
+
- Both hooks take values rather than reading the session, so they run on a stand-in session in your
|
|
668
|
+
tests, and on the web, where the session is idle.
|
|
669
|
+
|
|
307
670
|
## 6. Platform boundaries
|
|
308
671
|
|
|
309
672
|
Two pairs of files here, and Metro picks by platform. TypeScript resolves the non-native one, which
|
|
@@ -312,7 +675,7 @@ lives in the native package.)
|
|
|
312
675
|
|
|
313
676
|
| Native (iOS/Android) | Everywhere else |
|
|
314
677
|
|---|---|
|
|
315
|
-
| `engine.native.ts` — the real Zego
|
|
678
|
+
| `engine.native.ts` — hands the real Zego module, `Platform.OS`, picture-in-picture and the token call to `ZegoLiveEngine` (`zegoEngine.ts`), which owns the room, plays into a rebindable view, publishes a guest's camera, and translates every Zego channel into a `LiveEvent` | `engine.ts` — no-op, `isNoop: true` |
|
|
316
679
|
| `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 |
|
|
317
680
|
|
|
318
681
|
The web file is not only about behaviour. `LiveVideoTarget` requires `zego-express-engine-reactnative`
|
|
@@ -328,8 +691,14 @@ entry module.
|
|
|
328
691
|
|
|
329
692
|
**Picture-in-picture** is two stages. `actions.enterPip()` shrinks to a surface *you* draw; the session
|
|
330
693
|
arms the OS handover in advance, so minimising the app opens a real system window with no JavaScript
|
|
331
|
-
involved at that moment. Coming back
|
|
332
|
-
|
|
694
|
+
involved at that moment. Coming back puts the picture where the window took it from: your bubble again
|
|
695
|
+
(`'inapp'`), or, for a window your full-screen player opened (`autoPip`), `'none'` and `onPipRestore` —
|
|
696
|
+
navigate to your live screen, which the SDK can't do itself from above the navigator.
|
|
697
|
+
|
|
698
|
+
To come back to the same picture with no black frame, keep the bubble's `LiveVideoTarget` mounted and
|
|
699
|
+
active while an iOS window that came from it is up (`pipMode` `'system'`): the engine goes on drawing
|
|
700
|
+
into that view, so the bubble needs no new binding when the window hands it back. Keep the full-screen
|
|
701
|
+
player's view mounted too (its `active` off), as the README's usual `active` expression already does.
|
|
333
702
|
|
|
334
703
|
## 7. Imperative client
|
|
335
704
|
|
|
@@ -338,7 +707,7 @@ report state.
|
|
|
338
707
|
|
|
339
708
|
| Namespace | Methods |
|
|
340
709
|
|---|---|
|
|
341
|
-
| `streams` | `list` `ongoing` `byId` `zegoToken` |
|
|
710
|
+
| `streams` | `list` `ongoing` `byId` `zegoToken` `commentHistory` `destinationFor` |
|
|
342
711
|
| `auctions` | `recordBid` `enterGiveaway` `hasAcceptedTerms` `acceptTerms` `unpaidStreamIds` `wins` `isBiddingBlocked` |
|
|
343
712
|
| `polls` | `vote` `loadVotedOption` `rememberVote` `forgetVote` |
|
|
344
713
|
| `replays` | `list` `segments` `comments` `incrementViews` |
|
|
@@ -349,7 +718,7 @@ report state.
|
|
|
349
718
|
| `engine` | the Zego engine (above) |
|
|
350
719
|
|
|
351
720
|
Read hooks for screens that only need data: `useOngoingLive`, `useLiveStreams`, `useStreamInfo`,
|
|
352
|
-
`useReplays`, `useClips`, `useAuction
|
|
721
|
+
`useReplays`, `useClips`, `useAuction`; and `useLiveShow`, the session's record for one show (§3).
|
|
353
722
|
|
|
354
723
|
**`useOngoingLive` is the one hook that polls, on purpose, to make the app experience better.** A
|
|
355
724
|
show that starts while the shopper already has the app open is worth catching, and no event reaches
|
|
@@ -368,12 +737,60 @@ turn it off in the config: `ongoingLivePolling: { whenIdleMs, whileLiveMs }` or
|
|
|
368
737
|
interval. 10,000 apps open at 2 minutes is about 83 requests a second; 100,000 is about 830. Raise
|
|
369
738
|
`whenIdleMs` for a large audience, or send a "show started" push and call `refreshOngoingLive()`.
|
|
370
739
|
|
|
371
|
-
`useReplays({ playableOnly: true })` keeps only replays with a playable video.
|
|
740
|
+
`useReplays({ playableOnly: true })` keeps only replays with a playable video. `hasRead` (0.9.0) turns
|
|
741
|
+
true when its first read has finished, successfully or not, and stays true: before it, an empty list
|
|
742
|
+
is still loading, not "no shows". Shows hidden from the viewer are left out, so a page can show nothing
|
|
743
|
+
while the archive goes on; read on while the list is empty, `hasRead` and `hasMore`.
|
|
744
|
+
|
|
745
|
+
**Replays, since 0.9.0:**
|
|
746
|
+
- `useReplays` pages by what the gateway **sent** (`ReplayFeed.receivedCount`), not by what it kept
|
|
747
|
+
after dropping entries without a playlist. Until 0.9.0 a page with an unplayable entry made the next
|
|
748
|
+
page overlap it, and a page with nothing playable on it was asked for again forever. An empty page
|
|
749
|
+
is the end (`hasMore: false`), whatever the gateway's flag says. One read at a time: `loadMore`
|
|
750
|
+
does nothing while another read or a `refresh` is running.
|
|
751
|
+
- `replays.comments(streamingId)` reads the show's chat file,
|
|
752
|
+
`https://cdn.apptile.io/assets/live-replay-comments/<streamingId>.json` (no company header). The
|
|
753
|
+
gateway's `/live-streaming/comments/{id}`, which it read until 0.9.0, answers 404 for every
|
|
754
|
+
recording. No file (403) means nobody talked: an empty list. The host's own rows keep the name the
|
|
755
|
+
file gives them, `"You"`; show your brand's name there instead.
|
|
372
756
|
|
|
373
757
|
`LiveEvent` is the union the engine emits — `comment`, `reaction`, `sold`, `viewers`, `productChange`,
|
|
374
|
-
`poll*`, `biddingStarted`/`bidInfo`/`biddingClosed`/`auctionWon`, `giveaway*`,
|
|
375
|
-
signals, and an `unknown` fallthrough so a new host-side message is visible rather
|
|
376
|
-
Subscribe via `LiveSellingProvider`'s `onEvent` or `liveSelling.engine.subscribe`.
|
|
758
|
+
`poll*`, `biddingStarted`/`bidInfo`/`biddingClosed`/`auctionWon`, `giveaway*`, `pinnedCommentChanged`,
|
|
759
|
+
the `guest*` co-host signals, and an `unknown` fallthrough so a new host-side message is visible rather
|
|
760
|
+
than dropped. Subscribe via `LiveSellingProvider`'s `onEvent` or `liveSelling.engine.subscribe`.
|
|
761
|
+
|
|
762
|
+
**The engine, since 0.9.0:**
|
|
763
|
+
- Zego hands messages over in **batches**; every entry of a barrage, broadcast or stream-extra-info
|
|
764
|
+
batch is read. Until 0.9.0 only the first was, and the rest vanished.
|
|
765
|
+
- A room counts as joined only once `loginRoom` succeeds. A refused login is tried 3 times (waiting
|
|
766
|
+
1 s, then 2 s), and a join that failed can simply be asked for again — before, a failed login left
|
|
767
|
+
the engine believing it was in the room, and every later join for that show returned early.
|
|
768
|
+
- `roomTokenWillExpire` fetches a fresh token (`streams.zegoToken`) and calls `renewToken`, so a long
|
|
769
|
+
watch is no longer dropped when the token runs out.
|
|
770
|
+
- A subscriber that throws is logged and skipped; leaving never throws; and only this package's own
|
|
771
|
+
Zego handlers are ever detached (L16 — `playerQualityUpdate` was attached twice).
|
|
772
|
+
- `bidInfo` carries `moderatorId` when the gateway sends one, and the session remembers the last
|
|
773
|
+
moderator of the show, so a viewer who missed a round's opening can still bid when one is known.
|
|
774
|
+
When none is, `placeBid` logs a warning and sends nothing — not even the REST ledger record — and
|
|
775
|
+
resolves `{ sent: false, reason: 'noModerator' }`, so an app can tell the shopper.
|
|
776
|
+
|
|
777
|
+
### Needs the backend
|
|
778
|
+
|
|
779
|
+
The SDK reads these the moment they exist; until then the gaps above remain.
|
|
780
|
+
|
|
781
|
+
- **The moderator on bid info (L3).** Add `moderatorInCharge` (the same `{ userID, userName }` that
|
|
782
|
+
`START_BIDDING` carries) to every `BROADCAST_BID_INFO` `data`, and to the stream record while a round
|
|
783
|
+
is open. Without it, a viewer who joined after the round opened — and has not heard any earlier
|
|
784
|
+
round's opening this show — has nobody to address `BID_PLACED` to.
|
|
785
|
+
- **A winner id.** The SDK reads only a display name from `BID_WINNER_ANNOUNCEMENT`
|
|
786
|
+
(`bidWinner.meta.user.userName`) and from `giveaway_end` (`winner.displayName`). So an auction's
|
|
787
|
+
"you won" is inferred (the shopper led as the round closed, at the winning amount), and a giveaway's
|
|
788
|
+
is matched by name unless a record read after the draw carries the drawn giveaway's `winner` (a
|
|
789
|
+
customer GID, read since 0.9.0). Send the winner's customer id on both events — the same bare id
|
|
790
|
+
`BROADCAST_BID_INFO.user.userId` uses — and the SDK can compare it with `identity.bidderId()`.
|
|
791
|
+
- **Entrant names on `giveaway_end`.** The reveal's roster is read back off the record because the end
|
|
792
|
+
event names only the winner, and the record lags it (ENG-705). Send the entrant display names (or a
|
|
793
|
+
sample) on the event and the 5-second wait goes away.
|
|
377
794
|
|
|
378
795
|
## Releasing
|
|
379
796
|
|
|
@@ -419,9 +836,10 @@ two copies of the same Expo module, and identical pod and module names collide a
|
|
|
419
836
|
| `npm run build` | Cleans and compiles `src` → `dist` |
|
|
420
837
|
| `npm run lint` | Typechecks, emitting nothing |
|
|
421
838
|
| `npm run clean` | Removes `dist` |
|
|
839
|
+
| `npm test` | Compiles `src` + `test` to `.test-dist` and runs `node --test` — the reducer, the engine (against a stand-in Zego), the transport and the mappers |
|
|
422
840
|
| `npm pack` | The publish tarball — `dist`, `types`, `session` and nothing native |
|
|
423
841
|
|
|
424
|
-
The reducer is exported on its own (`sessionReducer`, `deriveStatus`, `
|
|
842
|
+
The reducer is exported on its own (`sessionReducer`, `deriveStatus`, `roundClosesAt`, `nextExpiryAt`) and is pure, so it
|
|
425
843
|
can be driven with no room, socket or React — from Node, import it as
|
|
426
844
|
`@tiledev/sdk-apptile-live-selling/session` rather than off the root.
|
|
427
845
|
|