diff --git a/CHANGELOG.md b/CHANGELOG.md index 02fa25837..0cbab35b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # PayPal iOS SDK Release Notes + +## unreleased +* PaymentButtons + * Add `CardButton` to be used for PayPal guest checkout using card funding option +* PaymentButtons + * Add `card` option in `PayPalWebChecoutFundingSource` for web card field option ## 2.0.0 (2025-03-18) * Breaking Changes diff --git a/Demo/Demo/Extensions/PaymentButtonEnums+Extension.swift b/Demo/Demo/Extensions/PaymentButtonEnums+Extension.swift index 8c1e67fb2..19545d70b 100644 --- a/Demo/Demo/Extensions/PaymentButtonEnums+Extension.swift +++ b/Demo/Demo/Extensions/PaymentButtonEnums+Extension.swift @@ -33,6 +33,17 @@ extension PayPalCreditButton.Color { } } +extension CardButton.Color { + + public static var allCases: [CardButton.Color] { + [.black, .white] + } + + static func allCasesAsString() -> [String] { + Self.allCases.map { $0.rawValue } + } +} + extension PaymentButtonEdges { public static var allCases: [PaymentButtonEdges] { @@ -58,7 +69,7 @@ extension PaymentButtonSize { extension PaymentButtonFundingSource { public static var allCases: [PaymentButtonFundingSource] { - [.payPal, .payLater, .credit] + [.payPal, .payLater, .credit, .card] } static func allCasesAsString() -> [String] { diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift index bac812076..8d09a5f6b 100644 --- a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift @@ -22,6 +22,7 @@ struct PayPalWebButtonsView: View { Text("PayPal").tag(PayPalWebCheckoutFundingSource.paypal) Text("PayPal Credit").tag(PayPalWebCheckoutFundingSource.paypalCredit) Text("Pay Later").tag(PayPalWebCheckoutFundingSource.paylater) + Text("Card").tag(PayPalWebCheckoutFundingSource.card) } .pickerStyle(SegmentedPickerStyle()) ZStack { @@ -38,6 +39,10 @@ struct PayPalWebButtonsView: View { PayPalButton.Representable(color: .blue, size: .full) { payPalWebViewModel.paymentButtonTapped(funding: .paypal) } + case .card: + CardButton.Representable(color: .black, size: .full) { + payPalWebViewModel.paymentButtonTapped(funding: .card) + } } if payPalWebViewModel.state.approveResultResponse == .loading && payPalWebViewModel.checkoutResult == nil && diff --git a/Demo/Demo/SwiftUIComponents/SwiftUIPaymentButtonDemo.swift b/Demo/Demo/SwiftUIComponents/SwiftUIPaymentButtonDemo.swift index 145da5d35..04be2e744 100644 --- a/Demo/Demo/SwiftUIComponents/SwiftUIPaymentButtonDemo.swift +++ b/Demo/Demo/SwiftUIComponents/SwiftUIPaymentButtonDemo.swift @@ -118,6 +118,14 @@ struct SwiftUIPaymentButtonDemo: View { size: selectedSize ) .id(buttonID) + + case .card: + CardButton.Representable( + color: CardButton.Color.allCases[colorsIndex], + edges: selectedEdge, + size: selectedSize + ) + .id(buttonID) } } .padding() @@ -136,6 +144,9 @@ struct SwiftUIPaymentButtonDemo: View { case .credit: return PayPalCreditButton.Color.allCasesAsString() + + case .card: + return CardButton.Color.allCasesAsString() } } } diff --git a/PayPal.xcodeproj/project.pbxproj b/PayPal.xcodeproj/project.pbxproj index 137b07656..a863a46f7 100644 --- a/PayPal.xcodeproj/project.pbxproj +++ b/PayPal.xcodeproj/project.pbxproj @@ -19,7 +19,9 @@ 3B783DC32B7A69C2004623DB /* FakeUpdateSetupTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B783DC22B7A69C2004623DB /* FakeUpdateSetupTokenResponse.swift */; }; 3B79E4F72A8503CA00C01D06 /* UpdateVaultVariables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B79E4F62A8503C900C01D06 /* UpdateVaultVariables.swift */; }; 3B80D50C2A27979000D2EAC4 /* FailingJSONEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B80D50B2A27979000D2EAC4 /* FailingJSONEncoder.swift */; }; + 3B8F84842D92F95500A151AC /* CardButton_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8F84832D92F94600A151AC /* CardButton_Tests.swift */; }; 3B8F848C2D93380B00A151AC /* TestShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80E743F8270E40CE00BACECA /* TestShared.framework */; }; + 3BD014C82D8B4D60005565FE /* CardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD014C72D8B4D60005565FE /* CardButton.swift */; }; 3BD82DBB2A835AF900CBE764 /* UpdateSetupTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD82DBA2A835AF900CBE764 /* UpdateSetupTokenResponse.swift */; }; 3BDB34942A80CE6E008100D7 /* CardVaultRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDB34932A80CE6E008100D7 /* CardVaultRequest.swift */; }; 3BE738682B9A66D800598F05 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3BE738622B9A482800598F05 /* PrivacyInfo.xcprivacy */; }; @@ -199,8 +201,10 @@ 3B783DC22B7A69C2004623DB /* FakeUpdateSetupTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeUpdateSetupTokenResponse.swift; sourceTree = ""; }; 3B79E4F62A8503C900C01D06 /* UpdateVaultVariables.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateVaultVariables.swift; sourceTree = ""; }; 3B80D50B2A27979000D2EAC4 /* FailingJSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailingJSONEncoder.swift; sourceTree = ""; }; + 3B8F84832D92F94600A151AC /* CardButton_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardButton_Tests.swift; sourceTree = ""; }; 3B8F84852D93318D00A151AC /* UpdateClientConfigAP_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateClientConfigAP_Tests.swift; sourceTree = ""; }; 3B8F84912D933FAE00A151AC /* MockUpdateClientConfigAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUpdateClientConfigAPI.swift; sourceTree = ""; }; + 3BD014C72D8B4D60005565FE /* CardButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardButton.swift; sourceTree = ""; }; 3BD82DBA2A835AF900CBE764 /* UpdateSetupTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSetupTokenResponse.swift; sourceTree = ""; }; 3BDB34932A80CE6E008100D7 /* CardVaultRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVaultRequest.swift; sourceTree = ""; }; 3BE738622B9A482800598F05 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -626,6 +630,7 @@ isa = PBXGroup; children = ( BE00B79D2742F9F000758C63 /* Assets.xcassets */, + 3BD014C72D8B4D60005565FE /* CardButton.swift */, BEDB7FE32788AB8E00CEA554 /* Coordinator.swift */, BE00B7AA2743FD9F00758C63 /* PaymentButton.swift */, BE9F36DE274859A000AFC7DA /* PaymentButtonColor.swift */, @@ -647,6 +652,7 @@ BCFAC72227ED05D900C3AF00 /* PaymentButtonsTests */ = { isa = PBXGroup; children = ( + 3B8F84832D92F94600A151AC /* CardButton_Tests.swift */, BEF3FF1627AC5DF3006B4B69 /* Coordinator_Tests.swift */, BE00B7AC27444FE900758C63 /* PayPalButton_Tests.swift */, BE9F36E3275520E700AFC7DA /* PayPalCreditButton_Tests.swift */, @@ -1327,6 +1333,7 @@ BCFAC71F27ED04C800C3AF00 /* Coordinator.swift in Sources */, BCFAC71C27ED04C000C3AF00 /* PaymentButton.swift in Sources */, CB1A47F22820AFED00BD8184 /* PayPalPayLaterButton.swift in Sources */, + 3BD014C82D8B4D60005565FE /* CardButton.swift in Sources */, CB1A47F42820BA5D00BD8184 /* PaymentButtonEdges.swift in Sources */, BCFAC71A27ED04AC00C3AF00 /* PaymentButtonColor.swift in Sources */, ); @@ -1337,6 +1344,7 @@ buildActionMask = 0; files = ( BCFAC70E27ED043100C3AF00 /* PayPalButton_Tests.swift in Sources */, + 3B8F84842D92F95500A151AC /* CardButton_Tests.swift in Sources */, BCFAC70F27ED043100C3AF00 /* Coordinator_Tests.swift in Sources */, CB22C018291049500097E592 /* PayPalPayLaterButton_Tests.swift in Sources */, BCFAC71327ED043100C3AF00 /* PayPalCreditButton_Tests.swift in Sources */, diff --git a/Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift b/Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift index 03fe8c5d6..25714b744 100644 --- a/Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift +++ b/Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift @@ -155,7 +155,7 @@ public class PayPalWebCheckoutClient: NSObject { public func vault(_ vaultRequest: PayPalVaultRequest, completion: @escaping (Result) -> Void) { analyticsService = AnalyticsService(coreConfig: config, setupToken: vaultRequest.setupTokenID) analyticsService?.sendEvent("paypal-web-payments:vault-wo-purchase:started") - + Task { do { _ = try await clientConfigAPI.updateClientConfig( @@ -169,12 +169,12 @@ public class PayPalWebCheckoutClient: NSObject { var vaultURLComponents = URLComponents(url: config.environment.paypalVaultCheckoutURL, resolvingAgainstBaseURL: false) let queryItems = [URLQueryItem(name: "approval_session_id", value: vaultRequest.setupTokenID)] vaultURLComponents?.queryItems = queryItems - + guard let vaultCheckoutURL = vaultURLComponents?.url else { notifyVaultFailure(with: PayPalError.payPalURLError, completion: completion) return } - + webAuthenticationSession.start( url: vaultCheckoutURL, context: self, @@ -199,7 +199,7 @@ public class PayPalWebCheckoutClient: NSObject { return } } - + if let url = url { guard let tokenID = self.getQueryStringParameter(url: url.absoluteString, param: "approval_token_id"), let approvalSessionID = self.getQueryStringParameter(url: url.absoluteString, param: "approval_session_id"), @@ -208,7 +208,7 @@ public class PayPalWebCheckoutClient: NSObject { self.notifyVaultFailure(with: PayPalError.payPalVaultResponseError, completion: completion) return } - + let paypalVaultResult = PayPalVaultResult(tokenID: tokenID, approvalSessionID: approvalSessionID) self.notifyVaultSuccess(for: paypalVaultResult, completion: completion) } diff --git a/Sources/PayPalWebPayments/PayPalWebCheckoutFundingSource.swift b/Sources/PayPalWebPayments/PayPalWebCheckoutFundingSource.swift index b64f53e28..1a100b263 100644 --- a/Sources/PayPalWebPayments/PayPalWebCheckoutFundingSource.swift +++ b/Sources/PayPalWebPayments/PayPalWebCheckoutFundingSource.swift @@ -13,4 +13,7 @@ public enum PayPalWebCheckoutFundingSource: String { /// PayPal will launch the web checkout for a one-time PayPal Checkout flow case paypal = "paypal" + + /// PayPal will launch the web guest checkout for card funding source + case card = "card" } diff --git a/Sources/PaymentButtons/CardButton.swift b/Sources/PaymentButtons/CardButton.swift new file mode 100644 index 000000000..57a374cc4 --- /dev/null +++ b/Sources/PaymentButtons/CardButton.swift @@ -0,0 +1,122 @@ +import UIKit +import SwiftUI + +/// Configuration for Card button +public final class CardButton: PaymentButton { + + /// Available colors for CardButton. + public enum Color: String { + case white + case black + + var color: PaymentButtonColor { + PaymentButtonColor(rawValue: rawValue) ?? .black + } + } + + /// Available labels for CardButton. + public enum Label: String { + /// Display "Debit or Credit Card" on the left side of the button's logo + case card = "Debit or Credit Card" + + var label: PaymentButtonLabel? { + PaymentButtonLabel(rawValue: rawValue) + } + } + + /// Initialize a PayPalButton + /// - Parameters: + /// - insets: Edge insets of the button, defining the spacing of the button's edges relative to its content. + /// - color: Color of the button. Default to black if not provided. + /// - edges: Edges of the button. Default to softEdges if not provided. + /// - size: Size of the button. Default to collapsed if not provided. + /// - label: Label displayed next to the button's logo. Default to no label. + public convenience init( + insets: NSDirectionalEdgeInsets? = nil, + color: Color = .black, + edges: PaymentButtonEdges = .softEdges, + size: PaymentButtonSize = .collapsed, + label: Label = .card, + cardImage: UIImage? = nil + ) { + self.init( + fundingSource: .card, + color: color.color, + edges: edges, + size: size, + insets: insets, + label: label.label + ) + } + + deinit {} +} + +public extension CardButton { + + /// CardlButton for SwiftUI + struct Representable: UIViewRepresentable { + + private var action: () -> Void = { } + + private let button: CardButton + + /// Initialize a CardButton + /// - Parameters: + /// - insets: Edge insets of the button, defining the spacing of the button's edges relative to its content. + /// - color: Color of the button. Default to gold if not provided. + /// - edges: Edges of the button. Default to softEdges if not provided. + /// - size: Size of the button. Default to expanded if not provided. + /// - label: Label displayed next to the button's logo. Default to no label. + public init( + insets: NSDirectionalEdgeInsets? = nil, + color: CardButton.Color = .black, + edges: PaymentButtonEdges = .softEdges, + size: PaymentButtonSize = .expanded, + label: CardButton.Label = .card, + _ action: @escaping () -> Void = { } + ) { + button = CardButton( + fundingSource: .card, + color: color.color, + edges: edges, + size: size, + insets: insets, + label: label.label + ) + self.action = action + } + + // MARK: - UIViewRepresentable methods + // TODO: Make unit test for UIVRepresentable methods: https://engineering.paypalcorp.com/jira/browse/DTNOR-623 + + public func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + public func makeUIView(context: Context) -> PaymentButton { + button.addTarget(context.coordinator, action: #selector(Coordinator.onAction(_:)), for: .touchUpInside) + return button + } + + public func updateUIView(_ uiView: PaymentButton, context: Context) { + context.coordinator.action = action + } + } +} + +// MARK: PayPalButton Preview + +struct CardButtonView: View { + + var body: some View { + CardButton.Representable() + } +} + +struct CardButtonView_Preview: PreviewProvider { + + static var previews: some View { + CardButtonView() + } +} diff --git a/Sources/PaymentButtons/PaymentButton+ImageAsset.swift b/Sources/PaymentButtons/PaymentButton+ImageAsset.swift index 83aea52e1..74a491beb 100644 --- a/Sources/PaymentButtons/PaymentButton+ImageAsset.swift +++ b/Sources/PaymentButtons/PaymentButton+ImageAsset.swift @@ -10,6 +10,9 @@ extension PaymentButton { var imageAccessibilityLabel: String { // NEXT_MAJOR_VERSION: - To be replaced with translation strings. + if fundingSource == .card { + return "Credit Card" + } return fileName.starts(with: "credit") ? "PayPal Credit" : "PayPal" } @@ -32,6 +35,9 @@ extension PaymentButton { case .credit: imageAssetString += "credit_" + + case .card: + imageAssetString += "card_" } switch color { diff --git a/Sources/PaymentButtons/PaymentButtonFundingSource.swift b/Sources/PaymentButtons/PaymentButtonFundingSource.swift index b6f60b57a..e79a164bc 100644 --- a/Sources/PaymentButtons/PaymentButtonFundingSource.swift +++ b/Sources/PaymentButtons/PaymentButtonFundingSource.swift @@ -5,4 +5,5 @@ public enum PaymentButtonFundingSource: String { case payPal = "PayPal" case payLater = "Pay Later" case credit = "Credit" + case card = "Card" } diff --git a/Sources/PaymentButtons/PaymentButtonLabel.swift b/Sources/PaymentButtons/PaymentButtonLabel.swift index 4be605489..d91ac1113 100644 --- a/Sources/PaymentButtons/PaymentButtonLabel.swift +++ b/Sources/PaymentButtons/PaymentButtonLabel.swift @@ -15,6 +15,9 @@ public enum PaymentButtonLabel: String { /// Add "Pay later" to the right of button's logo, only used for PayPalPayLaterButton case payLater = "Pay Later" + /// Add "Debit or Credit Card" to the right of button's logo, only used for CardButton + case card = "Debit or Credit Card" + enum Position { case prefix case suffix @@ -22,7 +25,7 @@ public enum PaymentButtonLabel: String { var position: Position { switch self { - case .checkout, .buyNow, .payLater: + case .card, .checkout, .buyNow, .payLater: return .suffix case .payWith: diff --git a/UnitTests/PaymentButtonsTests/CardButton_Tests.swift b/UnitTests/PaymentButtonsTests/CardButton_Tests.swift new file mode 100644 index 000000000..4e6f99947 --- /dev/null +++ b/UnitTests/PaymentButtonsTests/CardButton_Tests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import PaymentButtons + +class CardButton_Tests: XCTestCase { + + // MARK: - CardButton for UIKit + + func testInit_whenCardButtonCreated_hasUIImageFromAssets() { + let sut = CardButton() + XCTAssertEqual(sut.imageView?.image, UIImage(named: "card-black")) + } + + func testInit_whenPayPalButtonCreated_hasDefaultUIValuess() { + let sut = CardButton() + XCTAssertEqual(sut.edges, PaymentButtonEdges.softEdges) + XCTAssertEqual(sut.size, PaymentButtonSize.collapsed) + XCTAssertEqual(sut.color, PaymentButtonColor.black) + XCTAssertEqual(sut.label, PaymentButtonLabel.card) + XCTAssertNil(sut.insets) + } + + // MARK: - PayPalButton.Representable for SwiftUI + + func testMakeCoordinator_whenOnActionIsCalled_executesActionPassedInInitializer() { + let expectation = expectation(description: "Action is called") + let sut = CardButton.Representable { + expectation.fulfill() + } + let coordinator = sut.makeCoordinator() + + coordinator.onAction(self) + waitForExpectations(timeout: 1) { error in + if error != nil { + XCTFail("Action passed in CardButton.Representable was never called.") + } + } + } +}