shopflo-appbrew 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # shopflo-appbrew
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shopflo one-click checkout. Replaces the native checkout flow with a Shopflo-hosted checkout rendered in a WebView.
4
+
5
+ ## Blocks
6
+
7
+ Registered via `registerShopfloBlocks(r)`:
8
+
9
+ - `shopflo-web-view` — WebView that loads a Shopflo checkout. Pass `page="cart"` to open Shopflo's cart instead; the registered cart screen does.
10
+
11
+ ## Cart and checkout screens
12
+
13
+ `registerShopfloScreens()` replaces the `cart` screen and adds `shopflo-checkout`, and sends every native checkout link (`checkout-address`, `add-address`, `payment`) to Shopflo. Call it in `initApp`, before the first render — the navigator reads the screen registry when it builds.
14
+
15
+ ```ts
16
+ registerShopfloScreens()
17
+ onConfigReady(() => setUpShopflo({ shopUrl, themeId, applicationId }))
18
+ ```
19
+
20
+ The cart screen shows the native (config-driven) cart until Shopflo is usable, then the Shopflo WebView:
21
+
22
+ | State | Cart tab shows |
23
+ | --- | --- |
24
+ | Health check pending, failed, or `enabled: false` | Native cart |
25
+ | Shopflo enabled, cart empty | Native cart (Shopflo's token API rejects an empty cart) |
26
+ | Shopflo enabled, cart has items | Shopflo WebView, kept after purchase so the thank-you page stays |
27
+
28
+ The status is reactive: a cart opened before the health check settles switches to Shopflo when it lands. A failed health check (network error or non-2xx) is retried up to 3 times, 1 s apart, before falling back to native.
29
+
30
+ ### What a registered screen has to provide
31
+
32
+ `app.components.register('screen', 'cart', …)` replaces the whole `ScreenBrewery`, not just its blocks. Everything `ScreenBrewery` normally does is now the screen's job:
33
+
34
+ - **Safe areas** — wrap the content in `SafeAreaContainer` from `@gauntlet/brewery`. Without it the WebView starts at y = 0 and Shopflo's header and close button sit under the status bar, where iOS swallows the taps.
35
+ - **Sizing** — there is no `ScreenContext`, so no `contentHeight`. `ShopfloWebview` sizes itself with `flex: 1` when it renders outside `ScreenBrewery`.
36
+ - **Bottom bar** — `cart` is a bottom-bar tab in most themes. If the theme shows the bar it is drawn over the screen, so leave room for it.
37
+
38
+ | Before | After |
39
+ | --- | --- |
40
+ | ![Shopflo header under the status bar](docs/images/cart-before.png) | ![Shopflo cart inside the safe area](docs/images/cart-after.png) |
41
+
42
+ ### Keeping the Appbrew app bar (recommended)
43
+
44
+ If you need the Appbrew app bar as the header, render the WebView *inside* `ScreenBrewery` with `renderContent`, and control the rest from the `cart` screen's config. `ScreenBrewery` keeps its app bar, safe areas, `ScreenContext` and bottom-bar spacing, and swaps only the block list for what `renderContent` returns:
45
+
46
+ ```tsx
47
+ showedShopflo.current = true
48
+ return (
49
+ <ScreenBrewery
50
+ id={nativeScreenId}
51
+ style={{ backgroundColor: '#fff', flex: 1 }}
52
+ options={options}
53
+ renderContent={() => (
54
+ <BlockOverrideContext.Provider value={shopfloBlock}>
55
+ <ShopfloWebview
56
+ screenId={shopfloBlock.screenId}
57
+ componentId={shopfloBlock.componentId}
58
+ instanceId={shopfloBlock.instanceId}
59
+ index={0}
60
+ length={1}
61
+ page="cart"
62
+ />
63
+ </BlockOverrideContext.Provider>
64
+ )}
65
+ />
66
+ )
67
+ ```
68
+
69
+ Then switch off what you don't need on the `cart` screen. Both are per-screen theme settings, so they apply to the theme you edit; publish it to take effect.
70
+
71
+ **In Studio:** open the app → **Design** → pick the theme → select the **Cart** screen, then click the element on the canvas to open its actions:
72
+
73
+ | What | Click on the canvas | Action | Theme config it writes |
74
+ | --- | --- | --- | --- |
75
+ | Checkout bar (the screen's footer, `checkout-bar-v2`) | the checkout bar at the bottom | **Hide block** (eye icon; **Show block** undoes it) | `screens.cart.layout.footer.visibility.hide: true` |
76
+ | App bar | the app bar at the top | **Hide app bar** (**Show app bar** undoes it) | `screens.cart.options.hideAppbar: true` |
77
+
78
+ **In the theme JSON** (the same two keys, if you edit config directly):
79
+
80
+ ```jsonc
81
+ "screens": {
82
+ "cart": {
83
+ "options": { "hideAppbar": true }, // false or absent: app bar shown
84
+ "layout": {
85
+ "footer": {
86
+ "componentId": "checkout-bar-v2",
87
+ "visibility": { "hide": true } // false or absent: checkout bar shown
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ What to set:
95
+
96
+ 1. **Checkout bar: always hide it.** `ScreenBrewery` renders it under the WebView, next to Shopflo's own Checkout button (screenshot below). Hiding it leaves no gap; the WebView takes the space.
97
+ 2. **App bar: your choice.** Keep it (`hideAppbar` false) to show the Appbrew header; its title and icons come from the screen's `layout.header`, falling back to the theme's global app bar, and are edited in Studio, not code. Hide it (`hideAppbar: true`) to give the WebView the full screen; `ScreenBrewery` still keeps it below the status bar.
98
+ 3. **Shopflo's own cart header: hide it** (on the Shopflo side) when you keep the app bar, or the screen shows two headers.
99
+
100
+ - Keep the `ShopfloWebview` call as is; only the wrapper changes. Drop the `SafeAreaContainer` — `ScreenBrewery` handles the insets.
101
+ - `ShopfloWebview` takes its height from `ScreenContext.contentHeight` here, so it fits between the app bar and the bottom bar without changes.
102
+ - These are config changes to the `cart` screen, which the native cart (the fallback when Shopflo is off or the cart is empty) also renders — check the native cart still looks right without them.
103
+
104
+ | App bar kept, checkout bar hidden | `hideAppbar: true` — full screen | Before the config changes |
105
+ | --- | --- | --- |
106
+ | ![Appbrew app bar over the Shopflo cart](docs/images/cart-appbar.png) | ![Shopflo cart full screen](docs/images/cart-appbar-hidden.png) | ![Two headers and two Checkout buttons](docs/images/cart-appbar-cart-screen.png) |
107
+
108
+ The last one is `renderContent` without the config changes: the app bar is in place, but Shopflo's header and the native checkout bar still show.
109
+
110
+ ### Messages from the Shopflo page
111
+
112
+ `ShopfloWebview` handles these `postMessage` events (only those with `source: 'SHOPFLO'`):
113
+
114
+ | `type` | Action |
115
+ | --- | --- |
116
+ | `FLO_CART_UPDATE` | Empty cart: reset the Shopify cart and go back |
117
+ | `FLO_ITEM_UPDATE` | Sync the line's quantity to the Shopify cart |
118
+ | `FLO_CART_TO_CHECKOUT` | Switch the same token to the checkout view |
119
+ | `CLOSE_CART_IFRAME` | Cart view's close (X): go back, or open `closeModalId` if set |
120
+ | `FLO_EXIT_CHECKOUT` | From checkout: go home; from the cart: same as `CLOSE_CART_IFRAME` |
121
+
122
+ ## Provider
123
+
124
+ `ShopfloProvider` implements `ICheckoutProvider`. `fetchCheckoutUrl()` exchanges the current Shopify cart token for a Shopflo checkout URL via `POST https://api.shopflo.co/kratos/api/v1/token/shopify-cart-token`, sending the cart token, `config.integrations.shopify.domain`, the storefront access token, and the customer access token (when signed in) — so the Shopify integration must be configured in the same app.
125
+
126
+ ## Settings
127
+
128
+ Declared in the `appbrew` manifest under `config.integrations.shopflo`:
129
+
130
+ | Key | Required | Purpose |
131
+ | --- | --- | --- |
132
+ | `salesChannelApiKey` | yes | Shopflo sales-channel API key, sent as the `Authorization` header (secret) |
133
+
134
+ ## Native dependencies
135
+
136
+ The manifest sets `requiresNativeBuild: true`. Peer dependencies shipping native code: `react-native-webview`, `react-native-in-app-review`, `react-native-send-intent`, `react-native-safe-area-context`, so installing this integration in an app requires a new binary.
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "shopflo-appbrew",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "main": "./src/index.ts",
5
+ "types": "./src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "peerDependencies": {
15
+ "@gauntlet/brewery": ">0.36.0",
16
+ "@gauntlet/components": ">0.36.0",
17
+ "@gauntlet/state": ">0.36.0",
18
+ "@gauntlet/types": ">0.36.0",
19
+ "@react-navigation/native": "*",
20
+ "react": "*",
21
+ "react-native": "*",
22
+ "react-native-in-app-review": "*",
23
+ "react-native-loading-dots": "*",
24
+ "react-native-safe-area-context": "*",
25
+ "react-native-send-intent": "^1.3.0",
26
+ "react-native-uuid": "^2.0.1",
27
+ "react-native-webview": "*"
28
+ },
29
+ "description": "Shopflo one-click checkout and discounts.",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/shopflo-co/appbrew-shopflo.git",
33
+ "directory": "packages/shopflo"
34
+ },
35
+ "license": "ISC",
36
+ "keywords": [
37
+ "appbrew-integration"
38
+ ],
39
+ "appbrew": {
40
+ "settings": [
41
+ {
42
+ "key": "salesChannelApiKey",
43
+ "title": "Api Key",
44
+ "secret": true
45
+ },
46
+ {
47
+ "key": "enableShopfloCart",
48
+ "title": "Enable Shopflo Cart",
49
+ "type": "boolean",
50
+ "required": false
51
+ }
52
+ ],
53
+ "configKey": "shopflo",
54
+ "requiresNativeBuild": true
55
+ }
56
+ }
@@ -0,0 +1,452 @@
1
+ import * as React from 'react'
2
+ import {
3
+ ScreenContext,
4
+ useAnalytics,
5
+ useAppStore,
6
+ useBlock,
7
+ useBottomSheetModal,
8
+ useDeviceDimensions,
9
+ useLink,
10
+ useSetOverrideBack,
11
+ useSetWebUrl,
12
+ } from '@gauntlet/state'
13
+ import { WebView } from 'react-native-webview'
14
+ import { Linking, Platform, View } from 'react-native'
15
+ import { AnalyticsEvent, BaseBlockProps } from '@gauntlet/types'
16
+ import LoadingDots from 'react-native-loading-dots'
17
+ import { useBlockSettings } from '@gauntlet/components/style-utils'
18
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
19
+ import InAppReview from 'react-native-in-app-review'
20
+ import SendIntentAndroid from 'react-native-send-intent'
21
+ import { ShopfloProvider } from '../provider'
22
+ import { isHomePage, isThankYou } from '@gauntlet/components/utils'
23
+ import { useIsFocused } from '@react-navigation/native'
24
+ import {
25
+ isShopifyCartUrl,
26
+ lastSegment,
27
+ toCartSignature,
28
+ toShopfloCartUrl,
29
+ } from '../utils/shopflo'
30
+ import { toUpiAppUrl } from '../utils/payment-intents'
31
+ import type { ShopfloPage } from '../types'
32
+
33
+ type ShopfloWebviewProps = BaseBlockProps & {
34
+ // 'cart' opens Shopflo's cart (cart token); 'checkout' a checkout (Token V2).
35
+ // Defaults to 'checkout', what themes placing this block have always got
36
+ page?: ShopfloPage
37
+ // Rendered by a registered screen, not placed by a theme: reaching the
38
+ // back_url closes this screen instead of navigating to 'cart'
39
+ isRegisteredScreen?: boolean
40
+ // Set by the registered screens: they fall back to the native cart /
41
+ // checkout when the token call fails, instead of the backup-checkout redirect
42
+ onUnavailable?: () => void
43
+ }
44
+
45
+ export function ShopfloWebview(props: ShopfloWebviewProps) {
46
+ const { isRegisteredScreen, onUnavailable, page = 'checkout' } = props
47
+ const webViewRef = React.useRef<WebView>(null)
48
+ const [checkoutData, setCheckoutData] = React.useState<
49
+ { checkoutUrl: string; tokenId: string; loadId: number } | undefined
50
+ >()
51
+ const isFocused = useIsFocused()
52
+ const cartSignature = useAppStore((s) => toCartSignature(s.cart.data.lineItems))
53
+ // Signature of the cart Shopflo currently shows; null until the first load
54
+ const loadedSignature = React.useRef<string | null>(null)
55
+ // Id of the newest token request; an older one resolving later is dropped
56
+ const latestRequest = React.useRef(0)
57
+ const block = useBlock(props.screenId, props.componentId, props.instanceId)
58
+ const settings = useBlockSettings(block)
59
+ const { width, height } = useDeviceDimensions()
60
+ const screen = React.useContext(ScreenContext)
61
+ const { gotoLink, back } = useLink()
62
+ const setWebUrl = useSetWebUrl()
63
+ const { openModal } = useBottomSheetModal()
64
+ const setOverrideBack = useSetOverrideBack()
65
+ const setCheckoutCompleted = useAppStore(
66
+ (s) => s.checkout.setCheckoutCompleted
67
+ )
68
+ const analytics = useAnalytics()
69
+ const { top, bottom } = useSafeAreaInsets()
70
+ const [keyboardHeight, setKeyboardHeight] = React.useState(0)
71
+ const appName = useAppStore.getState().constants.data?.env?.APP_NAME
72
+ const appVersion = useAppStore.getState().device.buildNumber
73
+ const shopifyDomain = useAppStore.getState().config.data?.integrations
74
+ ?.shopify?.domain as string
75
+ const userAgent = `Appbrew/${appName
76
+ ?.toLowerCase()
77
+ .replace(/\s/g, '')}/${Platform.OS.toLowerCase()}/${appVersion}`
78
+
79
+ const appbarHeight = useAppStore((store) => {
80
+ const h = Boolean(Number(store.config.data?.theme?.appBar.root?.height))
81
+ ? Number(store.config.data?.theme?.appBar.root?.height)
82
+ : 64
83
+ return h
84
+ })
85
+ const topOffset = settings?.options?.topOffset ?? 0
86
+ const defaultHeight = height - appbarHeight - top - bottom - topOffset
87
+
88
+ let h = screen?.value?.contentHeight ?? defaultHeight
89
+ if (!settings?.options?.ignoreKeyboardHeight && Platform.OS === 'android') {
90
+ h = h - keyboardHeight
91
+ }
92
+
93
+ const purchaseConfirmIdentifier = settings?.options?.purchaseConfirmIdentifier
94
+ const sendPurchasedEventDisabled =
95
+ settings.options?.sendPurchasedEventDisabled
96
+
97
+ const capturedCheckout = React.useRef(false)
98
+
99
+ React.useEffect(() => {
100
+ const id = screen?.value.keyboardHeightRef.current?.addListener((x) => {
101
+ setKeyboardHeight(x.value)
102
+ })
103
+ return () => {
104
+ if (id) {
105
+ screen?.value.keyboardHeightRef.current?.removeListener(id)
106
+ }
107
+ }
108
+ }, [screen])
109
+
110
+ const initializeWebView = React.useCallback(async () => {
111
+ if (!useAppStore.getState().modules.isModuleDefined('checkout')) {
112
+ console.error('Checkout module not found')
113
+ return
114
+ }
115
+
116
+ const checkoutProvider = useAppStore
117
+ .getState()
118
+ .modules.getModule<ShopfloProvider>('checkout')
119
+
120
+ if (!checkoutProvider) {
121
+ console.error('Failed to get checkout provider')
122
+ return
123
+ }
124
+
125
+ // Get the URL from the provider
126
+ loadedSignature.current = toCartSignature(
127
+ useAppStore.getState().cart.data.lineItems
128
+ )
129
+ const request = ++latestRequest.current
130
+ const result =
131
+ page === 'checkout'
132
+ ? await checkoutProvider.fetchCheckoutUrl()
133
+ : await checkoutProvider.fetchCartUrl()
134
+ // The cart changed while this was in flight; the newer request decides
135
+ if (request !== latestRequest.current) return
136
+ if (!result?.checkoutUrl || !result?.tokenId) {
137
+ console.error('Failed to get checkout URL')
138
+ if (onUnavailable) {
139
+ onUnavailable()
140
+ return
141
+ }
142
+ gotoLink({
143
+ kind: 'screen',
144
+ value: settings?.source?.backupCheckoutScreen ?? 'backup-checkout',
145
+ })
146
+ setOverrideBack('cart')
147
+ return
148
+ }
149
+
150
+ // The same cart_session_id returns the same URL, and an unchanged uri
151
+ // doesn't reload; loadId keys the WebView so Shopflo shows the new items
152
+ setCheckoutData((prev) => ({
153
+ checkoutUrl:
154
+ page === 'cart'
155
+ ? toShopfloCartUrl(result.checkoutUrl!)
156
+ : result.checkoutUrl!,
157
+ tokenId: result.tokenId!,
158
+ loadId: (prev?.loadId ?? 0) + 1,
159
+ }))
160
+ capturedCheckout.current = false
161
+ }, [page, settings, gotoLink, setOverrideBack, onUnavailable])
162
+
163
+ // On every visit, reload only if the app's cart changed since Shopflo got it.
164
+ // An empty cart (after purchase) keeps the thank-you page up
165
+ React.useEffect(() => {
166
+ if (!isFocused || !cartSignature) return
167
+ if (cartSignature === loadedSignature.current) return
168
+ initializeWebView()
169
+ }, [isFocused, cartSignature, initializeWebView])
170
+
171
+ // Shopflo already shows edits made inside it; record them as loaded so they
172
+ // don't trigger a reload of the cart the user is looking at
173
+ const syncFromShopflo = React.useCallback(
174
+ async (apply: () => Promise<unknown>) => {
175
+ try {
176
+ await apply()
177
+ } catch (error) {
178
+ console.warn('Syncing Shopflo cart change failed:', error)
179
+ }
180
+ loadedSignature.current = toCartSignature(
181
+ useAppStore.getState().cart.data.lineItems
182
+ )
183
+ },
184
+ []
185
+ )
186
+
187
+ const showInAppReview = React.useCallback(() => {
188
+ if (InAppReview.isAvailable()) {
189
+ setTimeout(() => {
190
+ InAppReview.RequestInAppReview().catch(console.error)
191
+ }, 5000)
192
+ }
193
+ }, [])
194
+
195
+ const handleCheckoutEvent = React.useCallback(async () => {
196
+ if (capturedCheckout.current) return
197
+ capturedCheckout.current = true
198
+
199
+ setCheckoutCompleted(true)
200
+
201
+ const payload = await useAppStore
202
+ .getState()
203
+ .modules.getModule<ShopfloProvider>('checkout')
204
+ ?.fetchOrderDetails(checkoutData?.tokenId)
205
+ const transformedPayload = transformCheckoutPayload(payload)
206
+ if (!transformedPayload) {
207
+ console.error('Failed to transform checkout payload')
208
+ }
209
+
210
+ analytics?.sendEvent(AnalyticsEvent.PURCHASE, transformedPayload)
211
+ useAppStore.getState().cart.resetCart()
212
+ setOverrideBack('home')
213
+ showInAppReview()
214
+
215
+ // Handle the redirect after purchase
216
+ if (!settings?.options?.redirectScreen) {
217
+ return
218
+ }
219
+ if (useAppStore.getState().user.isUserAuthenticated()) {
220
+ gotoLink({
221
+ kind: 'screen',
222
+ value: settings?.options?.redirectScreen,
223
+ })
224
+ } else {
225
+ gotoLink({
226
+ kind: 'screen',
227
+ value: 'home',
228
+ })
229
+ }
230
+ }, [
231
+ checkoutData,
232
+ gotoLink,
233
+ setOverrideBack,
234
+ settings,
235
+ showInAppReview,
236
+ setCheckoutCompleted,
237
+ analytics,
238
+ ])
239
+
240
+ const onMessageReceive = React.useCallback(
241
+ (event: any) => {
242
+ try {
243
+ const eventData = JSON.parse(event.nativeEvent.data)
244
+ const { type } = eventData
245
+ const cart = useAppStore.getState().cart
246
+ switch (type) {
247
+ // A quantity changed inside Shopflo; payload.item.id is the bare variant id
248
+ case 'FLO_ITEM_UPDATE': {
249
+ const item = eventData.payload?.item
250
+ const lineItem = cart.data.lineItems.find(
251
+ (li) => lastSegment(li.variantId) === String(item?.id)
252
+ )
253
+ if (!lineItem || lineItem.quantity === item.quantity) break
254
+ syncFromShopflo(() =>
255
+ Promise.resolve(
256
+ cart.updateQuantityForLineItem(lineItem.id, item.quantity)
257
+ )
258
+ )
259
+ break
260
+ }
261
+ // Everything was removed inside Shopflo: empty the app cart and leave
262
+ case 'FLO_CART_UPDATE': {
263
+ if (eventData.payload?.items?.length !== 0) break
264
+ const ids = cart.data.lineItems.map((li) => li.id)
265
+ if (ids.length) {
266
+ syncFromShopflo(() => cart.removeItemsFromCart(ids))
267
+ }
268
+ back()
269
+ break
270
+ }
271
+ // The cart view's close (X) posts CLOSE_CART_IFRAME, not FLO_EXIT_CHECKOUT
272
+ case 'CLOSE_CART_IFRAME':
273
+ case 'FLO_EXIT_CHECKOUT':
274
+ // On the cart screen Shopflo moved to checkout inside this WebView
275
+ // (is_cart_redirect); put it back on the cart so the next visit
276
+ // to the cart screen opens there
277
+ if (type === 'FLO_EXIT_CHECKOUT' && page === 'cart') {
278
+ setCheckoutData(
279
+ (prev) => prev && { ...prev, loadId: prev.loadId + 1 }
280
+ )
281
+ }
282
+ if (settings?.source?.closeModalId) {
283
+ openModal(settings.source.closeModalId)
284
+ } else {
285
+ back()
286
+ }
287
+ break
288
+ default:
289
+ return
290
+ }
291
+ } catch (error) {
292
+ console.error('Error parsing message from WebView:', error)
293
+ }
294
+ },
295
+ [page, settings, back, openModal, syncFromShopflo]
296
+ )
297
+
298
+ if (!checkoutData) return null
299
+
300
+ return (
301
+ <WebView
302
+ key={checkoutData.loadId}
303
+ ref={webViewRef}
304
+ applicationNameForUserAgent={userAgent}
305
+ source={{ uri: checkoutData?.checkoutUrl }}
306
+ startInLoadingState={true}
307
+ // Outside ScreenBrewery (the registered cart screen) there is no
308
+ // contentHeight, and defaultHeight would subtract an app bar that isn't drawn
309
+ style={screen ? { width: width, height: h } : { flex: 1, width: width }}
310
+ automaticallyAdjustContentInsets={false}
311
+ scrollEnabled={true}
312
+ nestedScrollEnabled={true}
313
+ javaScriptEnabled={true}
314
+ domStorageEnabled={true}
315
+ incognito={false}
316
+ originWhitelist={['*']}
317
+ onMessage={onMessageReceive}
318
+ onShouldStartLoadWithRequest={(e) => {
319
+ if (!e.url.startsWith('http')) {
320
+ if (e.url.startsWith('about:')) return false
321
+ if (
322
+ Platform.OS === 'android' &&
323
+ (e.url.startsWith('intent://') || e.url.startsWith('upi://'))
324
+ ) {
325
+ const upiAppUrl = toUpiAppUrl(e.url)
326
+ if (upiAppUrl) {
327
+ Linking.openURL(upiAppUrl).catch(() =>
328
+ SendIntentAndroid.openChromeIntent(e.url)
329
+ )
330
+ } else {
331
+ SendIntentAndroid.openChromeIntent(e.url)
332
+ }
333
+ return false
334
+ }
335
+ Linking.openURL(e.url)
336
+ return false
337
+ }
338
+ return true
339
+ }}
340
+ onLoadStart={(event) => {
341
+ const url = event.nativeEvent.url
342
+ setWebUrl(url)
343
+ if (isShopifyCartUrl(url, shopifyDomain)) {
344
+ webViewRef.current?.stopLoading()
345
+ // On the cart screen, going to 'cart' again would stack another one
346
+ // and mint a new token; on checkout this is back_url (exit). Leave
347
+ if (isRegisteredScreen) {
348
+ back()
349
+ return
350
+ }
351
+ gotoLink({
352
+ kind: 'screen',
353
+ value: 'cart',
354
+ })
355
+ } else if (isHomePage(url, shopifyDomain)) {
356
+ webViewRef.current?.stopLoading()
357
+ gotoLink({
358
+ kind: 'screen',
359
+ value: 'home',
360
+ })
361
+ } else if (
362
+ isThankYou(url, purchaseConfirmIdentifier) &&
363
+ !sendPurchasedEventDisabled
364
+ ) {
365
+ handleCheckoutEvent()
366
+ setOverrideBack('home')
367
+ }
368
+ }}
369
+ renderLoading={() => (
370
+ <View
371
+ style={{
372
+ position: 'absolute',
373
+ alignItems: 'center',
374
+ justifyContent: 'center',
375
+ left: 0,
376
+ right: 0,
377
+ top: 0,
378
+ bottom: 0,
379
+ }}
380
+ >
381
+ <Loader colors={['#000', '#000', '#000', '#000']} />
382
+ </View>
383
+ )}
384
+ />
385
+ )
386
+ }
387
+
388
+ export function Loader({ colors }: { colors?: string[] }) {
389
+ const numOfDots = 4
390
+ return (
391
+ <View
392
+ style={{
393
+ width: 100,
394
+ backgroundColor: 'transparent',
395
+ }}
396
+ >
397
+ <LoadingDots
398
+ bounceHeight={5}
399
+ dots={numOfDots}
400
+ size={10}
401
+ colors={colors ?? Array(numOfDots).fill('#fff')}
402
+ />
403
+ </View>
404
+ )
405
+ }
406
+
407
+ /**
408
+ * Helper Functions
409
+ */
410
+
411
+ const transformCheckoutPayload = (payload: any) => {
412
+ try {
413
+ const { response } = payload
414
+ const { order } = response
415
+ if (!order) return null
416
+
417
+ // Get coupon code if available
418
+ const coupon = order.discount_codes?.[0]?.code || ''
419
+
420
+ // Transform line items
421
+ const items = order.line_items.map((item: any) => ({
422
+ affiliation: item.vendor || '',
423
+ coupon,
424
+ discount: Number(item.total_discount),
425
+ handle: item.url?.replace('/products/', '') || '',
426
+ img_url: item.image || '',
427
+ item_brand: item.vendor,
428
+ item_category: item.product_type || '',
429
+ item_id: item.product_id?.toString(),
430
+ item_name: item.title,
431
+ item_variant: item.variant_id?.toString(),
432
+ url: item.url || '',
433
+ sku: item.sku,
434
+ price: Number(item.price),
435
+ quantity: item.quantity,
436
+ }))
437
+
438
+ return {
439
+ value: Number(order.total_price),
440
+ shipping: Number(order.total_shipping_price_set?.shop_money?.amount || 0),
441
+ items,
442
+ coupon,
443
+ sub_total: Number(order.subtotal_price),
444
+ checkout_token: order.token,
445
+ currency: order.currency,
446
+ discount: Number(order.total_discounts || 0),
447
+ }
448
+ } catch (error) {
449
+ console.error('Error transforming checkout payload:', error)
450
+ return null
451
+ }
452
+ }