11import type { PropsWithChildren , ReactNode } from 'react' ;
2- import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
3- import { Appearance , Linking , Pressable , StatusBar } from 'react-native' ;
2+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
3+ import { Alert , Appearance , Linking , Pressable , StatusBar , View } from 'react-native' ;
44import {
55 NavigationContainer ,
66 useNavigation ,
@@ -43,6 +43,7 @@ import ErrorBoundary from './ErrorBoundary';
4343import env from 'react-native-config' ;
4444import { createDebugLogger } from './utils' ;
4545import { useShopifyEventHandlers } from './hooks/useCheckoutEventHandlers' ;
46+ import useShopify from './hooks/useShopify' ;
4647
4748const log = createDebugLogger ( 'ENV' ) ;
4849
@@ -95,6 +96,67 @@ const client = new ApolloClient({
9596 connectToDevTools : __DEV__ ,
9697} ) ;
9798
99+ const CART_BOOTSTRAP_SCHEME = 'com.shopify.checkoutkit.reactnativedemo:' ;
100+ const CART_BOOTSTRAP_ROUTE = `${ CART_BOOTSTRAP_SCHEME } //cart` ;
101+
102+ type CartBootstrapLink = {
103+ variantId ?: string ;
104+ productIndex ?: number ;
105+ quantity : number ;
106+ } ;
107+
108+ function parseCartBootstrapLink ( url : string ) : CartBootstrapLink | null {
109+ if ( ! url . startsWith ( CART_BOOTSTRAP_SCHEME ) ) {
110+ return null ;
111+ }
112+
113+ const queryIndex = url . indexOf ( '?' ) ;
114+ const route = queryIndex < 0 ? url : url . slice ( 0 , queryIndex ) ;
115+
116+ if ( route !== CART_BOOTSTRAP_ROUTE ) {
117+ throw new Error ( 'Unsupported cart bootstrap path' ) ;
118+ }
119+
120+ if ( queryIndex < 0 ) {
121+ throw new Error ( 'Missing variantId or productIndex' ) ;
122+ }
123+
124+ const searchParams = new URLSearchParams ( url . slice ( queryIndex + 1 ) ) ;
125+ const variantId = searchParams . get ( 'variantId' ) ?. trim ( ) ;
126+ const productIndexParam = searchParams . get ( 'productIndex' ) ?. trim ( ) ;
127+
128+ const quantityParam = searchParams . get ( 'quantity' ) ?? '1' ;
129+ const quantity = Number ( quantityParam ) ;
130+
131+ if ( ! Number . isInteger ( quantity ) || quantity < 1 ) {
132+ throw new Error ( 'quantity must be a positive integer' ) ;
133+ }
134+
135+ if ( variantId && productIndexParam ) {
136+ throw new Error ( 'Use variantId or productIndex, not both' ) ;
137+ }
138+
139+ if ( variantId ) {
140+ return { variantId, quantity} ;
141+ }
142+
143+ if ( ! productIndexParam ) {
144+ throw new Error ( 'Missing variantId or productIndex' ) ;
145+ }
146+
147+ const productIndex = Number ( productIndexParam ) ;
148+
149+ if ( ! Number . isInteger ( productIndex ) || productIndex < 0 ) {
150+ throw new Error ( 'productIndex must be a non-negative integer' ) ;
151+ }
152+
153+ return { productIndex, quantity} ;
154+ }
155+
156+ function errorMessage ( error : unknown ) {
157+ return error instanceof Error ? error . message : 'Unknown error' ;
158+ }
159+
98160function AppWithTheme ( { children} : PropsWithChildren ) {
99161 const { colorScheme} = useTheme ( ) ;
100162
@@ -412,14 +474,51 @@ function AppWithNavigation(props: {children: React.ReactNode}) {
412474}
413475
414476function Routes ( ) {
415- const { totalQuantity} = useCart ( ) ;
477+ const { totalQuantity, seedCart } = useCart ( ) ;
416478 const navigation = useNavigation < NavigationProp < RootStackParamList > > ( ) ;
417479 const { url : initialUrl } = useInitialURL ( ) ;
480+ const handledInitialUrlRef = useRef < string | null > ( null ) ;
481+ const [ linkingReady , setLinkingReady ] = useState ( false ) ;
418482 const shopify = useShopifyCheckout ( ) ;
419483 const eventHandlers = useShopifyEventHandlers ( 'UniversalLink' ) ;
484+ const { queries} = useShopify ( ) ;
485+ const [ fetchProducts ] = queries . products ;
420486
421487 useEffect ( ( ) => {
422488 async function handleUniversalLink ( url : string ) {
489+ let cartBootstrapLink : CartBootstrapLink | null = null ;
490+
491+ try {
492+ cartBootstrapLink = parseCartBootstrapLink ( url ) ;
493+ } catch ( error ) {
494+ Alert . alert ( 'Invalid cart bootstrap link' , errorMessage ( error ) ) ;
495+ return ;
496+ }
497+
498+ if ( cartBootstrapLink ) {
499+ try {
500+ let { variantId} = cartBootstrapLink ;
501+
502+ if ( ! variantId ) {
503+ const { data} = await fetchProducts ( ) ;
504+ const product =
505+ data ?. products . edges [ cartBootstrapLink . productIndex ?? 0 ] ?. node ;
506+
507+ variantId = product ?. variants . edges [ 0 ] ?. node . id ;
508+ }
509+
510+ if ( ! variantId ) {
511+ throw new Error ( 'Cart bootstrap product variant was not found' ) ;
512+ }
513+
514+ await seedCart ( variantId , cartBootstrapLink . quantity ) ;
515+ navigation . navigate ( 'Cart' ) ;
516+ } catch ( error ) {
517+ Alert . alert ( 'Cart bootstrap failed' , errorMessage ( error ) ) ;
518+ }
519+ return ;
520+ }
521+
423522 const storefrontUrl = new StorefrontURL ( url ) ;
424523
425524 switch ( true ) {
@@ -444,58 +543,64 @@ function Routes() {
444543 }
445544 }
446545
447- if ( initialUrl ) {
546+ if ( initialUrl && handledInitialUrlRef . current !== initialUrl ) {
547+ handledInitialUrlRef . current = initialUrl ;
448548 handleUniversalLink ( initialUrl ) ;
449549 }
450550
451551 // Subscribe to universal links
452552 const subscription = Linking . addEventListener ( 'url' , ( { url} ) => {
453553 handleUniversalLink ( url ) ;
454554 } ) ;
555+ setLinkingReady ( true ) ;
455556
456557 return ( ) => {
457558 subscription . remove ( ) ;
458559 } ;
459- } , [ initialUrl , shopify , navigation , eventHandlers ] ) ;
560+ } , [ initialUrl , shopify , navigation , eventHandlers , seedCart , fetchProducts ] ) ;
460561
461562 return (
462- < Tab . Navigator >
463- < Tab . Screen
464- name = "Catalog"
465- component = { CatalogStack }
466- options = { {
467- headerShown : false ,
468- tabBarButtonTestID : 'catalog-tab' ,
469- tabBarIcon : createNavigationIcon ( 'shop' ) ,
470- } }
471- />
472- < Tab . Screen
473- name = "Cart"
474- component = { CartScreen }
475- options = { {
476- tabBarButtonTestID : 'cart-tab' ,
477- tabBarIcon : createNavigationIcon ( 'shopping-bag' ) ,
478- tabBarBadge : totalQuantity > 0 ? totalQuantity : undefined ,
479- } }
480- />
481- < Tab . Screen
482- name = "Account"
483- component = { AccountStackScreen }
484- options = { {
485- headerShown : false ,
486- tabBarButtonTestID : 'account-tab' ,
487- tabBarIcon : createNavigationIcon ( 'user' ) ,
488- } }
489- />
490- < Tab . Screen
491- name = "Settings"
492- component = { SettingsScreen }
493- options = { {
494- tabBarButtonTestID : 'settings-tab' ,
495- tabBarIcon : createNavigationIcon ( 'cog' ) ,
496- } }
497- />
498- </ Tab . Navigator >
563+ < View
564+ style = { { flex : 1 } }
565+ testID = { linkingReady ? 'checkout-kit-sample-ready' : undefined } >
566+ < Tab . Navigator >
567+ < Tab . Screen
568+ name = "Catalog"
569+ component = { CatalogStack }
570+ options = { {
571+ headerShown : false ,
572+ tabBarButtonTestID : 'catalog-tab' ,
573+ tabBarIcon : createNavigationIcon ( 'shop' ) ,
574+ } }
575+ />
576+ < Tab . Screen
577+ name = "Cart"
578+ component = { CartScreen }
579+ options = { {
580+ tabBarButtonTestID : 'cart-tab' ,
581+ tabBarIcon : createNavigationIcon ( 'shopping-bag' ) ,
582+ tabBarBadge : totalQuantity > 0 ? totalQuantity : undefined ,
583+ } }
584+ />
585+ < Tab . Screen
586+ name = "Account"
587+ component = { AccountStackScreen }
588+ options = { {
589+ headerShown : false ,
590+ tabBarButtonTestID : 'account-tab' ,
591+ tabBarIcon : createNavigationIcon ( 'user' ) ,
592+ } }
593+ />
594+ < Tab . Screen
595+ name = "Settings"
596+ component = { SettingsScreen }
597+ options = { {
598+ tabBarButtonTestID : 'settings-tab' ,
599+ tabBarIcon : createNavigationIcon ( 'cog' ) ,
600+ } }
601+ />
602+ </ Tab . Navigator >
603+ </ View >
499604 ) ;
500605}
501606
0 commit comments