diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift index ab0d408..6290264 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift @@ -9,6 +9,9 @@ struct BankTransferAccountDetailsView: View { let onIveSentTheMoney: () async -> Void let onChangePaymentMethod: () -> Void + @Environment(\.displayAudience) + private var displayAudience + @State private var provisionedAt: Date = Date() @State private var now: Date = Date() @@ -52,6 +55,10 @@ struct BankTransferAccountDetailsView: View { } } + private var copyTrailing: AccountDetailRow.Trailing { + displayAudience.shows(.bankTransferCopyButtons) ? .copy : .none + } + private var accountCard: some View { VStack(spacing: 0) { AccountDetailRow(label: "BANK NAME", @@ -59,17 +66,17 @@ struct BankTransferAccountDetailsView: View { divider AccountDetailRow(label: "ACCOUNT NUMBER", value: details.accountNumber, - trailing: .copy) + trailing: copyTrailing) divider AccountDetailRow(label: "AMOUNT", value: amount.description, - trailing: .copy) + trailing: copyTrailing) if provider == .pesalink { divider AccountDetailRow(label: "NARRATION / REASON", value: details.transactionReference, - trailing: .copy) + trailing: copyTrailing) } } .overlay( @@ -117,3 +124,28 @@ struct BankTransferAccountDetailsView: View { return min(1.0, elapsed / total) } } + +@available(iOS 14.0, *) +struct BankTransferAccountDetailsView_Previews: PreviewProvider { + static var previews: some View { + Group { + BankTransferAccountDetailsView( + details: .example, + amount: AmountCurrency(amount: 1000000, currency: "NGN"), + provider: .standard, + onIveSentTheMoney: {}, + onChangePaymentMethod: {}) + .environment(\.displayAudience, .customerFacing) + .previewDisplayName("Customer-facing — copy buttons") + + BankTransferAccountDetailsView( + details: .example, + amount: AmountCurrency(amount: 1000000, currency: "NGN"), + provider: .standard, + onIveSentTheMoney: {}, + onChangePaymentMethod: {}) + .environment(\.displayAudience, .merchantFacing) + .previewDisplayName("Merchant-facing — no copy buttons") + } + } +} diff --git a/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift b/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift index bba78db..be999b4 100644 --- a/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift +++ b/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift @@ -14,7 +14,7 @@ extension QRDetails { guard let url = URL(string: response.data.url) else { return nil } return QRDetails( qrImageURL: url, - qrReference: variant.showsQRReferenceRow ? response.data.qrCode : nil, + qrReference: variant.supportsAppDeeplinks ? response.data.qrCode : nil, pusherChannel: response.data.channel) } } diff --git a/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift b/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift index 1fee92f..81c5f0b 100644 --- a/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift +++ b/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift @@ -31,10 +31,10 @@ enum QRVariant: String, Equatable { } } - var showsQRReferenceRow: Bool { + var supportsAppDeeplinks: Bool { switch self { case .scanToPay: - return false + return true case .snapScan: return false } diff --git a/Sources/PaystackUI/Charge/QR/Models/ScanToPayApp.swift b/Sources/PaystackUI/Charge/QR/Models/ScanToPayApp.swift new file mode 100644 index 0000000..9fb4a7e --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Models/ScanToPayApp.swift @@ -0,0 +1,73 @@ +import Foundation + +struct ScanToPayApp: Equatable, Identifiable { + + enum DeeplinkStyle: Equatable { + case fnb + case nedbank + case generic + } + + let name: String + let iconAsset: String + let deeplinkStyle: DeeplinkStyle + + var id: String { iconAsset } + + var initials: String { + let words = name.split(separator: " ") + if words.count >= 2 { + return words.prefix(2).compactMap { $0.first }.map(String.init).joined().uppercased() + } + return String(name.prefix(2)).uppercased() + } + + func deeplink(for qrCode: String) -> URL? { + guard let encoded = URIComponentEncoder.encode(qrCode) else { return nil } + switch deeplinkStyle { + case .fnb: + return URL(string: "https://www.online.fnb.co.za/banking/mobileservices" + + "?codetype=masterpassdeeplink&urlencodedqr=\(encoded)") + case .nedbank: + return URL(string: "nedbank://masterpass.oltio.co.za/\(encoded)") + case .generic: + return URL(string: "masterpass.app.scheme://masterpass.oltio.co.za/\(encoded)") + } + } +} + +enum ScanToPayAppDirectory { + + static let apps: [ScanToPayApp] = [ + ScanToPayApp(name: "Absa Scan to Pay", iconAsset: "scanToPayAppAbsa", deeplinkStyle: .generic), + ScanToPayApp(name: "Avo", iconAsset: "scanToPayAppAvo", deeplinkStyle: .generic), + ScanToPayApp(name: "Capitec", iconAsset: "scanToPayAppCapitec", deeplinkStyle: .generic), + ScanToPayApp(name: "FNB Pay", iconAsset: "scanToPayAppFNB", deeplinkStyle: .fnb), + ScanToPayApp(name: "Instapay", iconAsset: "scanToPayAppInstapay", deeplinkStyle: .generic), + ScanToPayApp(name: "Nedbank Money", iconAsset: "scanToPayAppNedbankMoney", deeplinkStyle: .nedbank), + ScanToPayApp(name: "Nedbank Scan to Pay", iconAsset: "scanToPayAppNedbankScanToPay", deeplinkStyle: .generic), + ScanToPayApp(name: "RMB", iconAsset: "scanToPayAppRMB", deeplinkStyle: .fnb), + ScanToPayApp(name: "Spot", iconAsset: "scanToPayAppSpot", deeplinkStyle: .generic), + ScanToPayApp(name: "Standard Bank Scan to Pay", iconAsset: "scanToPayAppStandardBank", deeplinkStyle: .generic), + ScanToPayApp(name: "Telkom Pay", iconAsset: "scanToPayAppTelkomPay", deeplinkStyle: .generic), + ScanToPayApp(name: "Scan to Pay", iconAsset: "scanToPayAppScanToPay", deeplinkStyle: .generic), + ScanToPayApp(name: "Vodapay", iconAsset: "scanToPayAppVodapay", deeplinkStyle: .generic) + ] + + static func openFailedMessage(appName: String) -> String { + "Couldn't open \(appName). Make sure it's installed." + } +} + +enum URIComponentEncoder { + + private static let allowed: CharacterSet = { + var set = CharacterSet() + set.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()") + return set + }() + + static func encode(_ value: String) -> String? { + value.addingPercentEncoding(withAllowedCharacters: allowed) + } +} diff --git a/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift index 2c57832..ff6de92 100644 --- a/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift +++ b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift @@ -13,27 +13,45 @@ struct QRDisplayView: View { let onICompletedPayment: () -> Void let onChangePaymentMethod: () -> Void + @Environment(\.displayAudience) + private var displayAudience + @State private var showCopiedToast = false + @State private var showsAppPicker = false var body: some View { + Group { + if showsAppPicker, let qrCode = deeplinkQRCode { + ScanToPayAppPickerView(qrCode: qrCode, + onBack: { showsAppPicker = false }) + } else { + paymentDetails + } + } + .copiedToast(isPresented: $showCopiedToast) + } + + private var paymentDetails: some View { ScrollView { VStack(spacing: .triplePadding) { headerLogo - Text(variant.instructionCopy) + Text(instructionText) .font(.body16M) .foregroundColor(.contentPrimary) .multilineTextAlignment(.center) - qrCodeBlock + if showsQRCode { + qrCodeBlock + } Text(amount.description) .font(.body16M) .foregroundColor(.contentPrimary) - if variant.showsQRReferenceRow, let reference = details.qrReference { - qrReferenceRow(reference: reference) + if showsQRNumber, let qrCode = deeplinkQRCode { + qrReferenceRow(reference: qrCode) } if let banner = inlineBanner { @@ -44,7 +62,33 @@ struct QRDisplayView: View { } .padding(.doublePadding) } - .copiedToast(isPresented: $showCopiedToast) + } + + private var deeplinkQRCode: String? { + guard variant.supportsAppDeeplinks, + let qrCode = details.qrReference, + !qrCode.isEmpty else { return nil } + return qrCode + } + + private var deeplinksAvailable: Bool { + deeplinkQRCode != nil + } + + private var instructionText: String { + deeplinksAvailable ? displayAudience.text(.scanToPayInstruction) : variant.instructionCopy + } + + private var showsQRCode: Bool { + !deeplinksAvailable || displayAudience.shows(.scanToPayQRCode) + } + + private var showsQRNumber: Bool { + deeplinksAvailable && displayAudience.shows(.scanToPayQRNumber) + } + + private var showsSeeAppsButton: Bool { + deeplinksAvailable && displayAudience.shows(.scanToPaySeeAppsButton) } private var headerLogo: some View { @@ -91,8 +135,16 @@ struct QRDisplayView: View { private var actionButtons: some View { VStack(spacing: .singlePadding) { - Button("I've completed payment", action: onICompletedPayment) - .buttonStyle(PrimaryButtonStyle(showLoading: false)) + if showsSeeAppsButton { + Button("See Scan to Pay apps", action: { showsAppPicker = true }) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + + Button("I've completed payment", action: onICompletedPayment) + .buttonStyle(SecondaryButtonStyle()) + } else { + Button("I've completed payment", action: onICompletedPayment) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + } Button("Change payment method", action: onChangePaymentMethod) .foregroundColor(.contentSecondary) @@ -122,7 +174,18 @@ struct QRDisplayView_Previews: PreviewProvider { inlineBanner: nil, onICompletedPayment: {}, onChangePaymentMethod: {}) - .previewDisplayName("Scan to Pay — awaiting scan") + .environment(\.displayAudience, .customerFacing) + .previewDisplayName("Scan to Pay — customer-facing (number + apps)") + + QRDisplayView( + variant: .scanToPay, + details: .scanToPayExample, + amount: AmountCurrency(amount: 4500, currency: "ZAR"), + inlineBanner: nil, + onICompletedPayment: {}, + onChangePaymentMethod: {}) + .environment(\.displayAudience, .merchantFacing) + .previewDisplayName("Scan to Pay — merchant-facing (QR only)") QRDisplayView( variant: .snapScan, diff --git a/Sources/PaystackUI/Charge/QR/Views/ScanToPayAppPickerView.swift b/Sources/PaystackUI/Charge/QR/Views/ScanToPayAppPickerView.swift new file mode 100644 index 0000000..8d32495 --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Views/ScanToPayAppPickerView.swift @@ -0,0 +1,130 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14.0, *) +struct ScanToPayAppPickerView: View { + + let qrCode: String + let onBack: () -> Void + + @State private var openFailedMessage: String? + + private let columns = Array(repeating: GridItem(.flexible(), spacing: .doublePadding), count: 3) + + var body: some View { + ScrollView { + VStack(spacing: .triplePadding) { + + Text("Choose your Scan to Pay app") + .font(.body16M) + .foregroundColor(.contentPrimary) + .multilineTextAlignment(.center) + + if let message = openFailedMessage { + Text(message) + .font(.body14M) + .foregroundColor(.contentWarning) + .multilineTextAlignment(.center) + .padding(.singlePadding) + .frame(maxWidth: .infinity) + .background(Color.warningSurface) + .cornerRadius(.cornerRadius) + } + + LazyVGrid(columns: columns, spacing: .triplePadding) { + ForEach(ScanToPayAppDirectory.apps) { app in + Button(action: { open(app) }) { + ScanToPayAppCell(app: app) + } + .buttonStyle(PlainButtonStyle()) + } + } + + Button("Back", action: onBack) + .foregroundColor(.contentSecondary) + .font(.body14M) + .padding(.top, .singlePadding) + } + .padding(.doublePadding) + } + } + + private func open(_ app: ScanToPayApp) { + openFailedMessage = nil + let failedMessage = ScanToPayAppDirectory.openFailedMessage(appName: app.name) + guard let url = app.deeplink(for: qrCode) else { + openFailedMessage = failedMessage + return + } + #if canImport(UIKit) + UIApplication.shared.open(url, options: [:]) { success in + guard !success else { return } + DispatchQueue.main.async { + openFailedMessage = failedMessage + } + } + #endif + } +} + +@available(iOS 14.0, *) +private struct ScanToPayAppCell: View { + + let app: ScanToPayApp + + private let iconSize: CGFloat = 64 + + var body: some View { + VStack(spacing: .singlePadding) { + icon + .frame(width: iconSize, height: iconSize) + .cornerRadius(.cornerRadius) + + Text(app.name) + .font(.body14R) + .foregroundColor(.contentPrimary) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .top) + } + + @ViewBuilder + private var icon: some View { + if let image = bundledIcon { + image + .resizable() + .scaledToFit() + } else { + ZStack { + RoundedRectangle(cornerRadius: .cornerRadius) + .fill(Color.surfaceTertiary) + Text(app.initials) + .font(.body16M) + .foregroundColor(.contentSecondary) + } + } + } + + private var bundledIcon: Image? { + #if canImport(UIKit) + guard let image = UIImage(named: app.iconAsset, in: .current, compatibleWith: nil) else { + return nil + } + return Image(uiImage: image) + #else + return nil + #endif + } +} + +@available(iOS 14.0, *) +struct ScanToPayAppPickerView_Previews: PreviewProvider { + static var previews: some View { + ScanToPayAppPickerView(qrCode: "1490884538", onBack: {}) + .previewDisplayName("Scan to Pay app picker — placeholder icons") + } +} diff --git a/Sources/PaystackUI/Design/UIElementVisibility.swift b/Sources/PaystackUI/Design/UIElementVisibility.swift index be191a6..8f89148 100644 --- a/Sources/PaystackUI/Design/UIElementVisibility.swift +++ b/Sources/PaystackUI/Design/UIElementVisibility.swift @@ -4,10 +4,15 @@ import PaystackCore enum AudienceGatedElement: CaseIterable { case zapQRCode case zapOpenAppButton + case bankTransferCopyButtons + case scanToPayQRCode + case scanToPayQRNumber + case scanToPaySeeAppsButton } enum AudienceCopy: CaseIterable { case zapInstruction + case scanToPayInstruction } extension DisplayAudience { @@ -18,6 +23,14 @@ extension DisplayAudience { return resolve(customerFacing: false, merchantFacing: true) case .zapOpenAppButton: return resolve(customerFacing: true, merchantFacing: false) + case .bankTransferCopyButtons: + return resolve(customerFacing: true, merchantFacing: false) + case .scanToPayQRCode: + return resolve(customerFacing: false, merchantFacing: true) + case .scanToPayQRNumber: + return resolve(customerFacing: true, merchantFacing: false) + case .scanToPaySeeAppsButton: + return resolve(customerFacing: true, merchantFacing: false) } } @@ -26,6 +39,9 @@ extension DisplayAudience { case .zapInstruction: return resolve(customerFacing: "Open Zap to complete this payment", merchantFacing: "Scan this QR code with Zap to complete this payment") + case .scanToPayInstruction: + return resolve(customerFacing: "Open any Scan to Pay app to complete this payment", + merchantFacing: "Open any Scan to Pay app on your phone to scan the QR code") } } diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/Contents.json new file mode 100644 index 0000000..b860796 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppAbsa.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/scanToPayAppAbsa.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/scanToPayAppAbsa.png new file mode 100644 index 0000000..f62db53 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAbsa.imageset/scanToPayAppAbsa.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/Contents.json new file mode 100644 index 0000000..6067db5 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppAvo.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/scanToPayAppAvo.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/scanToPayAppAvo.png new file mode 100644 index 0000000..4c3cba7 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppAvo.imageset/scanToPayAppAvo.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/Contents.json new file mode 100644 index 0000000..c9ae904 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppCapitec.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/scanToPayAppCapitec.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/scanToPayAppCapitec.png new file mode 100644 index 0000000..010ad55 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppCapitec.imageset/scanToPayAppCapitec.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/Contents.json new file mode 100644 index 0000000..95077e8 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppFNB.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/scanToPayAppFNB.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/scanToPayAppFNB.png new file mode 100644 index 0000000..3fd9d85 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppFNB.imageset/scanToPayAppFNB.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/Contents.json new file mode 100644 index 0000000..221b732 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppInstapay.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/scanToPayAppInstapay.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/scanToPayAppInstapay.png new file mode 100644 index 0000000..5c515b7 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppInstapay.imageset/scanToPayAppInstapay.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/Contents.json new file mode 100644 index 0000000..4a9f87e --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppNedbankMoney.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/scanToPayAppNedbankMoney.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/scanToPayAppNedbankMoney.png new file mode 100644 index 0000000..5ee3af0 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankMoney.imageset/scanToPayAppNedbankMoney.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/Contents.json new file mode 100644 index 0000000..a4b2646 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppNedbankScanToPay.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/scanToPayAppNedbankScanToPay.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/scanToPayAppNedbankScanToPay.png new file mode 100644 index 0000000..262260e Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppNedbankScanToPay.imageset/scanToPayAppNedbankScanToPay.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/Contents.json new file mode 100644 index 0000000..6e65330 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppRMB.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/scanToPayAppRMB.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/scanToPayAppRMB.png new file mode 100644 index 0000000..165e96c Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppRMB.imageset/scanToPayAppRMB.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/Contents.json new file mode 100644 index 0000000..7292740 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppScanToPay.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/scanToPayAppScanToPay.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/scanToPayAppScanToPay.png new file mode 100644 index 0000000..3ad05c4 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppScanToPay.imageset/scanToPayAppScanToPay.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/Contents.json new file mode 100644 index 0000000..bb4bb2c --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppSpot.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/scanToPayAppSpot.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/scanToPayAppSpot.png new file mode 100644 index 0000000..af98550 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppSpot.imageset/scanToPayAppSpot.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/Contents.json new file mode 100644 index 0000000..4b777e4 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppStandardBank.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/scanToPayAppStandardBank.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/scanToPayAppStandardBank.png new file mode 100644 index 0000000..4c8a6fe Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppStandardBank.imageset/scanToPayAppStandardBank.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/Contents.json new file mode 100644 index 0000000..fce9710 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppTelkomPay.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/scanToPayAppTelkomPay.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/scanToPayAppTelkomPay.png new file mode 100644 index 0000000..80952bf Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppTelkomPay.imageset/scanToPayAppTelkomPay.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/Contents.json new file mode 100644 index 0000000..1ccbdc0 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "scanToPayAppVodapay.png" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/scanToPayAppVodapay.png b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/scanToPayAppVodapay.png new file mode 100644 index 0000000..73b2e1f Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/ScanToPayApps/scanToPayAppVodapay.imageset/scanToPayAppVodapay.png differ diff --git a/Tests/PaystackSDKTests/UI/Charge/QR/ScanToPayAppDirectoryTests.swift b/Tests/PaystackSDKTests/UI/Charge/QR/ScanToPayAppDirectoryTests.swift new file mode 100644 index 0000000..84de5f0 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/QR/ScanToPayAppDirectoryTests.swift @@ -0,0 +1,124 @@ +import XCTest +#if canImport(UIKit) +import UIKit +#endif +@testable import PaystackUI + +final class ScanToPayAppDirectoryTests: XCTestCase { + + private let qrCode = "1490884538" + + func testDirectoryListsAppsInAgreedOrder() { + XCTAssertEqual(ScanToPayAppDirectory.apps.map(\.name), [ + "Absa Scan to Pay", + "Avo", + "Capitec", + "FNB Pay", + "Instapay", + "Nedbank Money", + "Nedbank Scan to Pay", + "RMB", + "Spot", + "Standard Bank Scan to Pay", + "Telkom Pay", + "Scan to Pay", + "Vodapay" + ]) + } + + func testAppNamesAndIconAssetsAreUnique() { + let apps = ScanToPayAppDirectory.apps + XCTAssertEqual(Set(apps.map(\.name)).count, apps.count) + XCTAssertEqual(Set(apps.map(\.iconAsset)).count, apps.count) + } + + func testIconAssetsFollowNamingConvention() { + for app in ScanToPayAppDirectory.apps { + XCTAssertTrue(app.iconAsset.hasPrefix("scanToPayApp"), "\(app.name) has icon asset \(app.iconAsset)") + XCTAssertGreaterThan(app.iconAsset.count, "scanToPayApp".count) + } + } + + #if canImport(UIKit) + func testEveryAppIconLoadsFromTheBundle() { + for app in ScanToPayAppDirectory.apps { + XCTAssertNotNil(UIImage(named: app.iconAsset, in: .current, compatibleWith: nil), + "Missing icon image for \(app.name) (\(app.iconAsset))") + } + } + #endif + + func testFNBPayAndRMBUseFNBDeeplink() { + XCTAssertEqual(styles(named: ["FNB Pay", "RMB"]), [.fnb, .fnb]) + } + + func testNedbankMoneyUsesNedbankDeeplink() { + XCTAssertEqual(styles(named: ["Nedbank Money"]), [.nedbank]) + } + + func testAllOtherAppsUseGenericDeeplink() { + let others = ScanToPayAppDirectory.apps.filter { !["FNB Pay", "RMB", "Nedbank Money"].contains($0.name) } + XCTAssertEqual(others.count, 10) + XCTAssertTrue(others.allSatisfy { $0.deeplinkStyle == .generic }) + } + + func testFNBDeeplinkURL() { + let app = ScanToPayApp(name: "FNB Pay", iconAsset: "scanToPayAppFNB", deeplinkStyle: .fnb) + XCTAssertEqual(app.deeplink(for: qrCode)?.absoluteString, + "https://www.online.fnb.co.za/banking/mobileservices" + + "?codetype=masterpassdeeplink&urlencodedqr=1490884538") + } + + func testNedbankDeeplinkURL() { + let app = ScanToPayApp(name: "Nedbank Money", iconAsset: "scanToPayAppNedbankMoney", deeplinkStyle: .nedbank) + XCTAssertEqual(app.deeplink(for: qrCode)?.absoluteString, + "nedbank://masterpass.oltio.co.za/1490884538") + } + + func testGenericDeeplinkURL() { + let app = ScanToPayApp(name: "Capitec", iconAsset: "scanToPayAppCapitec", deeplinkStyle: .generic) + XCTAssertEqual(app.deeplink(for: qrCode)?.absoluteString, + "masterpass.app.scheme://masterpass.oltio.co.za/1490884538") + } + + func testEncoderEscapesReservedCharactersLikeEncodeURIComponent() { + XCTAssertEqual(URIComponentEncoder.encode("a b/c?d&e=f"), "a%20b%2Fc%3Fd%26e%3Df") + XCTAssertEqual(URIComponentEncoder.encode("#+:@,;$"), "%23%2B%3A%40%2C%3B%24") + } + + func testEncoderLeavesUnreservedCharactersUnchanged() { + XCTAssertEqual(URIComponentEncoder.encode("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()") + } + + func testEncoderPercentEncodesNonASCIIAsUTF8() { + XCTAssertEqual(URIComponentEncoder.encode("é"), "%C3%A9") + } + + func testDeeplinkEncodesQRCodeInEveryStyle() { + for style in [ScanToPayApp.DeeplinkStyle.fnb, .nedbank, .generic] { + let app = ScanToPayApp(name: "Test", iconAsset: "scanToPayAppTest", deeplinkStyle: style) + let url = app.deeplink(for: "12 34")?.absoluteString + XCTAssertEqual(url?.hasSuffix("12%2034"), true, "\(style) did not encode the QR code") + } + } + + func testOpenFailedMessage() { + XCTAssertEqual(ScanToPayAppDirectory.openFailedMessage(appName: "FNB Pay"), + "Couldn't open FNB Pay. Make sure it's installed.") + } + + func testInitialsUseFirstTwoWordsOrFirstTwoLetters() { + XCTAssertEqual(app(named: "Absa Scan to Pay")?.initials, "AS") + XCTAssertEqual(app(named: "FNB Pay")?.initials, "FP") + XCTAssertEqual(app(named: "Avo")?.initials, "AV") + XCTAssertEqual(app(named: "RMB")?.initials, "RM") + } + + private func app(named name: String) -> ScanToPayApp? { + ScanToPayAppDirectory.apps.first { $0.name == name } + } + + private func styles(named names: [String]) -> [ScanToPayApp.DeeplinkStyle?] { + names.map { app(named: $0)?.deeplinkStyle } + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift index 3ffda14..ba04d9d 100644 --- a/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift @@ -39,7 +39,7 @@ final class QRRepositoryImplementationTests: PSTestCase { channelOption: "MPASS_OLTI", variant: .scanToPay) - XCTAssertEqual(result.qrReference, nil) + XCTAssertEqual(result.qrReference, "1490884538") XCTAssertEqual(result.pusherChannel, "api_mpass_olti_qr_51826223921246") } diff --git a/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift index a4d24d3..f8738cb 100644 --- a/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift +++ b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift @@ -52,4 +52,75 @@ final class UIElementVisibilityTests: XCTestCase { XCTAssertEqual(futureAudience.text(.zapInstruction), DisplayAudience.customerFacing.text(.zapInstruction)) } + + func testBankTransferCopyButtonsReturnsTrueWhenCustomerFacing() { + XCTAssertTrue(DisplayAudience.customerFacing.shows(.bankTransferCopyButtons)) + } + + func testBankTransferCopyButtonsReturnsFalseWhenMerchantFacing() { + XCTAssertFalse(DisplayAudience.merchantFacing.shows(.bankTransferCopyButtons)) + } + + func testBankTransferCopyButtonsFollowsCustomerFacingForUnknownAudience() { + XCTAssertTrue(futureAudience.shows(.bankTransferCopyButtons)) + } + + func testScanToPayQRCodeReturnsFalseWhenCustomerFacing() { + XCTAssertFalse(DisplayAudience.customerFacing.shows(.scanToPayQRCode)) + } + + func testScanToPayQRCodeReturnsTrueWhenMerchantFacing() { + XCTAssertTrue(DisplayAudience.merchantFacing.shows(.scanToPayQRCode)) + } + + func testScanToPayQRCodeFollowsCustomerFacingForUnknownAudience() { + XCTAssertFalse(futureAudience.shows(.scanToPayQRCode)) + } + + func testScanToPayQRNumberReturnsTrueWhenCustomerFacing() { + XCTAssertTrue(DisplayAudience.customerFacing.shows(.scanToPayQRNumber)) + } + + func testScanToPayQRNumberReturnsFalseWhenMerchantFacing() { + XCTAssertFalse(DisplayAudience.merchantFacing.shows(.scanToPayQRNumber)) + } + + func testScanToPayQRNumberFollowsCustomerFacingForUnknownAudience() { + XCTAssertTrue(futureAudience.shows(.scanToPayQRNumber)) + } + + func testScanToPaySeeAppsButtonReturnsTrueWhenCustomerFacing() { + XCTAssertTrue(DisplayAudience.customerFacing.shows(.scanToPaySeeAppsButton)) + } + + func testScanToPaySeeAppsButtonReturnsFalseWhenMerchantFacing() { + XCTAssertFalse(DisplayAudience.merchantFacing.shows(.scanToPaySeeAppsButton)) + } + + func testScanToPaySeeAppsButtonFollowsCustomerFacingForUnknownAudience() { + XCTAssertTrue(futureAudience.shows(.scanToPaySeeAppsButton)) + } + + func testScanToPayShowsExactlyOnePaymentEntryPointForEveryAudience() { + for audience in [DisplayAudience.customerFacing, .merchantFacing, futureAudience] { + let visible = [audience.shows(.scanToPayQRCode), audience.shows(.scanToPaySeeAppsButton)] + .filter { $0 } + XCTAssertEqual(visible.count, 1, "Audience \(audience.id) must show exactly one Scan to Pay entry point") + } + } + + func testScanToPayInstructionTextForCustomerFacing() { + XCTAssertEqual(DisplayAudience.customerFacing.text(.scanToPayInstruction), + "Open any Scan to Pay app to complete this payment") + } + + func testScanToPayInstructionTextForMerchantFacing() { + XCTAssertEqual(DisplayAudience.merchantFacing.text(.scanToPayInstruction), + "Open any Scan to Pay app on your phone to scan the QR code") + } + + func testScanToPayInstructionTextFallsBackToCustomerWordingForUnknownAudience() { + XCTAssertEqual(futureAudience.text(.scanToPayInstruction), + DisplayAudience.customerFacing.text(.scanToPayInstruction)) + } }