shopflo-appbrew 0.1.0 → 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
|
@@ -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,6 +22,7 @@ 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
27
|
toCartSignature,
|
|
27
28
|
toShopfloCartUrl,
|
|
@@ -30,15 +31,19 @@ import { toUpiAppUrl } from '../utils/payment-intents'
|
|
|
30
31
|
import type { ShopfloPage } from '../types'
|
|
31
32
|
|
|
32
33
|
type ShopfloWebviewProps = BaseBlockProps & {
|
|
33
|
-
// 'cart' opens Shopflo's cart (cart token); 'checkout' a checkout (Token V2)
|
|
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
|
|
34
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
|
|
35
40
|
// Set by the registered screens: they fall back to the native cart /
|
|
36
41
|
// checkout when the token call fails, instead of the backup-checkout redirect
|
|
37
42
|
onUnavailable?: () => void
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
41
|
-
const { onUnavailable, page = '
|
|
46
|
+
const { isRegisteredScreen, onUnavailable, page = 'checkout' } = props
|
|
42
47
|
const webViewRef = React.useRef<WebView>(null)
|
|
43
48
|
const [checkoutData, setCheckoutData] = React.useState<
|
|
44
49
|
{ checkoutUrl: string; tokenId: string; loadId: number } | undefined
|
|
@@ -47,6 +52,8 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
47
52
|
const cartSignature = useAppStore((s) => toCartSignature(s.cart.data.lineItems))
|
|
48
53
|
// Signature of the cart Shopflo currently shows; null until the first load
|
|
49
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)
|
|
50
57
|
const block = useBlock(props.screenId, props.componentId, props.instanceId)
|
|
51
58
|
const settings = useBlockSettings(block)
|
|
52
59
|
const { width, height } = useDeviceDimensions()
|
|
@@ -119,10 +126,13 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
119
126
|
loadedSignature.current = toCartSignature(
|
|
120
127
|
useAppStore.getState().cart.data.lineItems
|
|
121
128
|
)
|
|
129
|
+
const request = ++latestRequest.current
|
|
122
130
|
const result =
|
|
123
131
|
page === 'checkout'
|
|
124
132
|
? await checkoutProvider.fetchCheckoutUrl()
|
|
125
133
|
: await checkoutProvider.fetchCartUrl()
|
|
134
|
+
// The cart changed while this was in flight; the newer request decides
|
|
135
|
+
if (request !== latestRequest.current) return
|
|
126
136
|
if (!result?.checkoutUrl || !result?.tokenId) {
|
|
127
137
|
console.error('Failed to get checkout URL')
|
|
128
138
|
if (onUnavailable) {
|
|
@@ -330,11 +340,11 @@ export function ShopfloWebview(props: ShopfloWebviewProps) {
|
|
|
330
340
|
onLoadStart={(event) => {
|
|
331
341
|
const url = event.nativeEvent.url
|
|
332
342
|
setWebUrl(url)
|
|
333
|
-
if (url
|
|
343
|
+
if (isShopifyCartUrl(url, shopifyDomain)) {
|
|
334
344
|
webViewRef.current?.stopLoading()
|
|
335
345
|
// On the cart screen, going to 'cart' again would stack another one
|
|
336
346
|
// and mint a new token; on checkout this is back_url (exit). Leave
|
|
337
|
-
if (
|
|
347
|
+
if (isRegisteredScreen) {
|
|
338
348
|
back()
|
|
339
349
|
return
|
|
340
350
|
}
|
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,11 +8,25 @@ 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
|
|
|
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`
|
|
29
|
+
|
|
16
30
|
// What Shopflo was last given; a different value means the token call must
|
|
17
31
|
// run again so Shopflo's cart matches the app's
|
|
18
32
|
export const toCartSignature = (
|
|
@@ -43,26 +57,36 @@ export const getShopfloHealthStatus = async ({
|
|
|
43
57
|
const query = new URLSearchParams({ 'shop-url': shopUrl })
|
|
44
58
|
if (themeId) query.append('theme-id', themeId)
|
|
45
59
|
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 ?? {},
|
|
60
|
+
const url = `${HEALTH_URL}?${query.toString()}`
|
|
61
|
+
|
|
62
|
+
// A failed check disables Shopflo for the session, so blips get retried
|
|
63
|
+
for (let retry = 0; ; retry++) {
|
|
64
|
+
try {
|
|
65
|
+
return await fetchHealthStatus(url)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.warn(`Shopflo health check failed (retry ${retry}):`, error)
|
|
68
|
+
if (retry === HEALTH_RETRIES) return SHOPFLO_DISABLED_STATUS
|
|
69
|
+
await new Promise<void>((resolve) =>
|
|
70
|
+
setTimeout(resolve, HEALTH_RETRY_DELAY_MS)
|
|
71
|
+
)
|
|
63
72
|
}
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.warn('Shopflo health check failed:', error)
|
|
66
|
-
return SHOPFLO_DISABLED_STATUS
|
|
67
73
|
}
|
|
68
|
-
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fetchHealthStatus = async (url: string): Promise<ShopfloHealthStatus> => {
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
method: 'GET',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
})
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`Health check failed with status: ${response.status}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = (await response.json())?.data
|
|
86
|
+
return {
|
|
87
|
+
isFloEnabled: data?.enabled === true,
|
|
88
|
+
isSSOEnabled: data?.sso_enabled === true,
|
|
89
|
+
isABEnabled: data?.ab_enabled === true,
|
|
90
|
+
clientMeta: data?.client_meta ?? {},
|
|
91
|
+
}
|
|
92
|
+
}
|