shopflo-appbrew 0.0.0-stage → 0.1.0

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,135 @@
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 the Shopflo checkout URL.
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. The health check retries once 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
+ />
62
+ </BlockOverrideContext.Provider>
63
+ )}
64
+ />
65
+ )
66
+ ```
67
+
68
+ 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.
69
+
70
+ **In Studio:** open the app → **Design** → pick the theme → select the **Cart** screen, then click the element on the canvas to open its actions:
71
+
72
+ | What | Click on the canvas | Action | Theme config it writes |
73
+ | --- | --- | --- | --- |
74
+ | 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` |
75
+ | App bar | the app bar at the top | **Hide app bar** (**Show app bar** undoes it) | `screens.cart.options.hideAppbar: true` |
76
+
77
+ **In the theme JSON** (the same two keys, if you edit config directly):
78
+
79
+ ```jsonc
80
+ "screens": {
81
+ "cart": {
82
+ "options": { "hideAppbar": true }, // false or absent: app bar shown
83
+ "layout": {
84
+ "footer": {
85
+ "componentId": "checkout-bar-v2",
86
+ "visibility": { "hide": true } // false or absent: checkout bar shown
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ What to set:
94
+
95
+ 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.
96
+ 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.
97
+ 3. **Shopflo's own cart header: hide it** (on the Shopflo side) when you keep the app bar, or the screen shows two headers.
98
+
99
+ - Keep the `ShopfloWebview` call as is; only the wrapper changes. Drop the `SafeAreaContainer` — `ScreenBrewery` handles the insets.
100
+ - `ShopfloWebview` takes its height from `ScreenContext.contentHeight` here, so it fits between the app bar and the bottom bar without changes.
101
+ - 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.
102
+
103
+ | App bar kept, checkout bar hidden | `hideAppbar: true` — full screen | Before the config changes |
104
+ | --- | --- | --- |
105
+ | ![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) |
106
+
107
+ 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.
108
+
109
+ ### Messages from the Shopflo page
110
+
111
+ `ShopfloWebview` handles these `postMessage` events (only those with `source: 'SHOPFLO'`):
112
+
113
+ | `type` | Action |
114
+ | --- | --- |
115
+ | `FLO_CART_UPDATE` | Empty cart: reset the Shopify cart and go back |
116
+ | `FLO_ITEM_UPDATE` | Sync the line's quantity to the Shopify cart |
117
+ | `FLO_CART_TO_CHECKOUT` | Switch the same token to the checkout view |
118
+ | `CLOSE_CART_IFRAME` | Cart view's close (X): go back, or open `closeModalId` if set |
119
+ | `FLO_EXIT_CHECKOUT` | From checkout: go home; from the cart: same as `CLOSE_CART_IFRAME` |
120
+
121
+ ## Provider
122
+
123
+ `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.
124
+
125
+ ## Settings
126
+
127
+ Declared in the `appbrew` manifest under `config.integrations.shopflo`:
128
+
129
+ | Key | Required | Purpose |
130
+ | --- | --- | --- |
131
+ | `salesChannelApiKey` | yes | Shopflo sales-channel API key, sent as the `Authorization` header (secret) |
132
+
133
+ ## Native dependencies
134
+
135
+ 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.0",
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,442 @@
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
+ lastSegment,
26
+ toCartSignature,
27
+ toShopfloCartUrl,
28
+ } from '../utils/shopflo'
29
+ import { toUpiAppUrl } from '../utils/payment-intents'
30
+ import type { ShopfloPage } from '../types'
31
+
32
+ type ShopfloWebviewProps = BaseBlockProps & {
33
+ // 'cart' opens Shopflo's cart (cart token); 'checkout' a checkout (Token V2)
34
+ page?: ShopfloPage
35
+ // Set by the registered screens: they fall back to the native cart /
36
+ // checkout when the token call fails, instead of the backup-checkout redirect
37
+ onUnavailable?: () => void
38
+ }
39
+
40
+ export function ShopfloWebview(props: ShopfloWebviewProps) {
41
+ const { onUnavailable, page = 'cart' } = props
42
+ const webViewRef = React.useRef<WebView>(null)
43
+ const [checkoutData, setCheckoutData] = React.useState<
44
+ { checkoutUrl: string; tokenId: string; loadId: number } | undefined
45
+ >()
46
+ const isFocused = useIsFocused()
47
+ const cartSignature = useAppStore((s) => toCartSignature(s.cart.data.lineItems))
48
+ // Signature of the cart Shopflo currently shows; null until the first load
49
+ const loadedSignature = React.useRef<string | null>(null)
50
+ const block = useBlock(props.screenId, props.componentId, props.instanceId)
51
+ const settings = useBlockSettings(block)
52
+ const { width, height } = useDeviceDimensions()
53
+ const screen = React.useContext(ScreenContext)
54
+ const { gotoLink, back } = useLink()
55
+ const setWebUrl = useSetWebUrl()
56
+ const { openModal } = useBottomSheetModal()
57
+ const setOverrideBack = useSetOverrideBack()
58
+ const setCheckoutCompleted = useAppStore(
59
+ (s) => s.checkout.setCheckoutCompleted
60
+ )
61
+ const analytics = useAnalytics()
62
+ const { top, bottom } = useSafeAreaInsets()
63
+ const [keyboardHeight, setKeyboardHeight] = React.useState(0)
64
+ const appName = useAppStore.getState().constants.data?.env?.APP_NAME
65
+ const appVersion = useAppStore.getState().device.buildNumber
66
+ const shopifyDomain = useAppStore.getState().config.data?.integrations
67
+ ?.shopify?.domain as string
68
+ const userAgent = `Appbrew/${appName
69
+ ?.toLowerCase()
70
+ .replace(/\s/g, '')}/${Platform.OS.toLowerCase()}/${appVersion}`
71
+
72
+ const appbarHeight = useAppStore((store) => {
73
+ const h = Boolean(Number(store.config.data?.theme?.appBar.root?.height))
74
+ ? Number(store.config.data?.theme?.appBar.root?.height)
75
+ : 64
76
+ return h
77
+ })
78
+ const topOffset = settings?.options?.topOffset ?? 0
79
+ const defaultHeight = height - appbarHeight - top - bottom - topOffset
80
+
81
+ let h = screen?.value?.contentHeight ?? defaultHeight
82
+ if (!settings?.options?.ignoreKeyboardHeight && Platform.OS === 'android') {
83
+ h = h - keyboardHeight
84
+ }
85
+
86
+ const purchaseConfirmIdentifier = settings?.options?.purchaseConfirmIdentifier
87
+ const sendPurchasedEventDisabled =
88
+ settings.options?.sendPurchasedEventDisabled
89
+
90
+ const capturedCheckout = React.useRef(false)
91
+
92
+ React.useEffect(() => {
93
+ const id = screen?.value.keyboardHeightRef.current?.addListener((x) => {
94
+ setKeyboardHeight(x.value)
95
+ })
96
+ return () => {
97
+ if (id) {
98
+ screen?.value.keyboardHeightRef.current?.removeListener(id)
99
+ }
100
+ }
101
+ }, [screen])
102
+
103
+ const initializeWebView = React.useCallback(async () => {
104
+ if (!useAppStore.getState().modules.isModuleDefined('checkout')) {
105
+ console.error('Checkout module not found')
106
+ return
107
+ }
108
+
109
+ const checkoutProvider = useAppStore
110
+ .getState()
111
+ .modules.getModule<ShopfloProvider>('checkout')
112
+
113
+ if (!checkoutProvider) {
114
+ console.error('Failed to get checkout provider')
115
+ return
116
+ }
117
+
118
+ // Get the URL from the provider
119
+ loadedSignature.current = toCartSignature(
120
+ useAppStore.getState().cart.data.lineItems
121
+ )
122
+ const result =
123
+ page === 'checkout'
124
+ ? await checkoutProvider.fetchCheckoutUrl()
125
+ : await checkoutProvider.fetchCartUrl()
126
+ if (!result?.checkoutUrl || !result?.tokenId) {
127
+ console.error('Failed to get checkout URL')
128
+ if (onUnavailable) {
129
+ onUnavailable()
130
+ return
131
+ }
132
+ gotoLink({
133
+ kind: 'screen',
134
+ value: settings?.source?.backupCheckoutScreen ?? 'backup-checkout',
135
+ })
136
+ setOverrideBack('cart')
137
+ return
138
+ }
139
+
140
+ // The same cart_session_id returns the same URL, and an unchanged uri
141
+ // doesn't reload; loadId keys the WebView so Shopflo shows the new items
142
+ setCheckoutData((prev) => ({
143
+ checkoutUrl:
144
+ page === 'cart'
145
+ ? toShopfloCartUrl(result.checkoutUrl!)
146
+ : result.checkoutUrl!,
147
+ tokenId: result.tokenId!,
148
+ loadId: (prev?.loadId ?? 0) + 1,
149
+ }))
150
+ capturedCheckout.current = false
151
+ }, [page, settings, gotoLink, setOverrideBack, onUnavailable])
152
+
153
+ // On every visit, reload only if the app's cart changed since Shopflo got it.
154
+ // An empty cart (after purchase) keeps the thank-you page up
155
+ React.useEffect(() => {
156
+ if (!isFocused || !cartSignature) return
157
+ if (cartSignature === loadedSignature.current) return
158
+ initializeWebView()
159
+ }, [isFocused, cartSignature, initializeWebView])
160
+
161
+ // Shopflo already shows edits made inside it; record them as loaded so they
162
+ // don't trigger a reload of the cart the user is looking at
163
+ const syncFromShopflo = React.useCallback(
164
+ async (apply: () => Promise<unknown>) => {
165
+ try {
166
+ await apply()
167
+ } catch (error) {
168
+ console.warn('Syncing Shopflo cart change failed:', error)
169
+ }
170
+ loadedSignature.current = toCartSignature(
171
+ useAppStore.getState().cart.data.lineItems
172
+ )
173
+ },
174
+ []
175
+ )
176
+
177
+ const showInAppReview = React.useCallback(() => {
178
+ if (InAppReview.isAvailable()) {
179
+ setTimeout(() => {
180
+ InAppReview.RequestInAppReview().catch(console.error)
181
+ }, 5000)
182
+ }
183
+ }, [])
184
+
185
+ const handleCheckoutEvent = React.useCallback(async () => {
186
+ if (capturedCheckout.current) return
187
+ capturedCheckout.current = true
188
+
189
+ setCheckoutCompleted(true)
190
+
191
+ const payload = await useAppStore
192
+ .getState()
193
+ .modules.getModule<ShopfloProvider>('checkout')
194
+ ?.fetchOrderDetails(checkoutData?.tokenId)
195
+ const transformedPayload = transformCheckoutPayload(payload)
196
+ if (!transformedPayload) {
197
+ console.error('Failed to transform checkout payload')
198
+ }
199
+
200
+ analytics?.sendEvent(AnalyticsEvent.PURCHASE, transformedPayload)
201
+ useAppStore.getState().cart.resetCart()
202
+ setOverrideBack('home')
203
+ showInAppReview()
204
+
205
+ // Handle the redirect after purchase
206
+ if (!settings?.options?.redirectScreen) {
207
+ return
208
+ }
209
+ if (useAppStore.getState().user.isUserAuthenticated()) {
210
+ gotoLink({
211
+ kind: 'screen',
212
+ value: settings?.options?.redirectScreen,
213
+ })
214
+ } else {
215
+ gotoLink({
216
+ kind: 'screen',
217
+ value: 'home',
218
+ })
219
+ }
220
+ }, [
221
+ checkoutData,
222
+ gotoLink,
223
+ setOverrideBack,
224
+ settings,
225
+ showInAppReview,
226
+ setCheckoutCompleted,
227
+ analytics,
228
+ ])
229
+
230
+ const onMessageReceive = React.useCallback(
231
+ (event: any) => {
232
+ try {
233
+ const eventData = JSON.parse(event.nativeEvent.data)
234
+ const { type } = eventData
235
+ const cart = useAppStore.getState().cart
236
+ switch (type) {
237
+ // A quantity changed inside Shopflo; payload.item.id is the bare variant id
238
+ case 'FLO_ITEM_UPDATE': {
239
+ const item = eventData.payload?.item
240
+ const lineItem = cart.data.lineItems.find(
241
+ (li) => lastSegment(li.variantId) === String(item?.id)
242
+ )
243
+ if (!lineItem || lineItem.quantity === item.quantity) break
244
+ syncFromShopflo(() =>
245
+ Promise.resolve(
246
+ cart.updateQuantityForLineItem(lineItem.id, item.quantity)
247
+ )
248
+ )
249
+ break
250
+ }
251
+ // Everything was removed inside Shopflo: empty the app cart and leave
252
+ case 'FLO_CART_UPDATE': {
253
+ if (eventData.payload?.items?.length !== 0) break
254
+ const ids = cart.data.lineItems.map((li) => li.id)
255
+ if (ids.length) {
256
+ syncFromShopflo(() => cart.removeItemsFromCart(ids))
257
+ }
258
+ back()
259
+ break
260
+ }
261
+ // The cart view's close (X) posts CLOSE_CART_IFRAME, not FLO_EXIT_CHECKOUT
262
+ case 'CLOSE_CART_IFRAME':
263
+ case 'FLO_EXIT_CHECKOUT':
264
+ // On the cart screen Shopflo moved to checkout inside this WebView
265
+ // (is_cart_redirect); put it back on the cart so the next visit
266
+ // to the cart screen opens there
267
+ if (type === 'FLO_EXIT_CHECKOUT' && page === 'cart') {
268
+ setCheckoutData(
269
+ (prev) => prev && { ...prev, loadId: prev.loadId + 1 }
270
+ )
271
+ }
272
+ if (settings?.source?.closeModalId) {
273
+ openModal(settings.source.closeModalId)
274
+ } else {
275
+ back()
276
+ }
277
+ break
278
+ default:
279
+ return
280
+ }
281
+ } catch (error) {
282
+ console.error('Error parsing message from WebView:', error)
283
+ }
284
+ },
285
+ [page, settings, back, openModal, syncFromShopflo]
286
+ )
287
+
288
+ if (!checkoutData) return null
289
+
290
+ return (
291
+ <WebView
292
+ key={checkoutData.loadId}
293
+ ref={webViewRef}
294
+ applicationNameForUserAgent={userAgent}
295
+ source={{ uri: checkoutData?.checkoutUrl }}
296
+ startInLoadingState={true}
297
+ // Outside ScreenBrewery (the registered cart screen) there is no
298
+ // contentHeight, and defaultHeight would subtract an app bar that isn't drawn
299
+ style={screen ? { width: width, height: h } : { flex: 1, width: width }}
300
+ automaticallyAdjustContentInsets={false}
301
+ scrollEnabled={true}
302
+ nestedScrollEnabled={true}
303
+ javaScriptEnabled={true}
304
+ domStorageEnabled={true}
305
+ incognito={false}
306
+ originWhitelist={['*']}
307
+ onMessage={onMessageReceive}
308
+ onShouldStartLoadWithRequest={(e) => {
309
+ if (!e.url.startsWith('http')) {
310
+ if (e.url.startsWith('about:')) return false
311
+ if (
312
+ Platform.OS === 'android' &&
313
+ (e.url.startsWith('intent://') || e.url.startsWith('upi://'))
314
+ ) {
315
+ const upiAppUrl = toUpiAppUrl(e.url)
316
+ if (upiAppUrl) {
317
+ Linking.openURL(upiAppUrl).catch(() =>
318
+ SendIntentAndroid.openChromeIntent(e.url)
319
+ )
320
+ } else {
321
+ SendIntentAndroid.openChromeIntent(e.url)
322
+ }
323
+ return false
324
+ }
325
+ Linking.openURL(e.url)
326
+ return false
327
+ }
328
+ return true
329
+ }}
330
+ onLoadStart={(event) => {
331
+ const url = event.nativeEvent.url
332
+ setWebUrl(url)
333
+ if (url.includes('/cart')) {
334
+ webViewRef.current?.stopLoading()
335
+ // On the cart screen, going to 'cart' again would stack another one
336
+ // and mint a new token; on checkout this is back_url (exit). Leave
337
+ if (onUnavailable) {
338
+ back()
339
+ return
340
+ }
341
+ gotoLink({
342
+ kind: 'screen',
343
+ value: 'cart',
344
+ })
345
+ } else if (isHomePage(url, shopifyDomain)) {
346
+ webViewRef.current?.stopLoading()
347
+ gotoLink({
348
+ kind: 'screen',
349
+ value: 'home',
350
+ })
351
+ } else if (
352
+ isThankYou(url, purchaseConfirmIdentifier) &&
353
+ !sendPurchasedEventDisabled
354
+ ) {
355
+ handleCheckoutEvent()
356
+ setOverrideBack('home')
357
+ }
358
+ }}
359
+ renderLoading={() => (
360
+ <View
361
+ style={{
362
+ position: 'absolute',
363
+ alignItems: 'center',
364
+ justifyContent: 'center',
365
+ left: 0,
366
+ right: 0,
367
+ top: 0,
368
+ bottom: 0,
369
+ }}
370
+ >
371
+ <Loader colors={['#000', '#000', '#000', '#000']} />
372
+ </View>
373
+ )}
374
+ />
375
+ )
376
+ }
377
+
378
+ export function Loader({ colors }: { colors?: string[] }) {
379
+ const numOfDots = 4
380
+ return (
381
+ <View
382
+ style={{
383
+ width: 100,
384
+ backgroundColor: 'transparent',
385
+ }}
386
+ >
387
+ <LoadingDots
388
+ bounceHeight={5}
389
+ dots={numOfDots}
390
+ size={10}
391
+ colors={colors ?? Array(numOfDots).fill('#fff')}
392
+ />
393
+ </View>
394
+ )
395
+ }
396
+
397
+ /**
398
+ * Helper Functions
399
+ */
400
+
401
+ const transformCheckoutPayload = (payload: any) => {
402
+ try {
403
+ const { response } = payload
404
+ const { order } = response
405
+ if (!order) return null
406
+
407
+ // Get coupon code if available
408
+ const coupon = order.discount_codes?.[0]?.code || ''
409
+
410
+ // Transform line items
411
+ const items = order.line_items.map((item: any) => ({
412
+ affiliation: item.vendor || '',
413
+ coupon,
414
+ discount: Number(item.total_discount),
415
+ handle: item.url?.replace('/products/', '') || '',
416
+ img_url: item.image || '',
417
+ item_brand: item.vendor,
418
+ item_category: item.product_type || '',
419
+ item_id: item.product_id?.toString(),
420
+ item_name: item.title,
421
+ item_variant: item.variant_id?.toString(),
422
+ url: item.url || '',
423
+ sku: item.sku,
424
+ price: Number(item.price),
425
+ quantity: item.quantity,
426
+ }))
427
+
428
+ return {
429
+ value: Number(order.total_price),
430
+ shipping: Number(order.total_shipping_price_set?.shop_money?.amount || 0),
431
+ items,
432
+ coupon,
433
+ sub_total: Number(order.subtotal_price),
434
+ checkout_token: order.token,
435
+ currency: order.currency,
436
+ discount: Number(order.total_discounts || 0),
437
+ }
438
+ } catch (error) {
439
+ console.error('Error transforming checkout payload:', error)
440
+ return null
441
+ }
442
+ }