shopflo-appbrew 0.1.0 → 0.2.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
|
@@ -6,7 +6,7 @@ Shopflo one-click checkout. Replaces the native checkout flow with a Shopflo-hos
|
|
|
6
6
|
|
|
7
7
|
Registered via `registerShopfloBlocks(r)`:
|
|
8
8
|
|
|
9
|
-
- `shopflo-web-view` — WebView that loads
|
|
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
10
|
|
|
11
11
|
## Cart and checkout screens
|
|
12
12
|
|
|
@@ -25,7 +25,7 @@ The cart screen shows the native (config-driven) cart until Shopflo is usable, t
|
|
|
25
25
|
| Shopflo enabled, cart empty | Native cart (Shopflo's token API rejects an empty cart) |
|
|
26
26
|
| Shopflo enabled, cart has items | Shopflo WebView, kept after purchase so the thank-you page stays |
|
|
27
27
|
|
|
28
|
-
The status is reactive: a cart opened before the health check settles switches to Shopflo when it lands.
|
|
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
29
|
|
|
30
30
|
### What a registered screen has to provide
|
|
31
31
|
|
|
@@ -58,6 +58,7 @@ return (
|
|
|
58
58
|
instanceId={shopfloBlock.instanceId}
|
|
59
59
|
index={0}
|
|
60
60
|
length={1}
|
|
61
|
+
page="cart"
|
|
61
62
|
/>
|
|
62
63
|
</BlockOverrideContext.Provider>
|
|
63
64
|
)}
|
package/package.json
CHANGED
|
@@ -22,31 +22,34 @@ import { ShopfloProvider } from '../provider'
|
|
|
22
22
|
import { isHomePage, isThankYou } from '@gauntlet/components/utils'
|
|
23
23
|
import { useIsFocused } from '@react-navigation/native'
|
|
24
24
|
import {
|
|
25
|
+
isShopifyCartUrl,
|
|
25
26
|
lastSegment,
|
|
26
|
-
toCartSignature,
|
|
27
27
|
toShopfloCartUrl,
|
|
28
28
|
} from '../utils/shopflo'
|
|
29
29
|
import { toUpiAppUrl } from '../utils/payment-intents'
|
|
30
30
|
import type { ShopfloPage } from '../types'
|
|
31
31
|
|
|
32
32
|
type ShopfloWebviewProps = BaseBlockProps & {
|
|
33
|
-
// 'cart' opens Shopflo's cart (cart token); 'checkout' a checkout (Token V2)
|
|
33
|
+
// 'cart' opens Shopflo's cart (cart token); 'checkout' a checkout (Token V2).
|
|
34
|
+
// Defaults to 'checkout', what themes placing this block have always got
|
|
34
35
|
page?: ShopfloPage
|
|
36
|
+
// Rendered by a registered screen, not placed by a theme: reaching the
|
|
37
|
+
// back_url closes this screen instead of navigating to 'cart'
|
|
38
|
+
isRegisteredScreen?: boolean
|
|
35
39
|
// Set by the registered screens: they fall back to the native cart /
|
|
36
40
|
// checkout when the token call fails, instead of the backup-checkout redirect
|
|
37
41
|
onUnavailable?: () => void
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
41
|
-
const { onUnavailable, page = '
|
|
45
|
+
const { isRegisteredScreen, onUnavailable, page = 'checkout' } = props
|
|
42
46
|
const webViewRef = React.useRef<WebView>(null)
|
|
43
47
|
const [checkoutData, setCheckoutData] = React.useState<
|
|
44
48
|
{ checkoutUrl: string; tokenId: string; loadId: number } | undefined
|
|
45
49
|
>()
|
|
46
50
|
const isFocused = useIsFocused()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const loadedSignature = React.useRef<string | null>(null)
|
|
51
|
+
// Id of the newest token request; an older one resolving later is dropped
|
|
52
|
+
const latestRequest = React.useRef(0)
|
|
50
53
|
const block = useBlock(props.screenId, props.componentId, props.instanceId)
|
|
51
54
|
const settings = useBlockSettings(block)
|
|
52
55
|
const { width, height } = useDeviceDimensions()
|
|
@@ -116,13 +119,13 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
// Get the URL from the provider
|
|
119
|
-
|
|
120
|
-
useAppStore.getState().cart.data.lineItems
|
|
121
|
-
)
|
|
122
|
+
const request = ++latestRequest.current
|
|
122
123
|
const result =
|
|
123
124
|
page === 'checkout'
|
|
124
125
|
? await checkoutProvider.fetchCheckoutUrl()
|
|
125
126
|
: await checkoutProvider.fetchCartUrl()
|
|
127
|
+
// A newer visit started another request while this was in flight
|
|
128
|
+
if (request !== latestRequest.current) return
|
|
126
129
|
if (!result?.checkoutUrl || !result?.tokenId) {
|
|
127
130
|
console.error('Failed to get checkout URL')
|
|
128
131
|
if (onUnavailable) {
|
|
@@ -150,16 +153,20 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
150
153
|
capturedCheckout.current = false
|
|
151
154
|
}, [page, settings, gotoLink, setOverrideBack, onUnavailable])
|
|
152
155
|
|
|
153
|
-
//
|
|
156
|
+
// Kept in a ref so only a new visit, not a re-created callback, reloads
|
|
157
|
+
const initializeRef = React.useRef(initializeWebView)
|
|
158
|
+
initializeRef.current = initializeWebView
|
|
159
|
+
|
|
160
|
+
// Every visit mints a fresh token from the app's cart; edits made inside
|
|
161
|
+
// Shopflo while it is open are only synced to the app cart, never reloaded.
|
|
154
162
|
// An empty cart (after purchase) keeps the thank-you page up
|
|
155
163
|
React.useEffect(() => {
|
|
156
|
-
if (!isFocused
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
}, [isFocused
|
|
164
|
+
if (!isFocused) return
|
|
165
|
+
if (!useAppStore.getState().cart.data.lineItems.length) return
|
|
166
|
+
initializeRef.current()
|
|
167
|
+
}, [isFocused])
|
|
160
168
|
|
|
161
|
-
// Shopflo already shows edits made inside it;
|
|
162
|
-
// don't trigger a reload of the cart the user is looking at
|
|
169
|
+
// Shopflo already shows edits made inside it; only the app cart needs them
|
|
163
170
|
const syncFromShopflo = React.useCallback(
|
|
164
171
|
async (apply: () => Promise<unknown>) => {
|
|
165
172
|
try {
|
|
@@ -167,9 +174,6 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
167
174
|
} catch (error) {
|
|
168
175
|
console.warn('Syncing Shopflo cart change failed:', error)
|
|
169
176
|
}
|
|
170
|
-
loadedSignature.current = toCartSignature(
|
|
171
|
-
useAppStore.getState().cart.data.lineItems
|
|
172
|
-
)
|
|
173
177
|
},
|
|
174
178
|
[]
|
|
175
179
|
)
|
|
@@ -261,14 +265,6 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
261
265
|
// The cart view's close (X) posts CLOSE_CART_IFRAME, not FLO_EXIT_CHECKOUT
|
|
262
266
|
case 'CLOSE_CART_IFRAME':
|
|
263
267
|
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
268
|
if (settings?.source?.closeModalId) {
|
|
273
269
|
openModal(settings.source.closeModalId)
|
|
274
270
|
} else {
|
|
@@ -282,7 +278,7 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
282
278
|
console.error('Error parsing message from WebView:', error)
|
|
283
279
|
}
|
|
284
280
|
},
|
|
285
|
-
[
|
|
281
|
+
[settings, back, openModal, syncFromShopflo]
|
|
286
282
|
)
|
|
287
283
|
|
|
288
284
|
if (!checkoutData) return null
|
|
@@ -330,11 +326,11 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
330
326
|
onLoadStart={(event) => {
|
|
331
327
|
const url = event.nativeEvent.url
|
|
332
328
|
setWebUrl(url)
|
|
333
|
-
if (url
|
|
329
|
+
if (isShopifyCartUrl(url, shopifyDomain)) {
|
|
334
330
|
webViewRef.current?.stopLoading()
|
|
335
331
|
// On the cart screen, going to 'cart' again would stack another one
|
|
336
332
|
// and mint a new token; on checkout this is back_url (exit). Leave
|
|
337
|
-
if (
|
|
333
|
+
if (isRegisteredScreen) {
|
|
338
334
|
back()
|
|
339
335
|
return
|
|
340
336
|
}
|
package/src/provider.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
SHOPFLO_API_BASE_URL,
|
|
7
7
|
SHOPFLO_CHECKOUT_TOKEN_URL,
|
|
8
8
|
} from './utils/constants'
|
|
9
|
-
import { lastSegment } from './utils/shopflo'
|
|
9
|
+
import { lastSegment, toShopifyCartUrl } from './utils/shopflo'
|
|
10
10
|
import {
|
|
11
11
|
CHECKOUT_UI_CONFIG,
|
|
12
12
|
toCheckoutItemsAndDiscounts,
|
|
@@ -181,8 +181,8 @@ export class ShopfloProvider implements ICheckoutProvider {
|
|
|
181
181
|
ui_config: CHECKOUT_UI_CONFIG,
|
|
182
182
|
tags: ['app'],
|
|
183
183
|
note_attributes: toNoteAttributes(cartData.cartAttributes),
|
|
184
|
-
// Mandatory. The WebView treats
|
|
185
|
-
back_url:
|
|
184
|
+
// Mandatory. The WebView treats reaching it as "exit checkout"
|
|
185
|
+
back_url: toShopifyCartUrl(shopifyConfig.domain),
|
|
186
186
|
}),
|
|
187
187
|
})
|
|
188
188
|
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { SafeAreaContainer, ScreenBrewery } from "@gauntlet/brewery";
|
|
3
3
|
import type { CustomScreenProps } from "@gauntlet/brewery";
|
|
4
4
|
import { BlockOverrideContext, useAppStore } from "@gauntlet/state";
|
|
5
|
+
import { useIsFocused } from "@react-navigation/native";
|
|
5
6
|
import { ShopfloWebview } from "../blocks/shopflo-web-view";
|
|
6
7
|
import { useShopfloStatus } from "./use-shopflo-status";
|
|
7
8
|
|
|
@@ -32,10 +33,18 @@ export function ShopfloCartScreen({ options }: CustomScreenProps) {
|
|
|
32
33
|
);
|
|
33
34
|
// Shopflo's token API rejects an empty cart; the native screen owns the empty state
|
|
34
35
|
const hasItems = useAppStore((s) => s.cart.data.lineItems.length > 0);
|
|
36
|
+
const isFocused = useIsFocused();
|
|
35
37
|
const [unavailable, setUnavailable] = React.useState(false);
|
|
38
|
+
// A failed token call shows the native cart for this visit only; leaving
|
|
39
|
+
// the screen lets the next visit try Shopflo again
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!isFocused) setUnavailable(false);
|
|
42
|
+
}, [isFocused]);
|
|
36
43
|
// Once shown, keep the WebView: the cart is reset on purchase, and swapping
|
|
37
|
-
// to the native screen then would hide Shopflo's thank-you page
|
|
44
|
+
// to the native screen then would hide Shopflo's thank-you page. Leaving
|
|
45
|
+
// with an empty cart ends that, so the next visit shows the native empty cart
|
|
38
46
|
const showedShopflo = React.useRef(false);
|
|
47
|
+
if (!isFocused && !hasItems) showedShopflo.current = false;
|
|
39
48
|
const onUnavailable = React.useCallback(() => setUnavailable(true), []);
|
|
40
49
|
|
|
41
50
|
if (
|
|
@@ -66,6 +75,8 @@ export function ShopfloCartScreen({ options }: CustomScreenProps) {
|
|
|
66
75
|
// Position in the screen's block list; it is the only block here
|
|
67
76
|
index={0}
|
|
68
77
|
length={1}
|
|
78
|
+
page="cart"
|
|
79
|
+
isRegisteredScreen
|
|
69
80
|
onUnavailable={onUnavailable}
|
|
70
81
|
/>
|
|
71
82
|
</BlockOverrideContext.Provider>
|
package/src/utils/shopflo.ts
CHANGED
|
@@ -8,16 +8,24 @@ const SHOPFLO_DISABLED_STATUS: ShopfloHealthStatus = {
|
|
|
8
8
|
clientMeta: {},
|
|
9
9
|
}
|
|
10
10
|
const HEALTH_URL = `${SHOPFLO_API_BASE_URL}/heimdall/api/v1/shopflo-health`
|
|
11
|
+
// Retries after the first try before falling back to the native flow
|
|
12
|
+
const HEALTH_RETRIES = 3
|
|
13
|
+
const HEALTH_RETRY_DELAY_MS = 1000
|
|
11
14
|
|
|
12
15
|
// Shopify ids are gids (gid://shopify/ProductVariant/123); Shopflo uses the bare id
|
|
13
16
|
export const lastSegment = (id: string | number) =>
|
|
14
17
|
String(id).split('/').pop() ?? ''
|
|
15
18
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// back_url given to Shopflo's checkout; reaching it means the user left
|
|
20
|
+
export const toShopifyCartUrl = (domain: string) => `https://${domain}/cart`
|
|
21
|
+
|
|
22
|
+
// Scheme, query, hash and a trailing slash don't matter; any other URL,
|
|
23
|
+
// including Shopflo's own with /cart in the path, isn't the back_url
|
|
24
|
+
export const isShopifyCartUrl = (url: string, domain: string) =>
|
|
25
|
+
url
|
|
26
|
+
.split(/[?#]/)[0]
|
|
27
|
+
.replace(/^https?:\/\//, '')
|
|
28
|
+
.replace(/\/$/, '') === `${domain}/cart`
|
|
21
29
|
|
|
22
30
|
// The token's checkout_url already opens Shopflo's cart view (page=cart);
|
|
23
31
|
// is_cart_redirect=true makes its Checkout button move to checkout inside the
|
|
@@ -43,26 +51,36 @@ export const getShopfloHealthStatus = async ({
|
|
|
43
51
|
const query = new URLSearchParams({ 'shop-url': shopUrl })
|
|
44
52
|
if (themeId) query.append('theme-id', themeId)
|
|
45
53
|
if (applicationId) query.append('application-id', applicationId)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
isFloEnabled: data?.enabled === true,
|
|
60
|
-
isSSOEnabled: data?.sso_enabled === true,
|
|
61
|
-
isABEnabled: data?.ab_enabled === true,
|
|
62
|
-
clientMeta: data?.client_meta ?? {},
|
|
54
|
+
const url = `${HEALTH_URL}?${query.toString()}`
|
|
55
|
+
|
|
56
|
+
// A failed check disables Shopflo for the session, so blips get retried
|
|
57
|
+
for (let retry = 0; ; retry++) {
|
|
58
|
+
try {
|
|
59
|
+
return await fetchHealthStatus(url)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(`Shopflo health check failed (retry ${retry}):`, error)
|
|
62
|
+
if (retry === HEALTH_RETRIES) return SHOPFLO_DISABLED_STATUS
|
|
63
|
+
await new Promise<void>((resolve) =>
|
|
64
|
+
setTimeout(resolve, HEALTH_RETRY_DELAY_MS)
|
|
65
|
+
)
|
|
63
66
|
}
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.warn('Shopflo health check failed:', error)
|
|
66
|
-
return SHOPFLO_DISABLED_STATUS
|
|
67
67
|
}
|
|
68
|
-
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fetchHealthStatus = async (url: string): Promise<ShopfloHealthStatus> => {
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
})
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Health check failed with status: ${response.status}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = (await response.json())?.data
|
|
80
|
+
return {
|
|
81
|
+
isFloEnabled: data?.enabled === true,
|
|
82
|
+
isSSOEnabled: data?.sso_enabled === true,
|
|
83
|
+
isABEnabled: data?.ab_enabled === true,
|
|
84
|
+
clientMeta: data?.client_meta ?? {},
|
|
85
|
+
}
|
|
86
|
+
}
|