Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 60 additions & 83 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ experiences.
- [When to preload](#when-to-preload)
- [Cache invalidation](#cache-invalidation)
- [Checkout lifecycle](#checkout-lifecycle)
- [`addEventListener(eventName, callback)`](#addeventlistenereventname-callback)
- [`removeEventListeners(eventName)`](#removeeventlistenerseventname)
- [SDK callbacks on `present()`](#sdk-callbacks-on-present)
- [Identity \& customer accounts](#identity--customer-accounts)
- [Cart: buyer bag, identity, and preferences](#cart-buyer-bag-identity-and-preferences)
- [Multipass](#multipass)
Expand Down Expand Up @@ -590,60 +589,36 @@ Should you wish to manually clear the preload cache, there is a `ShopifyCheckout

## Checkout lifecycle

There are currently 3 checkout events exposed through the Native Module. You can
subscribe to these events using `addEventListener` and `removeEventListeners`
methods - available on both the context provider as well as the class instance.
Lifecycle callbacks are passed per-call to `present()`. The bridge holds the
handles for the duration of that one presentation and releases them on
terminal events; nothing needs to be subscribed or torn down explicitly.

| Name | Callback | Description |
| ----------- | ----------------------------------------- | ------------------------------------------------------------ |
| `close` | `() => void` | Fired when the checkout has been closed. |
| `completed` | `(event: CheckoutCompletedEvent) => void` | Fired when the checkout has been successfully completed. |
| `error` | `(error: {message: string}) => void` | Fired when a checkout exception has been raised. |

### `addEventListener(eventName, callback)`

Subscribing to an event returns an `EmitterSubscription` object, which contains
a `remove()` function to unsubscribe. Here's an example of how you might create
an event listener in a React `useEffect`, ensuring to remove it on unmount.
### SDK callbacks on `present()`

```tsx
// Using hooks
const shopifyCheckout = useShopifyCheckout();

useEffect(() => {
const close = shopifyCheckout.addEventListener('close', () => {
// Do something on checkout close
});

const completed = shopifyCheckout.addEventListener(
'completed',
(event: CheckoutCompletedEvent) => {
// Lookup order on checkout completion
const orderId = event.orderDetails.id;
},
);

const error = shopifyCheckout.addEventListener(
'error',
(error: CheckoutError) => {
// Do something on checkout error
// console.log(error.message)
},
);

return () => {
// It is important to clear the subscription on unmount to prevent memory leaks
close?.remove();
completed?.remove();
error?.remove();
};
}, [shopifyCheckout]);
shopify.present(checkoutUrl, {
onClose: () => {
// The sheet was dismissed without a terminal error
},
onFail: (error: CheckoutException) => {
// A terminal error occurred — inspect `error.code`, `error.recoverable`, etc.
},
});
```

### `removeEventListeners(eventName)`
| Name | Callback | Fires |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `onClose` | `() => void` | Once, when the buyer dismisses the sheet without a terminal error. |
| `onFail` | `(error: CheckoutException) => void` | Once, when the checkout terminates with an error. |
| `onGeolocationRequest` | `(event: GeolocationRequestEvent) => void` | Android only. Fired each time the webview requests geolocation permissions. See [Opting out of the default behavior](#opting-out-of-the-default-behavior). |

On the rare occasion that you want to remove all event listeners for a given
`eventName`, you can use the `removeEventListeners(eventName)` method.
`onClose` and `onFail` are mutually exclusive — exactly one of them fires
per `present(...)` call, after which both handles are released.

> Protocol-level callbacks (`start`, `complete`, `error` on the protocol
> client) are not part of this section and will land in a follow-up release
> alongside a `<CheckoutSheet>` component. Checkout completion is not
> currently surfaced through the per-call callbacks.

## Identity & customer accounts

Expand Down Expand Up @@ -756,15 +731,16 @@ Android differs to iOS in that permission requests must be handled in two places
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```

The Checkout 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.
When the webview requests geolocation information, the Checkout Kit native
module surfaces it to JS so the app can respond. By default, the kit handles
the request itself and asks for both coarse and fine access on the buyer's
behalf.

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.
2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked.
3. Otherwise, with `features.handleGeolocationRequests: true` (the default), 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]
Expand All @@ -775,52 +751,53 @@ The geolocation request flow follows this sequence:
> [!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 `ShopifyCheckout` class.
There are two ways to opt out, depending on whether you want to override the
behavior for every presentation or just one.

If you're using the sheet programmatically, you can do so by specifying a `features` object as the second argument:
**Per-call override.** Pass an `onGeolocationRequest` callback to
`present()`. When set, the callback fires instead of the default handler
for that one presentation; the consumer is responsible for resolving
permissions and calling `initiateGeolocationRequest(allow)`:

```tsx
shopify.present(checkoutUrl, {
onGeolocationRequest: async (event: GeolocationRequestEvent) => {
const coarse = 'android.permission.ACCESS_COARSE_LOCATION';
const fine = 'android.permission.ACCESS_FINE_LOCATION';

const results = await PermissionsAndroid.requestMultiple([coarse, fine]);
const granted =
results[coarse] === 'granted' || results[fine] === 'granted';

shopify.initiateGeolocationRequest(granted);
},
});
```

**Process-wide opt-out.** Set `features.handleGeolocationRequests` to
`false` when you instantiate the `ShopifyCheckout` class to disable the
default handler entirely. Use this if you intend to always handle
geolocation yourself but don't want to wire the callback at every call
site.

```tsx
const shopifyCheckout = new ShopifyCheckout(config, {handleGeolocationRequests: false});
```

If you're using the context provider, you can pass the same `features` object as a prop to the `ShopifyCheckoutProvider` component:
If you're using the context provider, pass the same `features` object as a prop:

```tsx
<ShopifyCheckoutProvider configuration={config} features={{handleGeolocationRequests: false}}>
{children}
</ShopifyCheckoutProvider>
```

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:
Custom permission handling lets you:

- 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);
})
```

---

## Accelerated Checkouts
Expand Down
2 changes: 0 additions & 2 deletions platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ const ShopifyCheckoutKit = {
invalidateCache: jest.fn(),
getConfig: jest.fn(() => exampleConfig),
setConfig: jest.fn(),
addEventListener: jest.fn(),
removeEventListeners: jest.fn(),
initiateGeolocationRequest: jest.fn(),
configureAcceleratedCheckouts: jest.fn(() => true),
isAcceleratedCheckoutAvailable: jest.fn(() => true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ of this software and associated documentation files (the "Software"), to deal
import androidx.annotation.Nullable;

import com.shopify.checkoutkit.*;
import com.facebook.react.bridge.Callback;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.bridge.ReactApplicationContext;
import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -44,13 +44,25 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor
private final ReactApplicationContext reactContext;
private final ObjectMapper mapper = new ObjectMapper();

@Nullable
private Callback onCloseCallback;
@Nullable
private Callback onFailCallback;
@Nullable
private Callback onGeolocationRequestCallback;

// Geolocation-specific variables

private String geolocationOrigin;
private GeolocationPermissions.Callback geolocationCallback;

public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) {
public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext,
@Nullable Callback onClose, @Nullable Callback onFail,
@Nullable Callback onGeolocationRequest) {
this.reactContext = reactContext;
this.onCloseCallback = onClose;
this.onFailCallback = onFail;
this.onGeolocationRequestCallback = onGeolocationRequest;
}

// Public methods
Expand Down Expand Up @@ -86,11 +98,15 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
this.geolocationCallback = callback;
this.geolocationOrigin = origin;

// Emit a "geolocationRequest" event to the app.
try {
Map<String, Object> event = new HashMap<>();
event.put("origin", origin);
sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event));
String payload = mapper.writeValueAsString(event);
if (onGeolocationRequestCallback != null) {
onGeolocationRequestCallback.invoke(payload);
} else {
sendEventWithStringData("geolocationRequest", payload);
}
} catch (IOException e) {
Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e);
}
Expand All @@ -107,17 +123,26 @@ public void onGeolocationPermissionsHidePrompt() {

@Override
public void onCheckoutFailed(CheckoutException checkoutError) {
if (onFailCallback == null) {
return;
}
try {
String data = mapper.writeValueAsString(populateErrorDetails(checkoutError));
sendEventWithStringData("error", data);
onFailCallback.invoke(data);
} catch (IOException e) {
Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e);
} finally {
onFailCallback = null;
}
}

@Override
public void onCheckoutCanceled() {
sendEvent("close", null);
if (onCloseCallback == null) {
return;
}
onCloseCallback.invoke();
onCloseCallback = null;
}

@Override
Expand Down Expand Up @@ -162,12 +187,6 @@ private String getErrorTypeName(CheckoutException error) {
}
}

private void sendEvent(String eventName, @Nullable WritableNativeMap params) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
}

private void sendEventWithStringData(String name, String data) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ of this software and associated documentation files (the "Software"), to deal
import android.content.Context;
import androidx.activity.ComponentActivity;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
Expand Down Expand Up @@ -80,10 +82,12 @@ public void removeListeners(double count) {
}

@ReactMethod
public void present(String checkoutURL) {
public void present(String checkoutURL, @Nullable Callback onClose, @Nullable Callback onFail,
@Nullable Callback onGeolocationRequest) {
Activity currentActivity = getCurrentActivity();
if (currentActivity instanceof ComponentActivity) {
checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext);
checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, onClose,
onFail, onGeolocationRequest);
currentActivity.runOnUiThread(() -> {
checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity,
checkoutEventProcessor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,5 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO

#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTUIManager.h>
#import <React/RCTBridge.h>
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe

RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration)

RCT_EXTERN_METHOD(present:(NSString *)checkoutURL
onClose:(RCTResponseSenderBlock)onClose
onFail:(RCTResponseSenderBlock)onFail
onGeolocationRequest:(RCTResponseSenderBlock)onGeolocationRequest)

@end

// TurboModule registration. `RCTModuleProviders` (generated by codegen from
Expand Down
Loading
Loading