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 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.1.1",
4
4
  "main": "./src/index.ts",
5
5
  "types": "./src/index.ts",
6
6
  "exports": {
@@ -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 = 'cart' } = props
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.includes('/cart')) {
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 (onUnavailable) {
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 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,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
- 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 ?? {},
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
+ }