Skip to content

Commit 74bd532

Browse files
committed
Add RN sample E2E bootstrap support
1 parent 103fe88 commit 74bd532

8 files changed

Lines changed: 279 additions & 93 deletions

File tree

platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
<!-- Note: the host here will be replaced on application start -->
3636
<data android:scheme="https" android:host="{{STOREFRONT_DOMAIN}}" />
3737
</intent-filter>
38+
39+
<intent-filter>
40+
<action android:name="android.intent.action.VIEW" />
41+
<category android:name="android.intent.category.DEFAULT" />
42+
<category android:name="android.intent.category.BROWSABLE" />
43+
44+
<data android:scheme="com.shopify.checkoutkit.reactnativedemo" android:host="cart" />
45+
</intent-filter>
3846
</activity>
3947
</application>
4048
</manifest>

platforms/react-native/sample/ios/AppDelegate.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3131

3232
return true
3333
}
34+
35+
func application(
36+
_ app: UIApplication,
37+
open url: URL,
38+
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
39+
) -> Bool {
40+
return RCTLinkingManager.application(app, open: url, options: options)
41+
}
42+
43+
func application(
44+
_ application: UIApplication,
45+
continue userActivity: NSUserActivity,
46+
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
47+
) -> Bool {
48+
return RCTLinkingManager.application(
49+
application,
50+
continue: userActivity,
51+
restorationHandler: restorationHandler
52+
)
53+
}
3454
}
3555

3656
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {

platforms/react-native/sample/ios/CheckoutKitReactNativeDemo/Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<key>CFBundleURLSchemes</key>
2929
<array>
3030
<string>rn</string>
31+
<string>com.shopify.checkoutkit.reactnativedemo</string>
3132
</array>
3233
</dict>
3334
</array>

platforms/react-native/sample/src/App.tsx

Lines changed: 147 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 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';
44
import {
55
NavigationContainer,
66
useNavigation,
@@ -43,6 +43,7 @@ import ErrorBoundary from './ErrorBoundary';
4343
import env from 'react-native-config';
4444
import {createDebugLogger} from './utils';
4545
import {useShopifyEventHandlers} from './hooks/useCheckoutEventHandlers';
46+
import useShopify from './hooks/useShopify';
4647

4748
const 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+
98160
function AppWithTheme({children}: PropsWithChildren) {
99161
const {colorScheme} = useTheme();
100162

@@ -412,14 +474,51 @@ function AppWithNavigation(props: {children: React.ReactNode}) {
412474
}
413475

414476
function 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

Comments
 (0)