diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java index b10798ac..c5b8ae88 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java @@ -24,6 +24,7 @@ of this software and associated documentation files (the "Software"), to deal package com.shopify.reactnative.checkoutsheetkit; import android.content.Context; +import android.net.Uri; import android.util.Log; import android.webkit.GeolocationPermissions; @@ -212,6 +213,17 @@ public void onSubmitStart(@NonNull CheckoutSubmitStartEvent event) { } } + @Override + public void onLinkClick(@NonNull Uri uri) { + try { + Map event = new HashMap<>(); + event.put("url", uri.toString()); + sendEventWithStringData("linkClick", mapper.writeValueAsString(event)); + } catch (IOException e) { + Log.e(TAG, "Error processing link click event", e); + } + } + // Private private Map populateErrorDetails(CheckoutException error) { Map errorMap = new HashMap<>(Map.of( diff --git a/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessorTest.java b/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessorTest.java index 099dc657..2dfb7499 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessorTest.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessorTest.java @@ -32,6 +32,7 @@ of this software and associated documentation files (the "Software"), to deal import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import android.content.Context; +import android.net.Uri; import android.util.Log; import android.webkit.GeolocationPermissions; @@ -439,6 +440,35 @@ public void testOnSubmitStart_withSerializationError_logsErrorAndDoesNotEmit() t mockedLog.verify(() -> Log.e(eq("SheetCheckoutEventProcessor"), eq("Error processing submit start event"), any(IOException.class))); } + // MARK: - onLinkClick Tests + + @Test + public void testOnLinkClick_emitsLinkClickEventWithUrl() { + Uri mockUri = mock(Uri.class); + when(mockUri.toString()).thenReturn("https://example.com/terms"); + + processor.onLinkClick(mockUri); + + verify(mockEventEmitter).emit(eventNameCaptor.capture(), eventDataCaptor.capture()); + assertThat(eventNameCaptor.getValue()).isEqualTo("linkClick"); + String eventData = eventDataCaptor.getValue(); + assertThat(eventData).contains("\"url\":\"https://example.com/terms\""); + } + + @Test + public void testOnLinkClick_withSerializationError_logsErrorAndDoesNotEmit() throws Exception { + ObjectMapper mockMapper = mock(ObjectMapper.class); + when(mockMapper.writeValueAsString(any())).thenThrow(new JsonProcessingException("Serialization failed") {}); + setPrivateField(processor, "mapper", mockMapper); + + Uri mockUri = mock(Uri.class); + when(mockUri.toString()).thenReturn("https://example.com/terms"); + processor.onLinkClick(mockUri); + + verify(mockEventEmitter, never()).emit(anyString(), anyString()); + mockedLog.verify(() -> Log.e(eq("SheetCheckoutEventProcessor"), eq("Error processing link click event"), any(IOException.class))); + } + // MARK: - Helper Methods private void setPrivateField(Object object, String fieldName, Object value) throws Exception { diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index f4a342f3..090df36d 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -52,6 +52,7 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { case paymentMethodChangeStart case start case submitStart + case linkClick } override init() { @@ -110,6 +111,10 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { } } + func checkoutDidClickLink(url: URL) { + emit(event: .linkClick, body: ["url": url.absoluteString]) + } + @objc override func constantsToExport() -> [AnyHashable: Any]! { return [ "version": ShopifyCheckoutSheetKit.version diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index d8279e6b..5a67cc34 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -154,16 +154,22 @@ export type CheckoutEvent = | 'error' | 'addressChangeStart' | 'submitStart' - | 'geolocationRequest'; + | 'geolocationRequest' + | 'linkClick'; export interface GeolocationRequestEvent { origin: string; } +export interface LinkClickEvent { + url: string; +} + export type CloseEventCallback = () => void; export type GeolocationRequestEventCallback = ( event: GeolocationRequestEvent, ) => void; +export type LinkClickEventCallback = (event: LinkClickEvent) => void; export type CheckoutExceptionCallback = (error: CheckoutException) => void; export type CheckoutCompleteEventCallback = ( event: CheckoutCompleteEvent, @@ -185,7 +191,8 @@ export type CheckoutEventCallback = | CheckoutStartEventCallback | CheckoutAddressChangeStartCallback | CheckoutSubmitStartCallback - | GeolocationRequestEventCallback; + | GeolocationRequestEventCallback + | LinkClickEventCallback; /** * Available wallet types for accelerated checkout @@ -283,6 +290,11 @@ function addEventListener( callback: GeolocationRequestEventCallback, ): Maybe; +function addEventListener( + event: 'linkClick', + callback: LinkClickEventCallback, +): Maybe; + function removeEventListeners(event: CheckoutEvent): void; export type AddEventListener = typeof addEventListener; diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 0aec42e4..23774627 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -43,6 +43,7 @@ import type { Configuration, Features, GeolocationRequestEvent, + LinkClickEvent, Maybe, ShopifyCheckoutSheetKit, } from './index.d'; @@ -208,6 +209,9 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { callback, ); break; + case 'linkClick': + eventCallback = this.interceptEventEmission('linkClick', callback); + break; default: eventCallback = callback; } @@ -475,6 +479,7 @@ export type { Configuration, Features, GeolocationRequestEvent, + LinkClickEvent, }; // Event types diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index fd6aea2d..5d186b79 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -458,6 +458,42 @@ describe('ShopifyCheckoutSheetKit', () => { }); }); + describe('LinkClick Event', () => { + it('parses linkClick event string data as JSON', () => { + const instance = new ShopifyCheckoutSheet(); + const callback = jest.fn(); + instance.addEventListener('linkClick', callback); + + eventEmitter.emit( + 'linkClick', + JSON.stringify({url: 'https://example.com/terms'}), + ); + expect(callback).toHaveBeenCalledWith({url: 'https://example.com/terms'}); + }); + + it('parses linkClick event JSON data', () => { + const instance = new ShopifyCheckoutSheet(); + const callback = jest.fn(); + instance.addEventListener('linkClick', callback); + + eventEmitter.emit('linkClick', {url: 'https://example.com/privacy'}); + expect(callback).toHaveBeenCalledWith({url: 'https://example.com/privacy'}); + }); + + it('prints an error if the linkClick event data cannot be parsed', () => { + const mock = jest.spyOn(global.console, 'error'); + const instance = new ShopifyCheckoutSheet(); + const callback = jest.fn(); + instance.addEventListener('linkClick', callback); + + eventEmitter.emit('linkClick', 'INVALID JSON'); + expect(mock).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + 'INVALID JSON', + ); + }); + }); + describe('Error Event', () => { const internalError = { __typename: CheckoutNativeErrorType.InternalError, diff --git a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift index 07156f02..ad1f69ef 100644 --- a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift +++ b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift @@ -250,6 +250,20 @@ class ShopifyCheckoutSheetKitTests: XCTestCase { XCTAssertTrue((mock.checkoutSheet as! MockCheckoutSheet).dismissWasCalled) } + /// checkoutDidClickLink + func testCheckoutDidClickLinkSendsEvent() { + let mock = mockSendEvent(eventName: "linkClick") + + mock.startObserving() + + let testURL = URL(string: "https://example.com/terms")! + mock.checkoutDidClickLink(url: testURL) + + XCTAssertTrue(mock.didSendEvent) + let body = mock.eventBody as? [String: String] + XCTAssertEqual(body?["url"], "https://example.com/terms") + } + /// CheckoutOptions parsing func testCanPresentCheckoutWithAuthenticationOptions() { let options: [AnyHashable: Any] = [ diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 88012044..c3edbb34 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -243,12 +243,17 @@ function AppWithContext({children}: PropsWithChildren) { }, ); + const linkClick = shopify.addEventListener('linkClick', event => { + console.log('[App] linkClick event received:', event.url); + }); + return () => { completed?.remove(); started?.remove(); addressChangeStart?.remove(); close?.remove(); error?.remove(); + linkClick?.remove(); }; }, [shopify, eventHandlers]);