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 +134 -2
- package/package.json +54 -4
- package/src/blocks/shopflo-web-view.tsx +442 -0
- package/src/checkout.ts +77 -0
- package/src/index.ts +15 -0
- package/src/provider.ts +260 -0
- package/src/register.ts +25 -0
- package/src/screens/shopflo-cart-screen.tsx +74 -0
- package/src/screens/shopflo-checkout-screen.tsx +66 -0
- package/src/screens/use-shopflo-status.ts +10 -0
- package/src/types.ts +25 -0
- package/src/utils/checkout-payload.ts +116 -0
- package/src/utils/constants.ts +7 -0
- package/src/utils/payment-intents.ts +21 -0
- package/src/utils/shopflo.ts +68 -0
package/README.md
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shopflo-appbrew
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |  |
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|