diff --git a/README.md b/README.md index 5bf09b4b..571b09db 100644 --- a/README.md +++ b/README.md @@ -579,8 +579,8 @@ methods - available on both the context provider as well as the class instance. | Name | Callback | Description | | ----------- | ----------------------------------------- | ------------------------------------------------------------ | | `close` | `() => void` | Fired when the checkout has been closed. | -| `completed` | `(event: CheckoutCompletedEvent) => void` | Fired when the checkout has been successfully completed. | -| `started` | `(event: CheckoutStartedEvent) => void` | Fired when the checkout has been started. | +| `complete` | `(event: CheckoutCompleteEvent) => void` | Fired when the checkout has been successfully completed. | +| `start` | `(event: CheckoutStartEvent) => void` | Fired when the checkout has been started. | | `error` | `(error: {message: string}) => void` | Fired when a checkout exception has been raised. | ### `addEventListener(eventName, callback)` @@ -599,10 +599,10 @@ useEffect(() => { }); const completed = shopifyCheckout.addEventListener( - 'completed', - (event: CheckoutCompletedEvent) => { + 'complete', + (event: CheckoutCompleteEvent) => { // Lookup order on checkout completion - const orderId = event.orderDetails.id; + const orderId = event.orderConfirmation.order.id; }, ); 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 ea62c34f..ffdedcd8 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 @@ -36,6 +36,8 @@ of this software and associated documentation files (the "Software"), to deal import com.facebook.react.bridge.ReactApplicationContext; import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent; import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutStartEvent; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.HashMap; @@ -108,7 +110,7 @@ public void onGeolocationPermissionsHidePrompt() { } @Override - public void onCheckoutFailed(CheckoutException checkoutError) { + public void onFail(CheckoutException checkoutError) { try { String data = mapper.writeValueAsString(populateErrorDetails(checkoutError)); sendEventWithStringData("error", data); @@ -118,27 +120,49 @@ public void onCheckoutFailed(CheckoutException checkoutError) { } @Override - public void onCheckoutCanceled() { + public void onCancel() { sendEvent("close", null); } @Override - public void onCheckoutCompleted(@NonNull CheckoutCompleteEvent event) { + public void onComplete(@NonNull CheckoutCompleteEvent event) { try { String data = mapper.writeValueAsString(event); - sendEventWithStringData("completed", data); + sendEventWithStringData("complete", data); } catch (IOException e) { - Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e); + Log.e("ShopifyCheckoutSheetKit", "Error processing complete event", e); } } @Override - public void onCheckoutStarted(@NonNull CheckoutStartEvent event) { + public void onStart(@NonNull CheckoutStartEvent event) { try { String data = mapper.writeValueAsString(event); - sendEventWithStringData("started", data); + sendEventWithStringData("start", data); } catch (IOException e) { - Log.e("ShopifyCheckoutSheetKit", "Error processing started event", e); + Log.e("ShopifyCheckoutSheetKit", "Error processing start event", e); + } + } + + @Override + public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) { + try { + CheckoutAddressChangeStartEvent params = event.getParams(); + if (params == null) { + Log.e("ShopifyCheckoutSheetKit", "Address change event has null params"); + return; + } + + Map eventData = new HashMap<>(); + eventData.put("id", event.getId()); + eventData.put("type", "addressChangeStart"); + eventData.put("addressType", params.getAddressType()); + eventData.put("cart", params.getCart()); + + String data = mapper.writeValueAsString(eventData); + sendEventWithStringData("addressChangeStart", data); + } catch (IOException e) { + Log.e("ShopifyCheckoutSheetKit", "Error processing address change start event", e); } } 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 c5d0f6b1..04a69685 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 @@ -175,7 +175,7 @@ public void initiateGeolocationRequest(Boolean allow) { private CheckoutOptions parseCheckoutOptions(ReadableMap options) { if (options == null) { - return null; + return new CheckoutOptions(); } // Parse authentication @@ -184,12 +184,12 @@ private CheckoutOptions parseCheckoutOptions(ReadableMap options) { if (authMap != null && authMap.hasKey("token")) { String token = authMap.getString("token"); if (token != null) { - return new CheckoutOptions(token); + return new CheckoutOptions(new Authentication.Token(token)); } } } - return null; + return new CheckoutOptions(); } private ColorScheme getColorScheme(String colorScheme) { diff --git a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift index 6177063f..f9c5d47a 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift @@ -79,11 +79,12 @@ class RCTCheckoutWebView: UIView { } } @objc var onLoad: RCTDirectEventBlock? + @objc var onStart: RCTBubblingEventBlock? @objc var onError: RCTBubblingEventBlock? @objc var onComplete: RCTBubblingEventBlock? @objc var onCancel: RCTBubblingEventBlock? @objc var onClickLink: RCTBubblingEventBlock? - @objc var onAddressChangeIntent: RCTBubblingEventBlock? + @objc var onAddressChangeStart: RCTBubblingEventBlock? @objc var onPaymentChangeIntent: RCTBubblingEventBlock? override init(frame: CGRect) { @@ -209,7 +210,7 @@ class RCTCheckoutWebView: UIView { try event.respondWith(json: responseData) print("[CheckoutWebView] Successfully responded to event: \(id)") self.events.remove(key: id) - } catch let error as EventResponseError { + } catch let error as CheckoutEventResponseError { print("[CheckoutWebView] Event response error: \(error)") handleEventError(eventId: id, error: error) } catch { @@ -222,7 +223,7 @@ class RCTCheckoutWebView: UIView { let errorMessage: String let errorCode: String - if let eventError = error as? EventResponseError { + if let eventError = error as? CheckoutEventResponseError { switch eventError { case .invalidEncoding: errorMessage = "Invalid response data encoding" @@ -257,6 +258,10 @@ class RCTCheckoutWebView: UIView { } extension RCTCheckoutWebView: CheckoutDelegate { + func checkoutDidStart(event: CheckoutStartEvent) { + onStart?(ShopifyEventSerialization.serialize(checkoutStartEvent: event)) + } + func checkoutDidComplete(event: CheckoutCompletedEvent) { onComplete?(ShopifyEventSerialization.serialize(checkoutCompletedEvent: event)) } @@ -277,15 +282,26 @@ extension RCTCheckoutWebView: CheckoutDelegate { error.isRecoverable } - func checkoutDidRequestAddressChange(event: AddressChangeRequested) { + /// Called when checkout starts an address change flow. + /// This method stores the event for later response and emits it to the React Native layer. + /// + /// - Parameter event: The address change start event containing: + /// - id: Unique identifier for responding to the event + /// - addressType: Type of address being changed ("shipping" or "billing") + /// - cart: Current cart state + func checkoutDidStartAddressChange(event: CheckoutAddressChangeStart) { guard let id = event.id else { return } self.events.set(key: id, event: event) - onAddressChangeIntent?([ + // Serialize the cart to JSON + let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) + + onAddressChangeStart?([ "id": event.id, - "type": "addressChangeIntent", + "type": "addressChangeStart", "addressType": event.params.addressType, + "cart": cartJSON, ]) } diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm index a1de5477..6464eb82 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm @@ -119,9 +119,9 @@ @interface RCT_EXTERN_MODULE (RCTCheckoutWebViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(onCancel, RCTBubblingEventBlock) /** - * Emitted when checkout is moving to address selection screen + * Emitted when checkout starts an address change flow */ - RCT_EXPORT_VIEW_PROPERTY(onAddressChangeIntent, RCTBubblingEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onAddressChangeStart, RCTBubblingEventBlock) /** * Emitted when checkout is moving to payment selection screen diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index 90e371d5..7a307b7c 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -53,7 +53,7 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { } override func supportedEvents() -> [String]! { - return ["close", "completed", "started", "error", "addressChangeIntent"] + return ["close", "complete", "start", "error", "addressChangeStart"] } override func startObserving() { @@ -66,13 +66,13 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { func checkoutDidComplete(event: CheckoutCompletedEvent) { if hasListeners { - sendEvent(withName: "completed", body: ShopifyEventSerialization.serialize(checkoutCompletedEvent: event)) + sendEvent(withName: "complete", body: ShopifyEventSerialization.serialize(checkoutCompletedEvent: event)) } } func checkoutDidStart(event: CheckoutStartEvent) { if hasListeners { - sendEvent(withName: "started", body: ShopifyEventSerialization.serialize(checkoutStartEvent: event)) + sendEvent(withName: "start", body: ShopifyEventSerialization.serialize(checkoutStartEvent: event)) } } diff --git a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx index 6365e4b5..55e75838 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx @@ -26,7 +26,7 @@ import {requireNativeComponent, Platform} from 'react-native'; import type {ViewStyle} from 'react-native'; import type { AcceleratedCheckoutWallet, - CheckoutCompletedEvent, + CheckoutCompleteEvent, CheckoutException, } from '..'; @@ -94,7 +94,7 @@ interface CommonAcceleratedCheckoutButtonsProps { /** * Called when checkout is completed successfully */ - onComplete?: (event: CheckoutCompletedEvent) => void; + onComplete?: (event: CheckoutCompleteEvent) => void; /** * Called when checkout is cancelled @@ -147,7 +147,7 @@ interface NativeAcceleratedCheckoutButtonsProps { cornerRadius?: number; wallets?: AcceleratedCheckoutWallet[]; onFail?: (event: {nativeEvent: CheckoutException}) => void; - onComplete?: (event: {nativeEvent: CheckoutCompletedEvent}) => void; + onComplete?: (event: {nativeEvent: CheckoutCompleteEvent}) => void; onCancel?: () => void; onRenderStateChange?: (event: { nativeEvent: {state: string; reason?: string | undefined}; @@ -209,7 +209,7 @@ export const AcceleratedCheckoutButtons: React.FC< ); const handleComplete = useCallback( - (event: {nativeEvent: CheckoutCompletedEvent}) => { + (event: {nativeEvent: CheckoutCompleteEvent}) => { onComplete?.(event.nativeEvent); }, [onComplete], diff --git a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx index b7185b47..a6d973e7 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx @@ -32,11 +32,11 @@ import { } from 'react-native'; import type {ViewStyle} from 'react-native'; import type { - CheckoutCompletedEvent, + CheckoutCompleteEvent, CheckoutException, } from '..'; import {useCheckoutEvents} from '../CheckoutEventProvider'; -import type {CheckoutAddressChangeIntent, CheckoutPaymentChangeIntent} from '../events'; +import type {CheckoutAddressChangeStart, CheckoutPaymentChangeIntent, CheckoutStartEvent} from '../events'; export interface CheckoutProps { /** @@ -54,6 +54,11 @@ export interface CheckoutProps { */ onLoad?: (event: {url: string}) => void; + /** + * Called when checkout starts, providing the initial cart state + */ + onStart?: (event: CheckoutStartEvent) => void; + /** * Called when checkout fails */ @@ -62,7 +67,7 @@ export interface CheckoutProps { /** * Called when checkout is completed successfully */ - onComplete?: (event: CheckoutCompletedEvent) => void; + onComplete?: (event: CheckoutCompleteEvent) => void; /** * Called when checkout is cancelled @@ -75,9 +80,12 @@ export interface CheckoutProps { onClickLink?: (url: string) => void; /** - * Called when checkout requests an address change (e.g., for native address picker) + * Called when checkout starts an address change flow (e.g., for native address picker). + * + * Note: This callback is only invoked when native address selection is enabled + * for the authenticated app. */ - onAddressChangeIntent?: (event: CheckoutAddressChangeIntent) => void; + onAddressChangeStart?: (event: CheckoutAddressChangeStart) => void; /** * Called when checkout requests a payment method change (e.g., for native payment selector) @@ -88,6 +96,11 @@ export interface CheckoutProps { * Style for the webview container */ style?: ViewStyle; + + /** + * Test identifier for testing + */ + testID?: string; } export interface CheckoutRef { @@ -101,18 +114,14 @@ interface NativeCheckoutWebViewProps { checkoutUrl: string; auth?: string; style?: ViewStyle; + testID?: string; onLoad?: (event: {nativeEvent: {url: string}}) => void; + onStart?: (event: {nativeEvent: CheckoutStartEvent}) => void; onError?: (event: {nativeEvent: CheckoutException}) => void; - onComplete?: (event: {nativeEvent: CheckoutCompletedEvent}) => void; + onComplete?: (event: {nativeEvent: CheckoutCompleteEvent}) => void; onCancel?: () => void; onClickLink?: (event: {nativeEvent: {url: string}}) => void; - onAddressChangeIntent?: (event: { - nativeEvent: { - id: string; - type: string; - addressType: string; - }; - }) => void; + onAddressChangeStart?: (event: {nativeEvent: CheckoutAddressChangeStart}) => void; onPaymentChangeIntent?: (event: { nativeEvent: { id: string; @@ -175,13 +184,15 @@ export const Checkout = forwardRef( checkoutUrl, auth, onLoad, + onStart, onError, onComplete, onCancel, onClickLink, - onAddressChangeIntent, + onAddressChangeStart, onPaymentChangeIntent, style, + testID, }, ref, ) => { @@ -207,6 +218,16 @@ export const Checkout = forwardRef( [onLoad], ); + const handleStart = useCallback< + Required['onStart'] + >( + (event: {nativeEvent: CheckoutStartEvent}) => { + console.log('[Checkout] onStart:', event.nativeEvent); + onStart?.(event.nativeEvent); + }, + [onStart], + ); + const handleError = useCallback< Required['onError'] >( @@ -216,14 +237,14 @@ export const Checkout = forwardRef( [onError], ); - const handleComplete = useCallback< - Required['onComplete'] - >( - (event: {nativeEvent: CheckoutCompletedEvent}) => { - onComplete?.(event.nativeEvent); - }, - [onComplete], - ); + const handleComplete = useCallback< + Required['onComplete'] + >( + (event: {nativeEvent: CheckoutCompleteEvent}) => { + onComplete?.(event.nativeEvent); + }, + [onComplete], + ); const handleCancel = useCallback< Required['onCancel'] @@ -241,16 +262,14 @@ export const Checkout = forwardRef( [onClickLink], ); - const handleAddressChangeIntent = useCallback< - Required['onAddressChangeIntent'] + const handleAddressChangeStart = useCallback< + Required['onAddressChangeStart'] >( - (event: { - nativeEvent: {id: string; type: string; addressType: string}; - }) => { + (event: {nativeEvent: CheckoutAddressChangeStart}) => { if (!event.nativeEvent) return; - onAddressChangeIntent?.(event.nativeEvent); + onAddressChangeStart?.(event.nativeEvent); }, - [onAddressChangeIntent], + [onAddressChangeStart], ); const handlePaymentChangeIntent = useCallback< @@ -301,12 +320,14 @@ export const Checkout = forwardRef( checkoutUrl={checkoutUrl} auth={auth} style={style} + testID={testID} onLoad={handleLoad} + onStart={handleStart} onError={handleError} onComplete={handleComplete} onCancel={handleCancel} onClickLink={handleClickLink} - onAddressChangeIntent={handleAddressChangeIntent} + onAddressChangeStart={handleAddressChangeStart} onPaymentChangeIntent={handlePaymentChangeIntent} /> ); diff --git a/modules/@shopify/checkout-sheet-kit/src/events.d.ts b/modules/@shopify/checkout-sheet-kit/src/events.d.ts index 77698c64..17643f2e 100644 --- a/modules/@shopify/checkout-sheet-kit/src/events.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/events.d.ts @@ -21,202 +21,337 @@ 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. */ -namespace CheckoutCompletedEvent { - export interface OrderConfirmation { - url?: string; - order: Order; - number?: string; - isFirstOrder: boolean; - } +/** + * Cart state from checkout events. + * Represents the current state of the buyer's cart including items, costs, delivery, and buyer identity. + */ +export interface Cart { + id: string; + lines: CartLine[]; + cost: CartCost; + buyerIdentity: CartBuyerIdentity; + deliveryGroups: CartDeliveryGroup[]; + discountCodes: CartDiscountCode[]; + appliedGiftCards: AppliedGiftCard[]; + discountAllocations: CartDiscountAllocation[]; + delivery: CartDelivery; +} - interface Order { - id: string; - } +export interface CartLine { + id: string; + quantity: number; + merchandise: CartLineMerchandise; + cost: CartLineCost; + discountAllocations: CartDiscountAllocation[]; +} - export interface Cart { - id: string; - lines: CartLine[]; - cost: CartCost; - buyerIdentity: CartBuyerIdentity; - deliveryGroups: CartDeliveryGroup[]; - discountCodes: CartDiscountCode[]; - appliedGiftCards: AppliedGiftCard[]; - discountAllocations: CartDiscountAllocation[]; - delivery: CartDelivery; - } +export interface CartLineCost { + amountPerQuantity: Money; + subtotalAmount: Money; + totalAmount: Money; +} - interface CartLine { - id: string; - quantity: number; - merchandise: CartLineMerchandise; - cost: CartLineCost; - discountAllocations: CartDiscountAllocation[]; - } +export interface CartLineMerchandise { + id: string; + title: string; + product: Product; + image?: MerchandiseImage; + selectedOptions: SelectedOption[]; +} - interface CartLineCost { - amountPerQuantity: Money; - subtotalAmount: Money; - totalAmount: Money; - } +export interface Product { + id: string; + title: string; +} - interface CartLineMerchandise { - id: string; - title: string; - product: Product; - image?: MerchandiseImage; - selectedOptions: SelectedOption[]; - } +export interface MerchandiseImage { + url: string; + altText?: string; +} - interface Product { - id: string; - title: string; - } +export interface SelectedOption { + name: string; + value: string; +} - interface MerchandiseImage { - url: string; - altText?: string; - } +export interface CartDiscountAllocation { + discountedAmount: Money; + discountApplication: DiscountApplication; + targetType: DiscountApplicationTargetType; +} - interface SelectedOption { - name: string; - value: string; - } +export interface DiscountApplication { + allocationMethod: AllocationMethod; + targetSelection: TargetSelection; + targetType: DiscountApplicationTargetType; + value: DiscountValue; +} - interface CartDiscountAllocation { - discountedAmount: Money; - discountApplication: DiscountApplication; - targetType: DiscountApplicationTargetType; - } +export type AllocationMethod = 'ACROSS' | 'EACH'; +export type TargetSelection = 'ALL' | 'ENTITLED' | 'EXPLICIT'; +export type DiscountApplicationTargetType = 'LINE_ITEM' | 'SHIPPING_LINE'; - interface DiscountApplication { - allocationMethod: AllocationMethod; - targetSelection: TargetSelection; - targetType: DiscountApplicationTargetType; - value: DiscountValue; - } +export interface CartCost { + subtotalAmount: Money; + totalAmount: Money; +} - type AllocationMethod = 'ACROSS' | 'EACH'; - type TargetSelection = 'ALL' | 'ENTITLED' | 'EXPLICIT'; - type DiscountApplicationTargetType = 'LINE_ITEM' | 'SHIPPING_LINE'; +export interface CartBuyerIdentity { + email?: string; + phone?: string; + customer?: Customer; + countryCode?: string; +} - interface CartCost { - subtotalAmount: Money; - totalAmount: Money; - } +export interface Customer { + id?: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; +} - interface CartBuyerIdentity { - email?: string; - phone?: string; - customer?: Customer; - countryCode?: string; - } +export interface CartDeliveryGroup { + deliveryAddress: MailingAddress; + deliveryOptions: CartDeliveryOption[]; + selectedDeliveryOption?: CartDeliveryOption; + groupType: CartDeliveryGroupType; +} - interface Customer { - id?: string; - firstName?: string; - lastName?: string; - email?: string; - phone?: string; - } +export interface MailingAddress { + address1?: string; + address2?: string; + city?: string; + province?: string; + country?: string; + countryCodeV2?: string; + zip?: string; + firstName?: string; + lastName?: string; + phone?: string; + company?: string; +} - interface CartDeliveryGroup { - deliveryAddress: MailingAddress; - deliveryOptions: CartDeliveryOption[]; - selectedDeliveryOption?: CartDeliveryOption; - groupType: CartDeliveryGroupType; - } +export interface CartDeliveryOption { + code?: string; + title?: string; + description?: string; + handle: string; + estimatedCost: Money; + deliveryMethodType: CartDeliveryMethodType; +} - interface MailingAddress { - address1?: string; - address2?: string; - city?: string; - province?: string; - country?: string; - countryCodeV2?: string; - zip?: string; - firstName?: string; - lastName?: string; - phone?: string; - company?: string; - } +export type CartDeliveryMethodType = 'SHIPPING' | 'PICKUP' | 'PICKUP_POINT' | 'LOCAL' | 'NONE'; +export type CartDeliveryGroupType = 'SUBSCRIPTION' | 'ONE_TIME_PURCHASE'; - interface CartDeliveryOption { - code?: string; - title?: string; - description?: string; - handle: string; - estimatedCost: Money; - deliveryMethodType: CartDeliveryMethodType; - } +export interface CartDelivery { + addresses: CartSelectableAddress[]; +} - type CartDeliveryMethodType = 'SHIPPING' | 'PICKUP' | 'PICKUP_POINT' | 'LOCAL' | 'NONE'; - type CartDeliveryGroupType = 'SUBSCRIPTION' | 'ONE_TIME_PURCHASE'; +export interface CartSelectableAddress { + address: CartAddress; +} - interface CartDelivery { - addresses: CartSelectableAddress[]; - } +/** + * A delivery address of the buyer that is interacting with the cart. + * This is a union type to support future address types + * Currently only CartDeliveryAddress is supported. + */ +export type CartAddress = CartDeliveryAddress; + +export interface CartDeliveryAddress { + address1?: string; + address2?: string; + city?: string; + company?: string; + countryCode?: string; + firstName?: string; + lastName?: string; + phone?: string; + provinceCode?: string; + zip?: string; +} - interface CartSelectableAddress { - address: CartAddress; - } +export interface CartDiscountCode { + code: string; + applicable: boolean; +} - /** - * A delivery address of the buyer that is interacting with the cart. - * This is a union type to support future address types - * Currently only CartDeliveryAddress is supported. - */ - type CartAddress = CartDeliveryAddress; +export interface AppliedGiftCard { + amountUsed: Money; + balance: Money; + lastCharacters: string; + presentmentAmountUsed: Money; +} - interface CartDeliveryAddress { - address1?: string; - address2?: string; - city?: string; - company?: string; - countryCode?: string; - firstName?: string; - lastName?: string; - phone?: string; - provinceCode?: string; - zip?: string; - } +export interface Money { + amount: string; + currencyCode: string; +} - interface CartDiscountCode { - code: string; - applicable: boolean; - } +export interface PricingPercentageValue { + percentage: number; +} - interface AppliedGiftCard { - amountUsed: Money; - balance: Money; - lastCharacters: string; - presentmentAmountUsed: Money; - } +export type DiscountValue = Money | PricingPercentageValue; - interface Money { - amount: string; - currencyCode: string; +namespace CheckoutCompleteEvent { + export interface OrderConfirmation { + url?: string; + order: Order; + number?: string; + isFirstOrder: boolean; } - interface PricingPercentageValue { - percentage: number; + interface Order { + id: string; } +} - type DiscountValue = Money | PricingPercentageValue; +export interface CheckoutCompleteEvent { + orderConfirmation: CheckoutCompleteEvent.OrderConfirmation; + cart: Cart; } -export interface CheckoutCompletedEvent { - orderConfirmation: CheckoutCompletedEvent.OrderConfirmation; - cart: CheckoutCompletedEvent.Cart; +export interface CheckoutStartEvent { + cart: Cart; } -export interface CheckoutStartedEvent { - cart: CheckoutCompletedEvent.Cart; +/** + * Error object returned in checkout event responses. + * Used to communicate validation or processing errors back to checkout. + */ +export interface CheckoutResponseError { + /** + * Human-readable error message. + */ + message: string; + /** + * Additional error properties (e.g., error code, field name, etc.) + */ + [key: string]: any; +} + +/** + * Cart input for updating cart state via checkout events. + * Mirrors the Storefront API CartInput structure. + * + * @see https://shopify.dev/docs/api/storefront/latest/input-objects/CartInput + */ +export interface CartInput { + /** + * Delivery-related fields for the cart. + */ + delivery?: { + /** + * Array of selectable addresses presented to the buyer. + */ + addresses?: Array<{ + /** + * The delivery address details. + */ + address: { + /** First line of the address (street address or PO Box) */ + address1?: string; + /** Second line of the address (apartment, suite, unit) */ + address2?: string; + /** City, district, village, or town */ + city?: string; + /** Company or organization name */ + company?: string; + /** + * Two-letter country code - REQUIRED + * Must be exactly 2 characters (ISO 3166-1 alpha-2 format) + * Examples: "US", "CA", "GB", "AU" + */ + countryCode: string; + /** First name of the customer */ + firstName?: string; + /** Last name of the customer */ + lastName?: string; + /** Phone number (E.164 format recommended, e.g., +16135551111) */ + phone?: string; + /** Province/state code (e.g., "CA", "ON") */ + provinceCode?: string; + /** Zip or postal code */ + zip?: string; + }; + /** + * Whether this address is selected as the active delivery address. + * Optional - use to pre-select an address from multiple options. + */ + selected?: boolean; + }>; + }; + /** + * The customer associated with the cart. + * Optional - use to update buyer identity information. + */ + buyerIdentity?: { + email?: string; + phone?: string; + countryCode?: string; + }; + /** + * Case-insensitive discount codes. + * Optional - use to apply discount codes to the cart. + */ + discountCodes?: string[]; } -export interface CheckoutAddressChangeIntent { +/** + * Event emitted when checkout starts an address change flow. + * + * This event is only emitted when native address selection is enabled + * for the authenticated app in the Shopify admin settings. + */ +export interface CheckoutAddressChangeStart { + /** + * Unique identifier for this event instance. + * Use this ID with the CheckoutEventProvider to respond to the event. + */ id: string; - type: string; - addressType: string; + + /** + * The event type identifier + */ + type: 'addressChangeStart'; + + /** + * The type of address being changed. + * - "shipping": The buyer is changing their shipping address + * - "billing": The buyer is changing their billing address + */ + addressType: 'shipping' | 'billing'; + + /** + * The current cart state at the time of the address change request. + * This provides context about the cart contents, buyer identity, and current delivery options. + */ + cart: Cart; +} + +/** + * Response payload for CheckoutAddressChangeStart event. + * Use with CheckoutEventProvider.respondToEvent() or useShopifyEvent().respondWith() + * + * Note: This response is only used when native address selection is enabled + * for the authenticated app in the Shopify admin settings. + * + * Validation requirements: + * - cart.delivery.addresses must contain at least one address + * - Each address must include a countryCode + * - countryCode must be exactly 2 characters (ISO 3166-1 alpha-2 format) + */ +export interface CheckoutAddressChangeStartResponse { + /** + * Updated cart input with delivery addresses and optional buyer identity. + */ + cart?: CartInput; + /** + * Optional array of errors if the address selection failed. + */ + errors?: CheckoutResponseError[]; } export interface CheckoutPaymentChangeIntent { diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index 02fa9cc9..a3ad70bb 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -22,7 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO */ import type {EmitterSubscription} from 'react-native'; -import type {CheckoutCompletedEvent, CheckoutStartedEvent} from './events'; +import type {CheckoutCompleteEvent, CheckoutStartEvent} from './events'; import type {CheckoutException} from './errors'; export type Maybe = T | undefined; @@ -149,8 +149,8 @@ export type Configuration = CommonConfiguration & { export type CheckoutEvent = | 'close' - | 'completed' - | 'started' + | 'complete' + | 'start' | 'error' | 'geolocationRequest'; @@ -163,18 +163,18 @@ export type GeolocationRequestEventCallback = ( event: GeolocationRequestEvent, ) => void; export type CheckoutExceptionCallback = (error: CheckoutException) => void; -export type CheckoutCompletedEventCallback = ( - event: CheckoutCompletedEvent, +export type CheckoutCompleteEventCallback = ( + event: CheckoutCompleteEvent, ) => void; -export type CheckoutStartedEventCallback = ( - event: CheckoutStartedEvent, +export type CheckoutStartEventCallback = ( + event: CheckoutStartEvent, ) => void; export type CheckoutEventCallback = | CloseEventCallback | CheckoutExceptionCallback - | CheckoutCompletedEventCallback - | CheckoutStartedEventCallback + | CheckoutCompleteEventCallback + | CheckoutStartEventCallback | GeolocationRequestEventCallback; /** @@ -244,13 +244,13 @@ function addEventListener( ): Maybe; function addEventListener( - event: 'completed', - callback: CheckoutCompletedEventCallback, + event: 'complete', + callback: CheckoutCompleteEventCallback, ): Maybe; function addEventListener( - event: 'started', - callback: CheckoutStartedEventCallback, + event: 'start', + callback: CheckoutStartEventCallback, ): Maybe; function addEventListener( diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index e0ce1bf4..1ec6dc37 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -58,7 +58,6 @@ import { GenericError, } from './errors.d'; import {CheckoutErrorCode} from './errors.d'; -import type {CheckoutCompletedEvent, CheckoutStartedEvent} from './events.d'; import {ApplePayLabel} from './components/AcceleratedCheckoutButtons'; import type { AcceleratedCheckoutButtonsProps, @@ -184,11 +183,11 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { let eventCallback; switch (event) { - case 'completed': - eventCallback = this.interceptEventEmission('completed', callback); + case 'complete': + eventCallback = this.interceptEventEmission('complete', callback); break; - case 'started': - eventCallback = this.interceptEventEmission('started', callback); + case 'start': + eventCallback = this.interceptEventEmission('start', callback); break; case 'error': eventCallback = this.interceptEventEmission( @@ -488,8 +487,6 @@ export type { AcceleratedCheckoutButtonsProps, AcceleratedCheckoutConfiguration, CheckoutAuthentication, - CheckoutCompletedEvent, - CheckoutStartedEvent, CheckoutEvent, CheckoutEventCallback, CheckoutException, @@ -500,6 +497,18 @@ export type { RenderStateChangeEvent, }; +// Event types +export type { + Cart, + CartInput, + CheckoutAddressChangeStart, + CheckoutAddressChangeStartResponse, + CheckoutCompleteEvent, + CheckoutPaymentChangeIntent, + CheckoutResponseError, + CheckoutStartEvent, +} from './events.d'; + // Components export { AcceleratedCheckoutButtons, diff --git a/modules/@shopify/checkout-sheet-kit/tests/CheckoutAddressChange.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/CheckoutAddressChange.test.tsx new file mode 100644 index 00000000..e4496f93 --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/tests/CheckoutAddressChange.test.tsx @@ -0,0 +1,225 @@ +// Mock the native view component BEFORE imports +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + const React = jest.requireActual('react'); + + RN.UIManager.getViewManagerConfig = jest.fn(() => ({ + Commands: {}, + })); + + // Create mock component + const MockRCTCheckoutWebView = (props: any) => { + return React.createElement('View', props); + }; + + return Object.setPrototypeOf( + { + requireNativeComponent: jest.fn(() => MockRCTCheckoutWebView), + }, + RN, + ); +}); + +import React from 'react'; +import {render, act} from '@testing-library/react-native'; +import {Checkout} from '../src/components/Checkout'; +import {createTestCart} from './testFixtures'; + +describe('Checkout Component - Address Change Events', () => { + const mockCheckoutUrl = 'https://example.myshopify.com/checkout'; + + it('calls onAddressChangeStart callback with complete event data', () => { + const onAddressChangeStart = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + const testCart = createTestCart(); + + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'test-event-123', + type: 'addressChangeStart', + addressType: 'shipping', + cart: testCart, + }, + }); + }); + + expect(onAddressChangeStart).toHaveBeenCalledTimes(1); + expect(onAddressChangeStart).toHaveBeenCalledWith({ + id: 'test-event-123', + type: 'addressChangeStart', + addressType: 'shipping', + cart: testCart, + }); + }); + + it('does not crash when onAddressChangeStart prop is not provided', () => { + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + expect(() => { + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'test-event', + type: 'addressChangeStart', + addressType: 'shipping', + cart: createTestCart(), + }, + }); + }); + }).not.toThrow(); + }); + + it('does not call callback when nativeEvent is missing', () => { + const onAddressChangeStart = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onAddressChangeStart({}); + }); + + expect(onAddressChangeStart).not.toHaveBeenCalled(); + }); + + it('works alongside other checkout callbacks', () => { + const onComplete = jest.fn(); + const onCancel = jest.fn(); + const onAddressChangeStart = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'event-123', + type: 'addressChangeStart', + addressType: 'shipping', + cart: createTestCart(), + }, + }); + }); + + expect(onAddressChangeStart).toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('includes cart data in the event', () => { + const onAddressChangeStart = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + const testCart = createTestCart(); + + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'event-with-cart', + type: 'addressChangeStart', + addressType: 'shipping', + cart: testCart, + }, + }); + }); + + const receivedEvent = onAddressChangeStart.mock.calls[0][0]; + expect(receivedEvent.cart).toBeDefined(); + expect(receivedEvent.cart.id).toBe('gid://shopify/Cart/test-cart-123'); + expect(receivedEvent.cart.delivery).toBeDefined(); + expect(receivedEvent.cart.delivery.addresses).toHaveLength(1); + }); + + it('can be updated with new callback', () => { + const firstCallback = jest.fn(); + const secondCallback = jest.fn(); + + const {getByTestId, rerender} = render( + , + ); + + let nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'event-1', + type: 'addressChangeStart', + addressType: 'shipping', + cart: createTestCart(), + }, + }); + }); + + expect(firstCallback).toHaveBeenCalledTimes(1); + expect(secondCallback).not.toHaveBeenCalled(); + + rerender( + , + ); + + nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onAddressChangeStart({ + nativeEvent: { + id: 'event-2', + type: 'addressChangeStart', + addressType: 'shipping', + cart: createTestCart(), + }, + }); + }); + + expect(firstCallback).toHaveBeenCalledTimes(1); + expect(secondCallback).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index 1f8dd9ed..9985a77b 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -168,9 +168,9 @@ describe('ShopifyCheckoutSheetKit', () => { }); describe('Completed Event', () => { - it('parses completed event string data as JSON', () => { + it('parses complete event string data as JSON', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'completed'; + const eventName = 'complete'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -178,19 +178,19 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'completed', + 'complete', expect.any(Function), ); eventEmitter.emit( - 'completed', + 'complete', JSON.stringify({orderConfirmation: {order: {id: 'test-id'}}, cart: {}}), ); expect(callback).toHaveBeenCalledWith({orderConfirmation: {order: {id: 'test-id'}}, cart: {}}); }); - it('parses completed event JSON data', () => { + it('parses complete event JSON data', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'completed'; + const eventName = 'complete'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -198,16 +198,16 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'completed', + 'complete', expect.any(Function), ); - eventEmitter.emit('completed', {orderConfirmation: {order: {id: 'test-id'}}, cart: {}}); + eventEmitter.emit('complete', {orderConfirmation: {order: {id: 'test-id'}}, cart: {}}); expect(callback).toHaveBeenCalledWith({orderConfirmation: {order: {id: 'test-id'}}, cart: {}}); }); - it('parses completed event with realistic data structure', () => { + it('parses complete event with realistic data structure', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'completed'; + const eventName = 'complete'; const callback = jest.fn(); instance.addEventListener(eventName, callback); @@ -282,14 +282,14 @@ describe('ShopifyCheckoutSheetKit', () => { } }; - eventEmitter.emit('completed', realisticEvent); + eventEmitter.emit('complete', realisticEvent); expect(callback).toHaveBeenCalledWith(realisticEvent); }); - it('prints an error if the completed event data cannot be parsed', () => { + it('prints an error if the complete event data cannot be parsed', () => { const mock = jest.spyOn(global.console, 'error'); const instance = new ShopifyCheckoutSheet(); - const eventName = 'completed'; + const eventName = 'complete'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -297,11 +297,11 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'completed', + 'complete', expect.any(Function), ); const invalidData = 'INVALID JSON'; - eventEmitter.emit('completed', invalidData); + eventEmitter.emit('complete', invalidData); expect(mock).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), invalidData, @@ -310,9 +310,9 @@ describe('ShopifyCheckoutSheetKit', () => { }); describe('Started Event', () => { - it('parses started event string data as JSON', () => { + it('parses start event string data as JSON', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'started'; + const eventName = 'start'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -320,19 +320,19 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'started', + 'start', expect.any(Function), ); eventEmitter.emit( - 'started', + 'start', JSON.stringify({cart: {id: 'test-cart-id'}}), ); expect(callback).toHaveBeenCalledWith({cart: {id: 'test-cart-id'}}); }); - it('parses started event JSON data', () => { + it('parses start event JSON data', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'started'; + const eventName = 'start'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -340,16 +340,16 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'started', + 'start', expect.any(Function), ); - eventEmitter.emit('started', {cart: {id: 'test-cart-id'}}); + eventEmitter.emit('start', {cart: {id: 'test-cart-id'}}); expect(callback).toHaveBeenCalledWith({cart: {id: 'test-cart-id'}}); }); - it('parses started event with realistic cart data structure', () => { + it('parses start event with realistic cart data structure', () => { const instance = new ShopifyCheckoutSheet(); - const eventName = 'started'; + const eventName = 'start'; const callback = jest.fn(); instance.addEventListener(eventName, callback); @@ -395,14 +395,14 @@ describe('ShopifyCheckoutSheetKit', () => { } }; - eventEmitter.emit('started', JSON.stringify(realisticEvent)); + eventEmitter.emit('start', JSON.stringify(realisticEvent)); expect(callback).toHaveBeenCalledWith(realisticEvent); }); - it('prints an error if the started event data cannot be parsed', () => { + it('prints an error if the start event data cannot be parsed', () => { const mock = jest.spyOn(global.console, 'error'); const instance = new ShopifyCheckoutSheet(); - const eventName = 'started'; + const eventName = 'start'; const callback = jest.fn(); instance.addEventListener(eventName, callback); NativeModules.ShopifyCheckoutSheetKit.addEventListener( @@ -410,11 +410,11 @@ describe('ShopifyCheckoutSheetKit', () => { callback, ); expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'started', + 'start', expect.any(Function), ); const invalidData = 'INVALID JSON'; - eventEmitter.emit('started', invalidData); + eventEmitter.emit('start', invalidData); expect(mock).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), invalidData, diff --git a/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts b/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts new file mode 100644 index 00000000..ea1755de --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts @@ -0,0 +1,75 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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 type {Cart} from '../src/events.d'; + +/** + * Shared test fixtures for creating test objects. + */ + +/** + * Creates a test Cart instance with sensible defaults. + * + * @param overrides - Optional partial Cart object to override defaults + * @returns A Cart instance suitable for testing + * + * @example + * ```typescript + * const cart = createTestCart(); + * const cartWithCustomId = createTestCart({ id: 'custom-id' }); + * ``` + */ +export function createTestCart(overrides?: Partial): Cart { + return { + id: 'gid://shopify/Cart/test-cart-123', + lines: [], + cost: { + subtotalAmount: { + amount: '100.00', + currencyCode: 'USD', + }, + totalAmount: { + amount: '100.00', + currencyCode: 'USD', + }, + }, + buyerIdentity: {}, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [ + { + address: { + countryCode: 'US', + city: 'San Francisco', + provinceCode: 'CA', + }, + }, + ], + }, + ...overrides, + }; +} + diff --git a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java index a1714758..98d30661 100644 --- a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java +++ b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java @@ -1,5 +1,7 @@ package com.shopify.checkoutkitreactnative; +import static com.shopify.checkoutkitreactnative.TestFixtures.createTestCart; + import androidx.activity.ComponentActivity; import com.facebook.react.bridge.JavaOnlyMap; @@ -23,6 +25,8 @@ import com.shopify.checkoutsheetkit.lifecycleevents.CartDelivery; import com.shopify.checkoutsheetkit.lifecycleevents.Money; import com.shopify.checkoutsheetkit.lifecycleevents.OrderConfirmation; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent; import com.shopify.reactnative.checkoutsheetkit.ShopifyCheckoutSheetKitModule; import com.shopify.reactnative.checkoutsheetkit.CustomCheckoutEventProcessor; @@ -424,6 +428,87 @@ public void testCanProcessCheckoutStartedEvents() { .contains("cart-456"); } + @Test + public void testCanProcessCheckoutAddressChangeStartEvent() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + + // Create a mock CheckoutAddressChangeStart event + CheckoutAddressChangeStart addressChangeEvent = mock(CheckoutAddressChangeStart.class); + when(addressChangeEvent.getId()).thenReturn("address-event-123"); + + // Create a mock CheckoutAddressChangeStartEvent for params + CheckoutAddressChangeStartEvent mockParams = mock(CheckoutAddressChangeStartEvent.class); + when(mockParams.getAddressType()).thenReturn("shipping"); + + when(addressChangeEvent.getParams()).thenReturn(mockParams); + + processor.onCheckoutAddressChangeStart(addressChangeEvent); + + verify(mockEventEmitter).emit(eq("addressChangeStart"), stringCaptor.capture()); + + // Verify the JSON contains expected fields + String emittedJson = stringCaptor.getValue(); + assertThat(emittedJson) + .contains("address-event-123") + .contains("addressChangeStart") + .contains("shipping"); + } + + @Test + public void testCanProcessCheckoutAddressChangeStartForBillingAddress() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + + // Create a mock CheckoutAddressChangeStart event + CheckoutAddressChangeStart addressChangeEvent = mock(CheckoutAddressChangeStart.class); + when(addressChangeEvent.getId()).thenReturn("billing-event-456"); + + // Create a mock CheckoutAddressChangeStartEvent for params + CheckoutAddressChangeStartEvent mockParams = mock(CheckoutAddressChangeStartEvent.class); + when(mockParams.getAddressType()).thenReturn("billing"); + + when(addressChangeEvent.getParams()).thenReturn(mockParams); + + processor.onCheckoutAddressChangeStart(addressChangeEvent); + + verify(mockEventEmitter).emit(eq("addressChangeStart"), stringCaptor.capture()); + + // Verify the JSON contains billing address type + String emittedJson = stringCaptor.getValue(); + assertThat(emittedJson) + .contains("billing-event-456") + .contains("billing"); + } + + @Test + public void testCheckoutAddressChangeStartIncludesCartInPayload() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + + // Create a mock CheckoutAddressChangeStart event + CheckoutAddressChangeStart addressChangeEvent = mock(CheckoutAddressChangeStart.class); + when(addressChangeEvent.getId()).thenReturn("cart-test-event"); + + // Create a test Cart using shared fixture + Cart testCart = createTestCart(); + + // Create a mock CheckoutAddressChangeStartEvent for params + CheckoutAddressChangeStartEvent mockParams = mock(CheckoutAddressChangeStartEvent.class); + when(mockParams.getAddressType()).thenReturn("shipping"); + when(mockParams.getCart()).thenReturn(testCart); + + when(addressChangeEvent.getParams()).thenReturn(mockParams); + + processor.onCheckoutAddressChangeStart(addressChangeEvent); + + verify(mockEventEmitter).emit(eq("addressChangeStart"), stringCaptor.capture()); + + // Verify the JSON contains the cart + String emittedJson = stringCaptor.getValue(); + assertThat(emittedJson) + .contains("cart-test-event") + .contains("cart") + .contains("gid://shopify/Cart/test-cart-123"); + } + /** * Errors */ diff --git a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/TestFixtures.java b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/TestFixtures.java new file mode 100644 index 00000000..12001a41 --- /dev/null +++ b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/TestFixtures.java @@ -0,0 +1,97 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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. +*/ + +package com.shopify.checkoutkitreactnative; + +import com.shopify.checkoutsheetkit.lifecycleevents.Cart; +import com.shopify.checkoutsheetkit.lifecycleevents.CartBuyerIdentity; +import com.shopify.checkoutsheetkit.lifecycleevents.CartCost; +import com.shopify.checkoutsheetkit.lifecycleevents.CartDelivery; +import com.shopify.checkoutsheetkit.lifecycleevents.Money; + +import java.util.Collections; + +/** + * Shared test fixtures for creating test objects. + */ +public class TestFixtures { + + /** + * Creates a test Cart instance with sensible defaults. + * + * @param id The cart identifier + * @param subtotalAmount The subtotal amount as a string + * @param totalAmount The total amount as a string + * @param currencyCode The currency code (default: "USD") + * @return A Cart instance suitable for testing + * + * Example: + *
+   * Cart cart = createTestCart(
+   *     "custom-cart-id",
+   *     "50.00",
+   *     "50.00",
+   *     "USD"
+   * );
+   * 
+ */ + public static Cart createTestCart( + String id, + String subtotalAmount, + String totalAmount, + String currencyCode + ) { + Money subtotal = new Money(subtotalAmount, currencyCode); + Money total = new Money(totalAmount, currencyCode); + CartCost cost = new CartCost(subtotal, total); + CartBuyerIdentity buyerIdentity = new CartBuyerIdentity(null, null, null, null); + CartDelivery delivery = new CartDelivery(Collections.emptyList()); + + return new Cart( + id, + Collections.emptyList(), // lines + cost, + buyerIdentity, + Collections.emptyList(), // deliveryGroups + Collections.emptyList(), // discountCodes + Collections.emptyList(), // appliedGiftCards + Collections.emptyList(), // discountAllocations + delivery + ); + } + + /** + * Creates a test Cart instance with default values. + * + * @return A Cart instance with default test values + */ + public static Cart createTestCart() { + return createTestCart( + "gid://shopify/Cart/test-cart-123", + "10.00", + "10.00", + "USD" + ); + } +} + diff --git a/sample/ios/ReactNativeTests/RCTCheckoutWebViewTests.swift b/sample/ios/ReactNativeTests/RCTCheckoutWebViewTests.swift index c76e52ab..2764058e 100644 --- a/sample/ios/ReactNativeTests/RCTCheckoutWebViewTests.swift +++ b/sample/ios/ReactNativeTests/RCTCheckoutWebViewTests.swift @@ -200,16 +200,16 @@ class RCTCheckoutWebViewTests: XCTestCase { XCTAssertEqual(checkoutWebView.removeCheckoutWebViewControllerCallCount, 1) } - func test_respondToEvent_whenAddressIntentResponded_doesNotRecreateCheckout() { + func test_respondToEvent_whenAddressStartResponded_doesNotRecreateCheckout() { checkoutWebView.checkoutUrl = "https://shop.example.com/checkout" checkoutWebView.auth = "valid-token" checkoutWebView.flushScheduledSetup() - let request = AddressChangeRequested( + let request = CheckoutAddressChangeStart( id: "event-1", - params: .init(addressType: "shipping", selectedAddress: nil) + params: .init(addressType: "shipping", cart: createTestCart()) ) - checkoutWebView.checkoutDidRequestAddressChange(event: request) + checkoutWebView.checkoutDidStartAddressChange(event: request) checkoutWebView.respondToEvent(eventId: "event-1", responseData: "{}") @@ -219,6 +219,7 @@ class RCTCheckoutWebViewTests: XCTestCase { XCTAssertEqual(checkoutWebView.removeCheckoutWebViewControllerCallCount, 0) } + // MARK: - Event Emission func test_checkoutDidComplete_whenDelegateCalled_emitsOnCompleteEvent() { @@ -240,6 +241,80 @@ class RCTCheckoutWebViewTests: XCTestCase { let order = orderConfirmation?["order"] as? [String: Any] XCTAssertEqual(order?["id"] as? String, "order-123") } + + func test_checkoutDidStartAddressChange_whenDelegateCalled_emitsOnAddressChangeStartEvent() { + let expectation = expectation(description: "onAddressChangeStart event emitted") + var receivedPayload: [AnyHashable: Any]? + + checkoutWebView.onAddressChangeStart = { payload in + receivedPayload = payload + expectation.fulfill() + } + + let request = CheckoutAddressChangeStart( + id: "address-event-123", + params: .init(addressType: "shipping", cart: createTestCart()) + ) + + checkoutWebView.checkoutDidStartAddressChange(event: request) + + wait(for: [expectation], timeout: 0.1) + + XCTAssertEqual(receivedPayload?["id"] as? String, "address-event-123") + XCTAssertEqual(receivedPayload?["type"] as? String, "addressChangeStart") + XCTAssertEqual(receivedPayload?["addressType"] as? String, "shipping") + } + + func test_checkoutDidStartAddressChange_forBillingAddress_emitsCorrectAddressType() { + let expectation = expectation(description: "onAddressChangeStart event emitted") + var receivedPayload: [AnyHashable: Any]? + + checkoutWebView.onAddressChangeStart = { payload in + receivedPayload = payload + expectation.fulfill() + } + + let request = CheckoutAddressChangeStart( + id: "billing-event-456", + params: .init(addressType: "billing", cart: createTestCart()) + ) + + checkoutWebView.checkoutDidStartAddressChange(event: request) + + wait(for: [expectation], timeout: 0.1) + + XCTAssertEqual(receivedPayload?["id"] as? String, "billing-event-456") + XCTAssertEqual(receivedPayload?["type"] as? String, "addressChangeStart") + XCTAssertEqual(receivedPayload?["addressType"] as? String, "billing") + } + + func test_checkoutDidStartAddressChange_includesCartInPayload() { + let expectation = expectation(description: "onAddressChangeStart event emitted with cart") + var receivedPayload: [AnyHashable: Any]? + + checkoutWebView.onAddressChangeStart = { payload in + receivedPayload = payload + expectation.fulfill() + } + + let testCart = createTestCart() + let request = CheckoutAddressChangeStart( + id: "cart-test-event", + params: .init(addressType: "shipping", cart: testCart) + ) + + checkoutWebView.checkoutDidStartAddressChange(event: request) + + wait(for: [expectation], timeout: 0.1) + + // Verify cart is included in the payload + XCTAssertNotNil(receivedPayload?["cart"], "Cart should be included in the emitted event") + + // Verify cart structure + let cart = receivedPayload?["cart"] as? [String: Any] + XCTAssertNotNil(cart) + XCTAssertEqual(cart?["id"] as? String, "gid://shopify/Cart/test-cart-123") + } } // MARK: - Mock Class diff --git a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift index c3be73f5..8c7b55cc 100644 --- a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift +++ b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift @@ -178,7 +178,7 @@ class ShopifyCheckoutSheetKitTests: XCTestCase { mock.startObserving() - // Create a test JSON string matching the new CheckoutCompletedEvent structure + // Create a test JSON string matching the CheckoutCompleteEvent structure let testEventJSON = """ { "orderConfirmation": { @@ -207,7 +207,7 @@ class ShopifyCheckoutSheetKitTests: XCTestCase { mock.startObserving() - // Create a test JSON string matching the CheckoutStartedEvent structure + // Create a test JSON string matching the CheckoutStartEvent structure let testEventJSON = """ { "cart": { diff --git a/sample/ios/ReactNativeTests/TestFixtures.swift b/sample/ios/ReactNativeTests/TestFixtures.swift new file mode 100644 index 00000000..870c8e96 --- /dev/null +++ b/sample/ios/ReactNativeTests/TestFixtures.swift @@ -0,0 +1,69 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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 Foundation +import ShopifyCheckoutSheetKit + +// MARK: - Cart Fixtures + +/** + Creates a test Cart instance with sensible defaults. + + - Parameters: + - id: The cart identifier + - subtotalAmount: The subtotal amount as a string + - totalAmount: The total amount as a string + - currencyCode: The currency code (default: "USD") + + - Returns: A Cart instance suitable for testing + + Example: + ```swift + let cart = createTestCart( + id: "custom-cart-id", + totalAmount: "99.99" + ) + ``` + */ +func createTestCart( + id: String = "gid://shopify/Cart/test-cart-123", + subtotalAmount: String = "10.00", + totalAmount: String = "10.00", + currencyCode: String = "USD" +) -> ShopifyCheckoutSheetKit.Cart { + return ShopifyCheckoutSheetKit.Cart( + id: id, + lines: [], + cost: .init( + subtotalAmount: .init(amount: subtotalAmount, currencyCode: currencyCode), + totalAmount: .init(amount: totalAmount, currencyCode: currencyCode) + ), + buyerIdentity: .init(), + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: .init(addresses: []) + ) +} + diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 0e6f5b52..7147611d 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -44,9 +44,9 @@ import SettingsScreen from './screens/SettingsScreen'; import BuyNowStack from './screens/BuyNow'; import type { - CheckoutCompletedEvent, + CheckoutCompleteEvent, CheckoutException, - CheckoutStartedEvent, + CheckoutStartEvent, Configuration, Features, } from '@shopify/checkout-sheet-kit'; @@ -212,15 +212,15 @@ function AppWithContext({children}: PropsWithChildren) { }); const completed = shopify.addEventListener( - 'completed', - (event: CheckoutCompletedEvent) => { + 'complete', + (event: CheckoutCompleteEvent) => { eventHandlers.onComplete?.(event); }, ); const started = shopify.addEventListener( - 'started', - (event: CheckoutStartedEvent) => { + 'start', + (event: CheckoutStartEvent) => { eventHandlers.onStart?.(event); }, ); diff --git a/sample/src/context/Cart.tsx b/sample/src/context/Cart.tsx index 6777cb89..489465fc 100644 --- a/sample/src/context/Cart.tsx +++ b/sample/src/context/Cart.tsx @@ -102,7 +102,7 @@ export const CartProvider: React.FC = ({children}) => { }, [setCartId, setCheckoutURL, setTotalQuantity, setSelectedAddressIndex, setSelectedPaymentIndex]); useEffect(() => { - const subscription = shopify.addEventListener('completed', () => { + const subscription = shopify.addEventListener('complete', () => { // Clear the cart ID and checkout URL when the checkout is completed clearCart(); }); diff --git a/sample/src/hooks/useCheckoutEventHandlers.ts b/sample/src/hooks/useCheckoutEventHandlers.ts index 6af85f91..88cee87d 100644 --- a/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/sample/src/hooks/useCheckoutEventHandlers.ts @@ -4,17 +4,17 @@ import {createDebugLogger} from '../utils'; import {useCart} from '../context/Cart'; import type { - CheckoutCompletedEvent, + CheckoutCompleteEvent, CheckoutException, - CheckoutStartedEvent, + CheckoutStartEvent, RenderStateChangeEvent, } from '@shopify/checkout-sheet-kit'; import {Linking} from 'react-native'; interface EventHandlers { onFail?: (error: CheckoutException) => void; - onComplete?: (event: CheckoutCompletedEvent) => void; - onStart?: (event: CheckoutStartedEvent) => void; + onComplete?: (event: CheckoutCompleteEvent) => void; + onStart?: (event: CheckoutStartEvent) => void; onCancel?: () => void; onRenderStateChange?: (event: RenderStateChangeEvent) => void; onShouldRecoverFromError?: (error: {message: string}) => boolean; diff --git a/sample/src/screens/BuyNow/AddressScreen.tsx b/sample/src/screens/BuyNow/AddressScreen.tsx index 2aae6dd7..0fc19126 100644 --- a/sample/src/screens/BuyNow/AddressScreen.tsx +++ b/sample/src/screens/BuyNow/AddressScreen.tsx @@ -19,9 +19,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import type {RouteProp} from '@react-navigation/native'; import {useNavigation, useRoute} from '@react-navigation/native'; -import React from 'react'; -import {Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import React, {useState} from 'react'; +import {Alert, Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; import {useShopifyEvent} from '@shopify/checkout-sheet-kit'; +import type {CheckoutAddressChangeStartResponse} from '@shopify/checkout-sheet-kit'; import {useCart} from '../../context/Cart'; import type {BuyNowStackParamList} from './types'; @@ -30,6 +31,7 @@ export default function AddressScreen() { const navigation = useNavigation(); const event = useShopifyEvent(route.params.id); const {selectedAddressIndex, setSelectedAddressIndex} = useCart(); + const [isSubmitting, setIsSubmitting] = useState(false); const addressOptions = [ { @@ -77,20 +79,42 @@ export default function AddressScreen() { ]; const handleAddressSelection = async () => { - const selectedAddress = addressOptions[selectedAddressIndex]; - await event.respondWith({ - delivery: { - addresses: [ - { - address: selectedAddress!.address, + if (isSubmitting) return; + + setIsSubmitting(true); + + try { + const selectedAddress = addressOptions[selectedAddressIndex]; + + const response: CheckoutAddressChangeStartResponse = { + cart: { + delivery: { + addresses: [ + { + address: selectedAddress!.address, + selected: true, + }, + ], }, - ], - }, - }); + }, + }; + + await event.respondWith(response); + + await new Promise(resolve => setTimeout(resolve, 500)); - await new Promise(resolve => setTimeout(resolve, 500)); + navigation.goBack(); + } catch (error) { + // Handle validation errors, decoding errors, etc. + const errorMessage = + error instanceof Error ? error.message : 'Failed to update address'; - navigation.goBack(); + Alert.alert( + 'Address Update Failed', + errorMessage, + [{text: 'OK', onPress: () => setIsSubmitting(false)}], + ); + } }; return ( @@ -124,7 +148,11 @@ export default function AddressScreen() { -