Skip to content
Merged
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
29 changes: 25 additions & 4 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ shopify.present(checkoutUrl, {
| `onComplete` | `{checkout: Checkout}` | Checkout completes. The confirmation UI can remain visible. |
| `onDismiss` | None | Checkout is dismissed, including after completion. |
| `onFail` | `{error: CheckoutException}` | Checkout cannot continue. |
| `onLinkClick` | `{url: string}` | Checkout requests opening a link. |
| `onGeolocationRequest` | `GeolocationRequestEvent` | Android sheets only. See [geolocation handling](#opting-out-of-the-default-behavior). |

Completion keeps callbacks active until dismissal or failure. Delay changes that
Expand All @@ -825,7 +826,7 @@ until dismissal or failure. Calling `dismiss()` also delivers `onDismiss`.

Repeated `present()` calls while a checkout session is active are ignored,
including calls from another `ShopifyCheckout` instance. The original checkout
and callbacks remain active. Calls made while the previous sheet is closing are
and callbacks and link policy remain active. Calls made while the previous sheet is closing are
also ignored, without firing callbacks for the ignored attempt. `onDismiss` and
`onFail` can run before the closing animation finishes, so presenting from those
callbacks is not guaranteed to open another checkout.
Expand All @@ -834,6 +835,24 @@ callbacks is not guaranteed to open another checkout.
pending geolocation responses without dismissing the sheet; another checkout
can be presented once the native session ends.

### Link handling

Set `linkAction` before presentation (or on the accelerated buttons):

- `open` (default): let the native SDK open links.
- `handled`: your app opens or routes links itself.
- `cancel`: prevent links from opening.

`onLinkClick` is an asynchronous notification. Its return value cannot change the
native decision. For custom routing, pair it with `linkAction: 'handled'`:

```tsx
shopify.present(checkoutUrl, {
linkAction: 'handled',
onLinkClick: ({url}) => Linking.openURL(url),
});
```

### Migrating from protocol callbacks

Replace the third `present()` argument and accelerated `events` prop with the
Expand All @@ -843,8 +862,8 @@ data from `event.checkout`. Terminal protocol errors now arrive through `onFail`
checkout messages remain available in snapshots.

Rename sheet `onClose` and accelerated `onCancel` to `onDismiss`. Change
`onFail(error)` to `onFail({error})`. The accelerated `onClickLink` prop is removed;
native SDKs open checkout links by default. `CheckoutProtocol`,
`onFail(error)` to `onFail({error})`, and accelerated `onClickLink(url)` to
`onLinkClick({url})` with the appropriate `linkAction`. `CheckoutProtocol`,
`ProtocolHandlers`, and protocol payload exports have been removed.

## Identity & customer accounts
Expand Down Expand Up @@ -1168,7 +1187,7 @@ The `cornerRadius` prop lets you match the buttons to other calls-to-action in y

### Handle loading, errors, and lifecycle events

Accelerated buttons use the same lifecycle callbacks as sheets.
Accelerated buttons use the same lifecycle callbacks and link policy as sheets.
Use a ref to remember completion without unmounting the button's confirmation UI:

```tsx
Expand All @@ -1192,6 +1211,8 @@ const completed = useRef(false);
console.error('Accelerated checkout failed:', error.code);
}}
onRenderStateChange={(event) => setRenderState(event.state)}
linkAction="handled"
onLinkClick={({url}) => Linking.openURL(url)}
/>
```

Expand Down
1 change: 1 addition & 0 deletions platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const ShopifyCheckoutKit = {
'complete',
'dismiss',
'fail',
'linkClick',
'geolocationRequest',
],
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ public class CustomCheckoutListener extends DefaultCheckoutListener {
private final ObjectMapper mapper = new ObjectMapper();

private final DispatchHandle dispatch;
private CheckoutLinkAction linkAction = CheckoutLinkAction.Open;
private Runnable onTerminal = () -> {};

public void setOnTerminal(Runnable onTerminal) {
public void configure(String action, Runnable onTerminal) {
this.linkAction = "handled".equals(action) ? CheckoutLinkAction.Handled
: "cancel".equals(action) ? CheckoutLinkAction.Cancel : CheckoutLinkAction.Open;
this.onTerminal = onTerminal;
}

Expand Down Expand Up @@ -149,6 +152,19 @@ public void onCheckoutCompleted(CheckoutCompleteEvent event) {
emitCheckout(DispatchEventTypes.COMPLETE, event.getCheckout());
}

@Override
public CheckoutLinkAction onCheckoutLinkClicked(CheckoutLink link) {
if (dispatch.isReleased()) return CheckoutLinkAction.Cancel;
try {
Map<String, Object> payload = new HashMap<>();
payload.put("url", link.getUrl().toString());
dispatch.invoke(buildEnvelope(DispatchEventTypes.LINK_CLICK, payload));
} catch (IOException e) {
Log.e(TAG, "Error emitting link click event", e);
}
return linkAction;
}

private void emitCheckout(String type, Checkout checkout) {
if (dispatch.isReleased()) return;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ public final class DispatchEventTypes {
public static final String UPDATE = "update";
public static final String COMPLETE = "complete";
public static final String DISMISS = "dismiss";
public static final String LINK_CLICK = "linkClick";
public static final String FAIL = "fail";
public static final String GEOLOCATION_REQUEST = "geolocationRequest";

public static final List<String> ALL = Collections.unmodifiableList(
Arrays.asList(START, UPDATE, COMPLETE, DISMISS, FAIL, GEOLOCATION_REQUEST));
Arrays.asList(START, UPDATE, COMPLETE, DISMISS, FAIL, LINK_CLICK, GEOLOCATION_REQUEST));

private DispatchEventTypes() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public void removeListeners(double count) {
}

@ReactMethod
public void present(String checkoutURL, Callback onResult) {
public void present(String checkoutURL, String linkAction, Callback onResult) {
if (invalidated) {
onResult.invoke(false);
return;
Expand All @@ -96,7 +96,7 @@ public void present(String checkoutURL, Callback onResult) {
}
CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent);
checkoutListener = listener;
listener.setOnTerminal(this::finishCheckoutPresentation);
listener.configure(linkAction, this::finishCheckoutPresentation);
CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, listener);
// Initialization can fail synchronously and already emit a terminal event.
if (checkoutListener != listener) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.shopify.reactnative.checkoutkit

import android.net.Uri
import com.shopify.checkoutkit.*
import kotlinx.serialization.json.*
import org.assertj.core.api.Assertions.assertThat
Expand Down Expand Up @@ -42,4 +43,20 @@ class CheckoutEventSerializationTest {
.containsExactly("start", "update", "complete", "dismiss")
}

@Test
fun `link policy returns synchronously and also notifies JS`() {
val constructor = CheckoutLink::class.java.getDeclaredConstructor(Uri::class.java)
constructor.isAccessible = true
val link = constructor.newInstance(Uri.parse("https://example.test/policy"))
listOf("open" to CheckoutLinkAction.Open, "handled" to CheckoutLinkAction.Handled, "cancel" to CheckoutLinkAction.Cancel).forEach { (action, expected) ->
val events = mutableListOf<String>()
val listener = CustomCheckoutListener(DispatchCallback { events.add(it) })
listener.configure(action) {}
assertThat(listener.onCheckoutLinkClicked(link)).isEqualTo(expected)
val envelope = Json.parseToJsonElement(events.single()).jsonObject
assertThat(envelope["type"]?.jsonPrimitive?.content).isEqualTo("linkClick")
assertThat(envelope["payload"]?.jsonObject?.get("url")?.jsonPrimitive?.content).isEqualTo("https://example.test/policy")
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,13 @@ export enum CheckoutErrorCode {

// @public
export interface CheckoutEventHandlers {
linkAction?: CheckoutLinkAction;
onComplete?: (event: CheckoutCompleteEvent) => void;
// (undocumented)
onDismiss?: () => void;
// (undocumented)
onFail?: (event: CheckoutFailureEvent) => void;
onLinkClick?: (link: CheckoutLink) => void;
// (undocumented)
onStart?: (event: CheckoutStartEvent) => void;
// (undocumented)
Expand All @@ -228,6 +230,15 @@ export interface CheckoutFailureEvent {
error: CheckoutException;
}

// @public (undocumented)
export interface CheckoutLink {
// (undocumented)
url: string;
}

// @public (undocumented)
export type CheckoutLinkAction = 'open' | 'handled' | 'cancel';

// @public
export type CheckoutNativeError = {
code: CheckoutErrorCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
private var configuration: ShopifyAcceleratedCheckouts.Configuration?
private weak var parentViewController: UIViewController?
internal var instance: AcceleratedCheckoutButtons?
private var checkoutIsActive = false

@objc var onSizeChange: RCTDirectEventBlock?

Expand Down Expand Up @@ -99,6 +100,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
}
}

@objc var linkAction: String = "open"
@objc var onDismiss: RCTDirectEventBlock?
@objc var onRenderStateChange: RCTBubblingEventBlock?
@objc var onDispatch: RCTDirectEventBlock?
Expand Down Expand Up @@ -223,9 +225,17 @@ class RCTAcceleratedCheckoutButtonsView: UIView {

private func attachEventListeners(to buttons: AcceleratedCheckoutButtons) -> AcceleratedCheckoutButtons {
return buttons
.onStart { [weak self] event in self?.dispatchCheckout(.start, checkout: event.checkout) }
.onStart { [weak self] event in
self?.checkoutIsActive = true
self?.dispatchCheckout(.start, checkout: event.checkout)
}
.onUpdate { [weak self] event in self?.dispatchCheckout(.update, checkout: event.checkout) }
.onComplete { [weak self] event in self?.dispatchCheckout(.complete, checkout: event.checkout) }
.onLinkClick { [weak self] link in
Comment thread
markmur marked this conversation as resolved.
guard let self, self.checkoutIsActive else { return .cancel }
self.dispatchEvent(.linkClick, payload: ShopifyEventSerialization.serialize(clickEvent: link.url))
return checkoutLinkAction(self.linkAction)
}
.onFail { [weak self] error in
self?.handleCheckoutFailed(error)
}
Expand Down Expand Up @@ -324,10 +334,12 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
// MARK: - Event Handlers

private func handleCheckoutFailed(_ error: CheckoutError) {
checkoutIsActive = false
dispatchEvent(.fail, payload: ["error": ShopifyEventSerialization.serialize(checkoutError: error)])
}

private func handleCheckoutDismissed() {
checkoutIsActive = false
onDismiss?([:])
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,24 @@ func checkoutEventJSON(type: DispatchEventType, checkout: Checkout) -> String? {
}
}

func checkoutLinkAction(_ value: String) -> CheckoutLinkAction {
switch value {
case "handled": return .handled
case "cancel": return .cancel
default: return .open
}
}

/// A presentation retains its own delegate so queued events cannot cross sessions.
@MainActor
final class CheckoutEventBridge: CheckoutDelegate {
private var dispatch: ((String) -> Void)?
private let onTerminal: (CheckoutEventBridge) -> Void

init(dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) {
let linkAction: CheckoutLinkAction

init(linkAction: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) {
self.linkAction = checkoutLinkAction(linkAction)
self.dispatch = dispatch
self.onTerminal = onTerminal
}
Expand All @@ -58,6 +69,12 @@ final class CheckoutEventBridge: CheckoutDelegate {
finish(.dismiss)
}

func checkoutAction(for link: CheckoutLink) -> CheckoutLinkAction {
guard dispatch != nil else { return .cancel }
emit(.linkClick, payload: ShopifyEventSerialization.serialize(clickEvent: link.url))
return linkAction
}

private func emit(_ type: DispatchEventType, checkout: Checkout) {
guard let json = checkoutEventJSON(type: type, checkout: checkout) else { return }
dispatch?(json)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe
RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration)

RCT_EXTERN_METHOD(present:(NSString *)checkoutURL
linkAction:(NSString *)linkAction
onResult:(RCTResponseSenderBlock)onResult)

RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL
Expand Down Expand Up @@ -126,6 +127,8 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag
*/
RCT_EXPORT_VIEW_PROPERTY(applePayStyle, NSString*)

RCT_EXPORT_VIEW_PROPERTY(linkAction, NSString*)

// React Native reserves topDismiss as a direct event.
RCT_EXPORT_VIEW_PROPERTY(onDismiss, RCTDirectEventBlock)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum DispatchEventType: String, CaseIterable {
case update
case complete
case dismiss
case linkClick
case fail
case geolocationRequest
}
Expand Down Expand Up @@ -114,7 +115,7 @@ class RCTShopifyCheckoutKit: NSObject {
}
}

@objc func present(_ checkoutURL: String, onResult: @escaping RCTResponseSenderBlock) {
@objc func present(_ checkoutURL: String, linkAction: String, onResult: @escaping RCTResponseSenderBlock) {
DispatchQueue.main.async {
// Preserve the active session and ignore attempts during dismissal.
guard self.checkoutEvents == nil else {
Expand All @@ -128,7 +129,7 @@ class RCTShopifyCheckoutKit: NSObject {
return
}
self.closingCheckoutSheet = nil
let events = CheckoutEventBridge(dispatch: { [weak self] json in
let events = CheckoutEventBridge(linkAction: linkAction, dispatch: { [weak self] json in
self?.emitDispatchEvent(json)
}, onTerminal: { [weak self] ended in
guard let self, self.checkoutEvents === ended else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface CheckoutCompleteEvent {
export interface CheckoutFailureEvent {
error: CheckoutException;
}
export interface CheckoutLink {
url: string;
}
export type CheckoutLinkAction = 'open' | 'handled' | 'cancel';

/** Lifecycle callbacks shared by checkout sheets and accelerated buttons. */
export interface CheckoutEventHandlers {
onStart?: (event: CheckoutStartEvent) => void;
Expand All @@ -60,6 +65,10 @@ export interface CheckoutEventHandlers {
onComplete?: (event: CheckoutCompleteEvent) => void;
onFail?: (event: CheckoutFailureEvent) => void;
onDismiss?: () => void;
/** Notification only; use linkAction to choose the native response. */
onLinkClick?: (link: CheckoutLink) => void;
/** Native link policy, chosen before presentation. Defaults to open. */
linkAction?: CheckoutLinkAction;
}

export function decodeCheckout(value: unknown): Checkout {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export const AcceleratedCheckoutButtons: React.FC<
onUpdate,
onComplete,
onRenderStateChange,
onLinkClick,
linkAction = 'open',
...props
}) => {
const isCart = isCartProps(props);
Expand Down Expand Up @@ -170,9 +172,9 @@ export const AcceleratedCheckoutButtons: React.FC<
const {dispatcher} = useMemo(
() =>
createPresentDispatcher({
callbacks: {onStart, onUpdate, onComplete, onFail},
callbacks: {onStart, onUpdate, onComplete, onFail, onLinkClick},
}),
[onStart, onUpdate, onComplete, onFail],
[onStart, onUpdate, onComplete, onFail, onLinkClick],
);

const handleDispatch = useCallback(
Expand Down Expand Up @@ -239,6 +241,7 @@ export const AcceleratedCheckoutButtons: React.FC<
cornerRadius={cornerRadius}
wallets={wallets}
onDismiss={handleDismiss}
linkAction={linkAction}
onRenderStateChange={handleRenderStateChange}
onDispatch={handleDispatch}
onSizeChange={handleSizeChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const SDK_LIFECYCLE_EVENT_TYPES = [
'update',
'complete',
'dismiss',
'linkClick',
'fail',
'geolocationRequest',
] as const;
Expand Down
Loading
Loading