diff --git a/CHANGELOG.md b/CHANGELOG.md
index 049db0d0..51318ce2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 3.2.0 - December 18, 2024
+
+- Handle geolocation requests for Android devices
+
## 3.1.2 - November 4, 2024
- Add `consumerProguardRules` build.gradle option to prevent minification of
diff --git a/README.md b/README.md
index dda44dd8..92de06d1 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,8 @@ experiences.
- [Colors](#colors)
- [Localization](#localization)
- [Checkout Sheet title](#checkout-sheet-title)
- - [iOS](#ios)
- - [Android](#android)
+ - [iOS - Localization](#ios---localization)
+ - [Android - Localization](#android---localization)
- [Currency](#currency)
- [Language](#language)
- [Preloading](#preloading)
@@ -53,6 +53,10 @@ experiences.
- [Customer Account API](#customer-account-api)
- [Offsite Payments](#offsite-payments)
- [Universal Links - iOS](#universal-links---ios)
+- [Pickup points / Pickup in store](#pickup-points--pickup-in-store)
+ - [Geolocation - iOS](#geolocation---ios)
+ - [Geolocation - Android](#geolocation---android)
+ - [Opting out of the default behavior](#opting-out-of-the-default-behavior)
- [Contributing](#contributing)
- [License](#license)
@@ -144,8 +148,8 @@ function App() {
}
```
-See [Usage with the Storefront API](#usage-with-the-storefront-api) below on how
-to get a checkout URL to pass to the kit.
+See [usage with the Storefront API](#usage-with-the-storefront-api) below for details on how
+to obtain a checkout URL to pass to the kit.
> [!NOTE]
> The recommended usage of the library is through a
@@ -434,9 +438,7 @@ function AppWithContext() {
#### Checkout Sheet title
-There are several ways to change the title of the Checkout Sheet.
-
-##### iOS
+##### iOS - Localization
On iOS, you can set a localized value on the `title` attribute of the
configuration.
@@ -447,7 +449,7 @@ following:
1. Create a `Localizable.xcstrings` file under "ios/{YourApplicationName}"
2. Add an entry for the key `"shopify_checkout_sheet_title"`
-##### Android
+##### Android - Localization
On Android, you can add a string entry for the key `"checkout_web_view_title"`
to the "android/app/src/res/values/strings.xml" file for your application.
@@ -742,6 +744,95 @@ public func checkoutDidClickLink(url: URL) {
}
```
+## Pickup points / Pickup in store
+
+### Geolocation - iOS
+
+Geolocation permission requests are handled out of the box by iOS, provided you've added the required location usage description to your `Info.plist` file:
+
+```xml
+NSLocationWhenInUseUsageDescription
+Your location is required to locate pickup points near you.
+```
+
+> [!TIP]
+> Consider also adding `NSLocationAlwaysAndWhenInUseUsageDescription` if your app needs background location access for other features.
+
+### Geolocation - Android
+
+Android differs to iOS in that permission requests must be handled in two places:
+(1) in your `AndroidManifest.xml` and (2) at runtime.
+
+```xml
+
+
+```
+
+The Checkout Sheet Kit native module will emit a `geolocationRequest` event when the webview requests geolocation
+information. By default, the kit will listen for this event and request access to both coarse and fine access when
+invoked.
+
+The geolocation request flow follows this sequence:
+
+1. When checkout needs location data (e.g., to show nearby pickup points), it triggers a geolocation request.
+2. The native module emits a `geolocationRequest` event.
+3. If using default behavior, the module automatically handles the Android runtime permission request.
+4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted.
+
+> [!NOTE]
+> If the user denies location permissions, the checkout will still function but will not be able to show nearby pickup points. Users can manually enter their location instead.
+
+#### Opting out of the default behavior
+
+> [!NOTE]
+> This section is only applicable for Android.
+
+In order to opt-out of the default permission handling, you can set `features.handleGeolocationRequests` to `false`
+when you instantiate the `ShopifyCheckoutSheet` class.
+
+If you're using the sheet programmatically, you can do so by specifying a `features` object as the second argument:
+
+```tsx
+const checkoutSheetKit = new ShopifyCheckoutSheet(config, {handleGeolocationRequests: false});
+```
+
+If you're using the context provider, you can pass the same `features` object as a prop to the `ShopifyCheckoutSheetProvider` component:
+
+```tsx
+
+ {children}
+
+```
+
+When opting out, you'll need to implement your own permission handling logic and communicate the result back to the checkout sheet. This can be useful if you want to:
+
+- Customize the permission request UI/UX
+- Coordinate location permissions with other app features
+- Implement custom fallback behavior when permissions are denied
+
+The steps here to implement your own logic are to:
+
+1. Listen for the `geolocationRequest`
+2. Request the desired permissions
+3. Invoke the native callback by calling `initiateGeolocationRequest` with the permission status
+
+```tsx
+// Listen for "geolocationRequest" events
+shopify.addEventListener('geolocationRequest', async (event: GeolocationRequestEvent) => {
+ const coarse = 'android.permission.ACCESS_COARSE_LOCATION';
+ const fine = 'android.permission.ACCESS_FINE_LOCATION';
+
+ // Request one or many permissions at once
+ const results = await PermissionsAndroid.requestMultiple([coarse, fine]);
+
+ // Check the permission status results
+ const permissionGranted = results[coarse] === 'granted' || results[fine] === 'granted';
+
+ // Dispatch an event to the native module to invoke the native callback with the permission status
+ shopify.initiateGeolocationRequest(permissionGranted);
+})
+```
+
---
## Contributing
diff --git a/modules/@shopify/checkout-sheet-kit/android/gradle.properties b/modules/@shopify/checkout-sheet-kit/android/gradle.properties
index 250e0718..e1758975 100644
--- a/modules/@shopify/checkout-sheet-kit/android/gradle.properties
+++ b/modules/@shopify/checkout-sheet-kit/android/gradle.properties
@@ -5,4 +5,4 @@ ndkVersion=23.1.7779620
buildToolsVersion = "33.0.0"
# Version of Shopify Checkout SDK to use with React Native
-SHOPIFY_CHECKOUT_SDK_VERSION=3.2.2
+SHOPIFY_CHECKOUT_SDK_VERSION=3.3.0
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java
index 0d5d8e0d..f1a194a2 100644
--- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java
@@ -25,6 +25,8 @@ of this software and associated documentation files (the "Software"), to deal
import android.content.Context;
import android.util.Log;
+import android.webkit.GeolocationPermissions;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -41,25 +43,70 @@ of this software and associated documentation files (the "Software"), to deal
public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor {
private final ReactApplicationContext reactContext;
-
private final ObjectMapper mapper = new ObjectMapper();
+ // Geolocation-specific variables
+
+ private String geolocationOrigin;
+ private GeolocationPermissions.Callback geolocationCallback;
+
public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) {
super(context);
-
this.reactContext = reactContext;
}
+ // Public methods
+
+ public void invokeGeolocationCallback(boolean allow) {
+ if (geolocationCallback != null) {
+ boolean retainGeolocationForFutureRequests = false;
+ geolocationCallback.invoke(geolocationOrigin, allow, retainGeolocationForFutureRequests);
+ geolocationCallback = null;
+ }
+ }
+
+ // Lifecycle events
+
+ /**
+ * This method is called when the checkout sheet webpage requests geolocation
+ * permissions.
+ *
+ * Since the app needs to request permissions first before granting, we store
+ * the callback and origin in memory and emit a "geolocationRequest" event to
+ * the app. The app will then request the necessary geolocation permissions
+ * and invoke the native callback with the result.
+ *
+ * @param origin - The origin of the request
+ * @param callback - The callback to invoke when the app requests permissions
+ */
@Override
- public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) {
+ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
+ @NonNull GeolocationPermissions.Callback callback) {
+
+ // Store the callback and origin in memory. The kit will wait for the app to
+ // request permissions first before granting.
+ this.geolocationCallback = callback;
+ this.geolocationOrigin = origin;
+
+ // Emit a "geolocationRequest" event to the app.
try {
- String data = mapper.writeValueAsString(event);
- sendEventWithStringData("completed", data);
+ Map event = new HashMap<>();
+ event.put("origin", origin);
+ sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event));
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e);
+ Log.e("ShopifyCheckoutSheetKit", "Error emitting \"geolocationRequest\" event", e);
}
}
+ @Override
+ public void onGeolocationPermissionsHidePrompt() {
+ super.onGeolocationPermissionsHidePrompt();
+
+ // Reset the geolocation callback and origin when the prompt is hidden.
+ this.geolocationCallback = null;
+ this.geolocationOrigin = null;
+ }
+
@Override
public void onWebPixelEvent(@NonNull PixelEvent event) {
try {
@@ -80,6 +127,23 @@ public void onCheckoutFailed(CheckoutException checkoutError) {
}
}
+ @Override
+ public void onCheckoutCanceled() {
+ sendEvent("close", null);
+ }
+
+ @Override
+ public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) {
+ try {
+ String data = mapper.writeValueAsString(event);
+ sendEventWithStringData("completed", data);
+ } catch (IOException e) {
+ Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e);
+ }
+ }
+
+ // Private
+
private Map populateErrorDetails(CheckoutException checkoutError) {
Map errorMap = new HashMap();
errorMap.put("__typename", getErrorTypeName(checkoutError));
@@ -110,11 +174,6 @@ private String getErrorTypeName(CheckoutException error) {
}
}
- @Override
- public void onCheckoutCanceled() {
- sendEvent("close", null);
- }
-
private void sendEvent(String eventName, @Nullable WritableNativeMap params) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
index ef594dae..ef2638b2 100644
--- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
@@ -48,6 +48,8 @@ public class ShopifyCheckoutSheetKitModule extends ReactContextBaseJavaModule {
private CheckoutSheetKitDialog checkoutSheet;
+ private CustomCheckoutEventProcessor checkoutEventProcessor;
+
public ShopifyCheckoutSheetKitModule(ReactApplicationContext reactContext) {
super(reactContext);
@@ -86,8 +88,7 @@ public void removeListeners(Integer count) {
public void present(String checkoutURL) {
Activity currentActivity = getCurrentActivity();
if (currentActivity instanceof ComponentActivity) {
- DefaultCheckoutEventProcessor checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity,
- this.reactContext);
+ checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext);
currentActivity.runOnUiThread(() -> {
checkoutSheet = ShopifyCheckoutSheetKit.present(checkoutURL, (ComponentActivity) currentActivity,
checkoutEventProcessor);
@@ -117,6 +118,59 @@ public void invalidateCache() {
ShopifyCheckoutSheetKit.invalidate();
}
+ @ReactMethod
+ public void getConfig(Promise promise) {
+ WritableNativeMap resultConfig = new WritableNativeMap();
+
+ resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled());
+ resultConfig.putString("colorScheme", colorSchemeToString(checkoutConfig.getColorScheme()));
+
+ promise.resolve(resultConfig);
+ }
+
+ @ReactMethod
+ public void setConfig(ReadableMap config) {
+ Context context = getReactApplicationContext();
+
+ ShopifyCheckoutSheetKit.configure(configuration -> {
+ if (config.hasKey("preloading")) {
+ configuration.setPreloading(new Preloading(config.getBoolean("preloading")));
+ }
+
+ if (config.hasKey("colorScheme")) {
+ ColorScheme colorScheme = getColorScheme(Objects.requireNonNull(config.getString("colorScheme")));
+ ReadableMap colorsConfig = config.hasKey("colors") ? config.getMap("colors") : null;
+ ReadableMap androidConfig = null;
+
+ if (colorsConfig != null && colorsConfig.hasKey("android")) {
+ androidConfig = colorsConfig.getMap("android");
+ }
+
+ if (this.isValidColorConfig(androidConfig)) {
+ ColorScheme colorSchemeWithOverrides = getColors(colorScheme, androidConfig);
+ if (colorSchemeWithOverrides != null) {
+ configuration.setColorScheme(colorSchemeWithOverrides);
+ checkoutConfig = configuration;
+ return;
+ }
+ }
+
+ configuration.setColorScheme(colorScheme);
+ }
+
+ checkoutConfig = configuration;
+ });
+ }
+
+ @ReactMethod
+ public void initiateGeolocationRequest(Boolean allow) {
+ if (checkoutEventProcessor != null) {
+ checkoutEventProcessor.invokeGeolocationCallback(allow);
+ }
+ }
+
+ // Private
+
private ColorScheme getColorScheme(String colorScheme) {
switch (colorScheme) {
case "web_default":
@@ -233,50 +287,6 @@ private ColorScheme getColors(ColorScheme colorScheme, ReadableMap config) {
return null;
}
- @ReactMethod
- public void setConfig(ReadableMap config) {
- Context context = getReactApplicationContext();
-
- ShopifyCheckoutSheetKit.configure(configuration -> {
- if (config.hasKey("preloading")) {
- configuration.setPreloading(new Preloading(config.getBoolean("preloading")));
- }
-
- if (config.hasKey("colorScheme")) {
- ColorScheme colorScheme = getColorScheme(Objects.requireNonNull(config.getString("colorScheme")));
- ReadableMap colorsConfig = config.hasKey("colors") ? config.getMap("colors") : null;
- ReadableMap androidConfig = null;
-
- if (colorsConfig != null && colorsConfig.hasKey("android")) {
- androidConfig = colorsConfig.getMap("android");
- }
-
- if (this.isValidColorConfig(androidConfig)) {
- ColorScheme colorSchemeWithOverrides = getColors(colorScheme, androidConfig);
- if (colorSchemeWithOverrides != null) {
- configuration.setColorScheme(colorSchemeWithOverrides);
- checkoutConfig = configuration;
- return;
- }
- }
-
- configuration.setColorScheme(colorScheme);
- }
-
- checkoutConfig = configuration;
- });
- }
-
- @ReactMethod
- public void getConfig(Promise promise) {
- WritableNativeMap resultConfig = new WritableNativeMap();
-
- resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled());
- resultConfig.putString("colorScheme", colorSchemeToString(checkoutConfig.getColorScheme()));
-
- promise.resolve(resultConfig);
- }
-
private Color parseColor(String colorStr) {
try {
colorStr = colorStr.replace("#", "");
diff --git a/modules/@shopify/checkout-sheet-kit/package.json b/modules/@shopify/checkout-sheet-kit/package.json
index 7b1e4934..1602e715 100644
--- a/modules/@shopify/checkout-sheet-kit/package.json
+++ b/modules/@shopify/checkout-sheet-kit/package.json
@@ -1,7 +1,7 @@
{
"name": "@shopify/checkout-sheet-kit",
"license": "MIT",
- "version": "3.1.2",
+ "version": "3.2.0",
"main": "lib/commonjs/index.js",
"types": "src/index.ts",
"source": "src/index.ts",
diff --git a/modules/@shopify/checkout-sheet-kit/src/context.tsx b/modules/@shopify/checkout-sheet-kit/src/context.tsx
index e57ee92f..ad9bf831 100644
--- a/modules/@shopify/checkout-sheet-kit/src/context.tsx
+++ b/modules/@shopify/checkout-sheet-kit/src/context.tsx
@@ -23,8 +23,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
import React, {useCallback, useMemo, useRef} from 'react';
import type {PropsWithChildren} from 'react';
-import type {EmitterSubscription} from 'react-native';
+import {type EmitterSubscription} from 'react-native';
import {ShopifyCheckoutSheet} from './index';
+import type {Features} from './index.d';
import type {
AddEventListener,
RemoveEventListeners,
@@ -61,17 +62,19 @@ const ShopifyCheckoutSheetContext = React.createContext({
});
interface Props {
+ features?: Partial;
configuration?: Configuration;
}
export function ShopifyCheckoutSheetProvider({
+ features,
configuration,
children,
}: PropsWithChildren) {
const instance = useRef(null);
if (!instance.current) {
- instance.current = new ShopifyCheckoutSheet(configuration);
+ instance.current = new ShopifyCheckoutSheet(configuration, features);
}
const addEventListener: AddEventListener = useCallback(
diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts
index 122d2e12..2101d906 100644
--- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts
+++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts
@@ -28,6 +28,18 @@ import type {CheckoutException} from './errors';
export type Maybe = T | undefined;
+/**
+ * Configuration options for checkout sheet kit features
+ */
+export interface Features {
+ /**
+ * When enabled, the checkout will handle geolocation permission requests internally.
+ * If disabled, geolocation requests will emit a 'geolocationRequest' event that
+ * must be handled by the application.
+ */
+ handleGeolocationRequests: boolean;
+}
+
export enum ColorScheme {
automatic = 'automatic',
light = 'light',
@@ -127,9 +139,21 @@ export type Configuration = CommonConfiguration &
}
);
-export type CheckoutEvent = 'close' | 'completed' | 'error' | 'pixel';
+export type CheckoutEvent =
+ | 'close'
+ | 'completed'
+ | 'error'
+ | 'geolocationRequest'
+ | 'pixel';
+
+export interface GeolocationRequestEvent {
+ origin: string;
+}
export type CloseEventCallback = () => void;
+export type GeolocationRequestEventCallback = (
+ event: GeolocationRequestEvent,
+) => void;
export type PixelEventCallback = (event: PixelEvent) => void;
export type CheckoutExceptionCallback = (error: CheckoutException) => void;
export type CheckoutCompletedEventCallback = (
@@ -140,6 +164,7 @@ export type CheckoutEventCallback =
| CloseEventCallback
| CheckoutExceptionCallback
| CheckoutCompletedEventCallback
+ | GeolocationRequestEventCallback
| PixelEventCallback;
function addEventListener(
@@ -162,6 +187,11 @@ function addEventListener(
callback: PixelEventCallback,
): Maybe;
+function addEventListener(
+ event: 'geolocationRequest',
+ callback: GeolocationRequestEventCallback,
+): Maybe;
+
function removeEventListeners(event: CheckoutEvent): void;
export type AddEventListener = typeof addEventListener;
@@ -198,7 +228,11 @@ export interface ShopifyCheckoutSheetKit {
*/
addEventListener: AddEventListener;
/**
- * Remove subscriptions to checkout events
+ * Remove subscriptions to checkout events.
*/
removeEventListeners: RemoveEventListeners;
+ /**
+ * Cleans up any event callbacks to prevent memory leaks.
+ */
+ teardown(): void;
}
diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts
index 82fee184..4dda85a7 100644
--- a/modules/@shopify/checkout-sheet-kit/src/index.ts
+++ b/modules/@shopify/checkout-sheet-kit/src/index.ts
@@ -21,14 +21,26 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-import {NativeModules, NativeEventEmitter} from 'react-native';
-import type {EmitterSubscription} from 'react-native';
+import {
+ NativeModules,
+ NativeEventEmitter,
+ PermissionsAndroid,
+ Platform,
+} from 'react-native';
+import type {
+ EmitterSubscription,
+ EventSubscription,
+ PermissionStatus,
+} from 'react-native';
import {ShopifyCheckoutSheetProvider, useShopifyCheckoutSheet} from './context';
import {ColorScheme} from './index.d';
import type {
CheckoutEvent,
CheckoutEventCallback,
Configuration,
+ Features,
+ GeolocationRequestEvent,
+ Maybe,
ShopifyCheckoutSheetKit,
} from './index.d';
import type {CheckoutException, CheckoutNativeError} from './errors.d';
@@ -54,43 +66,95 @@ if (!('ShopifyCheckoutSheetKit' in NativeModules)) {
If you are building for iOS, make sure to run "pod install" first and restart the metro server.`);
}
+const defaultFeatures: Features = {
+ handleGeolocationRequests: true,
+};
+
class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit {
private static eventEmitter: NativeEventEmitter = new NativeEventEmitter(
RNShopifyCheckoutSheetKit,
);
- constructor(configuration?: Configuration) {
+ private features: Features;
+ private geolocationCallback: Maybe;
+
+ /**
+ * Initializes a new ShopifyCheckoutSheet instance
+ * @param configuration Optional configuration settings for the checkout
+ * @param features Optional feature flags to customize behavior, defaults to defaultFeatures
+ */
+ constructor(
+ configuration?: Configuration,
+ features: Partial = defaultFeatures,
+ ) {
+ this.features = {...defaultFeatures, ...features};
+
if (configuration != null) {
this.setConfig(configuration);
}
+
+ if (
+ Platform.OS === 'android' &&
+ this.featureEnabled('handleGeolocationRequests')
+ ) {
+ this.subscribeToGeolocationRequestPrompts();
+ }
}
public readonly version: string = RNShopifyCheckoutSheetKit.version;
+ /**
+ * Dismisses the currently displayed checkout sheet
+ */
public dismiss(): void {
RNShopifyCheckoutSheetKit.dismiss();
}
+ /**
+ * Invalidates the checkout that was cached using preload
+ */
public invalidate(): void {
RNShopifyCheckoutSheetKit.invalidateCache();
}
+ /**
+ * Preloads checkout for a given URL to improve performance
+ * @param checkoutUrl The URL of the checkout to preload
+ */
public preload(checkoutUrl: string): void {
RNShopifyCheckoutSheetKit.preload(checkoutUrl);
}
+ /**
+ * Presents the checkout sheet for a given checkout URL
+ * @param checkoutUrl The URL of the checkout to display
+ */
public present(checkoutUrl: string): void {
RNShopifyCheckoutSheetKit.present(checkoutUrl);
}
+ /**
+ * Retrieves the current checkout configuration
+ * @returns Promise containing the current Configuration
+ */
public async getConfig(): Promise {
return RNShopifyCheckoutSheetKit.getConfig();
}
+ /**
+ * Updates the checkout configuration
+ * @param configuration New configuration settings to apply
+ */
public setConfig(configuration: Configuration): void {
RNShopifyCheckoutSheetKit.setConfig(configuration);
}
+ /**
+ * Adds an event listener for checkout events
+ * @param event The type of event to listen for
+ * @param callback Function to be called when the event occurs
+ * @returns An EmitterSubscription that can be used to remove the listener
+ */
public addEventListener(
event: CheckoutEvent,
callback: CheckoutEventCallback,
@@ -115,6 +179,12 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit {
this.parseCheckoutError,
);
break;
+ case 'geolocationRequest':
+ eventCallback = this.interceptEventEmission(
+ 'geolocationRequest',
+ callback,
+ );
+ break;
default:
eventCallback = callback;
}
@@ -123,12 +193,82 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit {
return ShopifyCheckoutSheet.eventEmitter.addListener(event, eventCallback);
}
+ /**
+ * Removes all event listeners for a specific event type
+ * @param event The type of event to remove listeners for
+ */
public removeEventListeners(event: CheckoutEvent) {
ShopifyCheckoutSheet.eventEmitter.removeAllListeners(event);
}
+ /**
+ * Cleans up resources and event listeners used by the checkout sheet
+ */
+ public teardown() {
+ this.geolocationCallback?.remove();
+ }
+
+ /**
+ * Initiates a geolocation request for Android devices
+ * Only needed if features.handleGeolocationRequests is false
+ */
+ public async initiateGeolocationRequest(allow: boolean) {
+ if (Platform.OS === 'android') {
+ RNShopifyCheckoutSheetKit.initiateGeolocationRequest?.(allow);
+ }
+ }
+
// ---
+ /**
+ * Checks if a specific feature is enabled in the configuration
+ * @param feature The feature to check
+ * @returns boolean indicating if the feature is enabled
+ */
+ private featureEnabled(feature: keyof Features) {
+ return this.features[feature] ?? true;
+ }
+
+ /**
+ * Sets up geolocation request handling for Android devices
+ */
+ private subscribeToGeolocationRequestPrompts() {
+ this.geolocationCallback = this.addEventListener(
+ 'geolocationRequest',
+ async () => {
+ const coarseOrFineGrainAccessGranted = await this.requestGeolocation();
+
+ this.initiateGeolocationRequest(coarseOrFineGrainAccessGranted);
+ },
+ );
+ }
+
+ /**
+ * Requests geolocation permissions on Android
+ * @returns Promise indicating if permission was granted
+ */
+ private async requestGeolocation(): Promise {
+ const coarse = 'android.permission.ACCESS_COARSE_LOCATION';
+ const fine = 'android.permission.ACCESS_FINE_LOCATION';
+ const results = await PermissionsAndroid.requestMultiple([coarse, fine]);
+
+ return [results[coarse], results[fine]].some(this.permissionGranted);
+ }
+
+ /**
+ * Checks if the given permission status indicates that permission was granted
+ * @param status The permission status to check
+ * @returns boolean indicating if the permission was granted
+ */
+ private permissionGranted(status: PermissionStatus): boolean {
+ return status === 'granted';
+ }
+
+ /**
+ * Parses custom pixel event data from string to object if needed
+ * @param eventData The pixel event data to parse
+ * @returns Parsed PixelEvent object
+ */
private parseCustomPixelData(eventData: PixelEvent): PixelEvent {
if (
isCustomPixelEvent(eventData) &&
@@ -148,6 +288,11 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit {
return eventData;
}
+ /**
+ * Converts native checkout errors into appropriate error class instances
+ * @param exception The native error to parse
+ * @returns Appropriate CheckoutException instance
+ */
private parseCheckoutError(
exception: CheckoutNativeError,
): CheckoutException {
@@ -168,7 +313,11 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit {
}
/**
- * Event data can be sent back as either a parsed Event object or a JSON string.
+ * Handles event emission parsing and transformation
+ * @param event The type of event being intercepted
+ * @param callback The callback to execute with the parsed data
+ * @param transformData Optional function to transform the event data
+ * @returns Function that handles the event emission
*/
private interceptEventEmission(
event: CheckoutEvent,
@@ -252,6 +401,8 @@ export type {
CheckoutException,
Configuration,
CustomEvent,
+ GeolocationRequestEvent,
+ Features,
PixelEvent,
StandardEvent,
};
diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts
index 72b8f7da..3ac0f5d1 100644
--- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts
+++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts
@@ -12,7 +12,7 @@ import {
GenericError,
} from '../src';
import {ColorScheme, CheckoutNativeErrorType, type Configuration} from '../src';
-import {NativeModules} from 'react-native';
+import {NativeModules, PermissionsAndroid, Platform} from 'react-native';
const checkoutUrl = 'https://shopify.com/checkout';
const config: Configuration = {
@@ -53,9 +53,16 @@ jest.mock('react-native', () => {
setConfig: jest.fn(),
addEventListener: jest.fn(),
removeEventListeners: jest.fn(),
+ initiateGeolocationRequest: jest.fn(),
};
return {
+ Platform: {
+ OS: 'ios',
+ },
+ PermissionsAndroid: {
+ requestMultiple: jest.fn(),
+ },
_listeners: listeners,
NativeEventEmitter,
NativeModules: {
@@ -80,6 +87,8 @@ describe('ShopifyCheckoutSheetKit', () => {
// Clear mock listeners
NativeModules._listeners = [];
+
+ jest.clearAllMocks();
});
describe('instantiation', () => {
@@ -434,4 +443,146 @@ describe('ShopifyCheckoutSheetKit', () => {
expect(eventEmitter.removeAllListeners).toHaveBeenCalledWith('close');
});
});
+
+ describe('Geolocation', () => {
+ const defaultConfig = {};
+
+ async function emitGeolocationRequest() {
+ await new Promise(resolve => {
+ eventEmitter.emit('geolocationRequest', {
+ origin: 'https://shopify.com',
+ });
+ setTimeout(resolve);
+ });
+ }
+
+ describe('Android', () => {
+ const originalPlatform = Platform.OS;
+
+ beforeEach(() => {
+ Platform.OS = 'android';
+ });
+
+ afterAll(() => {
+ Platform.OS = originalPlatform;
+ });
+
+ it('subscribes to geolocation requests on Android when feature is enabled', () => {
+ new ShopifyCheckoutSheet(defaultConfig);
+
+ expect(eventEmitter.addListener).toHaveBeenCalledWith(
+ 'geolocationRequest',
+ expect.any(Function),
+ );
+ });
+
+ it('does not subscribe to geolocation requests when feature is disabled', () => {
+ new ShopifyCheckoutSheet(defaultConfig, {
+ handleGeolocationRequests: false,
+ });
+
+ expect(eventEmitter.addListener).not.toHaveBeenCalledWith(
+ 'geolocationRequest',
+ expect.any(Function),
+ );
+ });
+
+ it('handles geolocation permission grant correctly', async () => {
+ const mockPermissions = {
+ 'android.permission.ACCESS_COARSE_LOCATION': 'granted',
+ 'android.permission.ACCESS_FINE_LOCATION': 'denied',
+ };
+
+ (PermissionsAndroid.requestMultiple as jest.Mock).mockResolvedValue(
+ mockPermissions,
+ );
+
+ new ShopifyCheckoutSheet();
+
+ await emitGeolocationRequest();
+
+ expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([
+ 'android.permission.ACCESS_COARSE_LOCATION',
+ 'android.permission.ACCESS_FINE_LOCATION',
+ ]);
+ expect(
+ NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest,
+ ).toHaveBeenCalledWith(true);
+ });
+
+ it('handles geolocation permission denial correctly', async () => {
+ const mockPermissions = {
+ 'android.permission.ACCESS_COARSE_LOCATION': 'denied',
+ 'android.permission.ACCESS_FINE_LOCATION': 'denied',
+ };
+
+ (PermissionsAndroid.requestMultiple as jest.Mock).mockResolvedValue(
+ mockPermissions,
+ );
+
+ new ShopifyCheckoutSheet();
+
+ await emitGeolocationRequest();
+
+ expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([
+ 'android.permission.ACCESS_COARSE_LOCATION',
+ 'android.permission.ACCESS_FINE_LOCATION',
+ ]);
+ expect(
+ NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest,
+ ).toHaveBeenCalledWith(false);
+ });
+
+ it('cleans up geolocation callback on teardown', () => {
+ const sheet = new ShopifyCheckoutSheet();
+ const mockRemove = jest.fn();
+
+ // @ts-expect-error
+ sheet.geolocationCallback = {
+ remove: mockRemove,
+ };
+
+ sheet.teardown();
+
+ expect(mockRemove).toHaveBeenCalled();
+ });
+ });
+
+ describe('iOS', () => {
+ const originalPlatform = Platform.OS;
+
+ beforeEach(() => {
+ Platform.OS = 'ios';
+ });
+
+ afterAll(() => {
+ Platform.OS = originalPlatform;
+ });
+
+ it('does not subscribe to geolocation requests', () => {
+ new ShopifyCheckoutSheet();
+
+ expect(eventEmitter.addListener).not.toHaveBeenCalledWith(
+ 'geolocationRequest',
+ expect.any(Function),
+ );
+ });
+
+ it('does not call the native function, even if an event is emitted', async () => {
+ new ShopifyCheckoutSheet();
+
+ await emitGeolocationRequest();
+
+ expect(
+ NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('tears down gracefully', () => {
+ const sheet = new ShopifyCheckoutSheet();
+
+ expect(() => sheet.teardown()).not.toThrow();
+ });
+ });
+ });
});
diff --git a/sample/android/gradle.properties b/sample/android/gradle.properties
index 79a3c35b..796f3a91 100644
--- a/sample/android/gradle.properties
+++ b/sample/android/gradle.properties
@@ -41,4 +41,4 @@ newArchEnabled=false
hermesEnabled=true
# Note: only used here for testing
-SHOPIFY_CHECKOUT_SDK_VERSION=3.2.2
+SHOPIFY_CHECKOUT_SDK_VERSION=3.3.0
diff --git a/sample/src/App.tsx b/sample/src/App.tsx
index 5f4044cd..490c820a 100644
--- a/sample/src/App.tsx
+++ b/sample/src/App.tsx
@@ -342,7 +342,9 @@ function Routes() {
function App() {
return (
-
+