Architecture
There are two layers: the package (the mock + offer UI) and your app’s wiring (a thin indirection that picks mock or real).
Inside react-native-encore-mock
react-native-encore-mock
├── createMockEncore(config?) # the in-memory EncoreClient + offer state machine
├── MockEncoreProvider # hosts the offer UI (retention sheet + sponsored carousel)
├── useEncoreCallbacks(...) # registers onPurchaseRequest / onPassthrough / onPurchaseComplete
├── isGranted(result) # granted | completed → true
├── DEFAULT_OFFERS, DEFAULT_PARTNERS
└── types # EncoreClient, PlacementResult, MockOffer, PartnerTrial, …Your app’s wiring
A consuming app keeps a tiny indirection layer. Hotspot Havoc looks like this:
src/lib/
├── encoreConfig.js # ENCORE_API_KEY, USE_MOCK, Placements
├── offers.js # this app's offers (pulls in DEFAULT_PARTNERS)
├── encore.js # THE indirection: createMockEncore() vs real SDK
├── billingService.js # YOUR billing (RevenueCat / IAP stand-in)
└── encoreSdkStub.js # build-time stub so the dormant real-SDK import resolves
metro.config.js # aliases @tryencorekit/react-native → stub in mock mode
App.js # <MockEncoreProvider> + useEncoreCallbacksThe one rule
Everything imports Encore from one indirection file — never the package or the real SDK directly. That file decides mock vs real:
const mockClient = createMockEncore({ offers: GAME_OFFERS });
const Encore = USE_MOCK ? mockClient : realEncore;
export default Encore;Flipping USE_MOCK re-points the whole app — no call-site edits. See
Mock vs Live Mode.
Who owns entitlement state?
Your app does — not Encore. Encore presents offers and reports results; your
app records the outcome. Keep entitlement in your own store (or derive it from your
billing provider/backend) and update it from placement().show() results +
onPurchaseComplete. This is why the same code works identically in mock and live mode.
Data flow
show() resolves with a PlacementResult (the real Encore shape):
interface PlacementResult {
status: 'granted' | 'completed' | 'not_granted' | 'dismissed' | 'no_offers';
reason?: string;
entitlement?: string;
offerId?: string;
campaignId?: string;
}The two channels carry complementary information:
show()resolves withresult.status— the screen’s primary signal. TheisGranted(result)helper istrueforgranted(a completed paid purchase) orcompleted(an accepted brand-sponsored offer).onPassthroughfires on dismissal so you can resume the user’s original action (finalize a cancellation, close a paywall) and never trap them behind the SDK.