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 the Shopflo checkout URL.
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. The health check retries once before falling back to native.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shopflo-appbrew",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "main": "./src/index.ts",
5
5
  "types": "./src/index.ts",
6
6
  "exports": {
@@ -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 = 'cart' } = props
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
- 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)
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
- loadedSignature.current = toCartSignature(
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
- // On every visit, reload only if the app's cart changed since Shopflo got it.
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 || !cartSignature) return
157
- if (cartSignature === loadedSignature.current) return
158
- initializeWebView()
159
- }, [isFocused, cartSignature, initializeWebView])
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; record them as loaded so they
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
- [page, settings, back, openModal, syncFromShopflo]
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.includes('/cart')) {
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 (onUnavailable) {
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 any /cart URL as "exit checkout"
185
- back_url: `https://${shopifyConfig.domain}/cart`,
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>
@@ -58,6 +58,7 @@ export function ShopfloCheckoutScreen(_props: CustomScreenProps) {
58
58
  index={0}
59
59
  length={1}
60
60
  page="checkout"
61
+ isRegisteredScreen
61
62
  onUnavailable={toNativeCheckout}
62
63
  />
63
64
  </BlockOverrideContext.Provider>
@@ -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
- // What Shopflo was last given; a different value means the token call must
17
- // run again so Shopflo's cart matches the app's
18
- export const toCartSignature = (
19
- lineItems: Array<{ variantId: string; quantity: number }>
20
- ) => lineItems.map((li) => `${li.variantId}:${li.quantity}`).join(',')
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
- try {
48
-
49
- const response = await fetch(`${HEALTH_URL}?${query.toString()}`, {
50
- method: 'GET',
51
- headers: { 'Content-Type': 'application/json' },
52
- })
53
- if (!response.ok) {
54
- throw new Error(`Health check failed with status: ${response.status}`)
55
- }
56
-
57
- const data = (await response.json())?.data
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
+ }