diff --git a/Example/Podfile b/Example/Podfile
index cca2182..e852d38 100644
--- a/Example/Podfile
+++ b/Example/Podfile
@@ -1,6 +1,6 @@
use_frameworks!
-platform :ios, '13.0'
+platform :ios, '15.0'
target 'paystack-sdk-ios' do
@@ -13,7 +13,8 @@ post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
- config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
+ # Xcode 27 only supports deployment targets from iOS 15.0 upward.
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
# Expose TweetNacl's `CTweetNacl` C submodule to every pod target.
# Without this, Xcode 16+/Swift 6 explicit-module builds fail to
diff --git a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj
index e417584..8bf3cd4 100644
--- a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj
+++ b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj
@@ -284,7 +284,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -338,7 +338,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -365,7 +365,7 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -397,7 +397,7 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
diff --git a/Example/paystack-sdk-ios/Info.plist b/Example/paystack-sdk-ios/Info.plist
index 0a7d195..5bfbbda 100644
--- a/Example/paystack-sdk-ios/Info.plist
+++ b/Example/paystack-sdk-ios/Info.plist
@@ -2,10 +2,10 @@
- UIApplicationSceneManifest
-
- UISceneConfigurations
-
-
+ UIApplicationSceneManifest
+
+ UISceneConfigurations
+
+
diff --git a/README.md b/README.md
index c6ba993..46120f5 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,19 @@ func paymentDone(_ result: TransactionResult) {
}
```
+### Display audience
+By default the payment UI assumes the customer is holding the device (`.customerFacing`). If your app runs on a device the merchant holds and shows to the customer, such as a point-of-sale or terminal app, set the display audience when you build the Paystack object:
+
+```swift
+let paystackObject = try? PaystackBuilder
+ .newInstance
+ .setKey("PUBLIC KEY GOES HERE")
+ .setDisplayAudience(.merchantFacing)
+ .build()
+```
+
+Some payment methods adapt to the audience. For example, Zap shows an "Open Zap" button when customer-facing and a QR code for the customer to scan when merchant-facing.
+
### Handling the Transaction Result
There are 3 possibly results that can be returned
diff --git a/Sources/PaystackSDK/Core/DisplayAudience.swift b/Sources/PaystackSDK/Core/DisplayAudience.swift
new file mode 100644
index 0000000..32c29e8
--- /dev/null
+++ b/Sources/PaystackSDK/Core/DisplayAudience.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// Describes who the Paystack payment UI is being shown to.
+///
+/// Set it once on ``PaystackBuilder`` with ``PaystackBuilder/setDisplayAudience(_:)``.
+/// The payment flows use it to decide which UI elements to show. When it isn't set,
+/// ``customerFacing`` is used.
+///
+/// Example Usage:
+/// ```swift
+/// let paystack = try PaystackBuilder.newInstance
+/// .setKey("PUBLIC KEY GOES HERE")
+/// .setDisplayAudience(.merchantFacing)
+/// .build()
+/// ```
+public struct DisplayAudience: Hashable {
+
+ let id: String
+
+ init(id: String) {
+ self.id = id
+ }
+
+ /// The customer is holding the device and paying on it. This is the default.
+ public static let customerFacing = DisplayAudience(id: "customer_facing")
+
+ /// The merchant is holding the device and showing the screen to the customer,
+ /// for example on a point-of-sale or terminal app.
+ public static let merchantFacing = DisplayAudience(id: "merchant_facing")
+}
diff --git a/Sources/PaystackSDK/Core/PaystackBuilder.swift b/Sources/PaystackSDK/Core/PaystackBuilder.swift
index 5cbb5a7..dbf60b9 100644
--- a/Sources/PaystackSDK/Core/PaystackBuilder.swift
+++ b/Sources/PaystackSDK/Core/PaystackBuilder.swift
@@ -4,6 +4,7 @@ public class PaystackBuilder {
private var apiKey: String?
private var loggingEnabled = false
+ private var displayAudience: DisplayAudience = .customerFacing
public func setKey(_ apiKey: String) -> Self {
self.apiKey = apiKey
@@ -15,6 +16,14 @@ public class PaystackBuilder {
return self
}
+ /// Sets who the payment UI is shown to. Defaults to ``DisplayAudience/customerFacing`` when not called.
+ /// - Parameter audience: The ``DisplayAudience`` the payment flows should be tailored for
+ /// - Returns: The builder, so calls can be chained
+ public func setDisplayAudience(_ audience: DisplayAudience) -> Self {
+ self.displayAudience = audience
+ return self
+ }
+
public func build() throws -> Paystack {
guard let apiKey = apiKey else {
throw PaystackError.noAPIKey
@@ -22,7 +31,7 @@ public class PaystackBuilder {
Logger.loggingEnabled = loggingEnabled
- let config = PaystackConfig(apiKey: apiKey)
+ let config = PaystackConfig(apiKey: apiKey, displayAudience: displayAudience)
return Paystack(config: config)
}
}
diff --git a/Sources/PaystackSDK/Core/PaystackConfig.swift b/Sources/PaystackSDK/Core/PaystackConfig.swift
index 51f2cc8..a1bf7ac 100644
--- a/Sources/PaystackSDK/Core/PaystackConfig.swift
+++ b/Sources/PaystackSDK/Core/PaystackConfig.swift
@@ -3,4 +3,5 @@ import Foundation
public struct PaystackConfig {
public var apiKey: String
public var version: String = "2.1.0"
+ public var displayAudience: DisplayAudience = .customerFacing
}
diff --git a/Sources/PaystackUI/Charge/StartCharge.swift b/Sources/PaystackUI/Charge/StartCharge.swift
index 97c2257..cec64e5 100644
--- a/Sources/PaystackUI/Charge/StartCharge.swift
+++ b/Sources/PaystackUI/Charge/StartCharge.swift
@@ -27,7 +27,8 @@ public extension Paystack {
initializeSDK()
return NavigationalButton(
onComplete: onComplete,
- destination: ChargeView(accessCode: accessCode)) { button() }
+ destination: ChargeView(accessCode: accessCode)
+ .environment(\.displayAudience, config.displayAudience)) { button() }
}
@@ -51,6 +52,7 @@ public extension Paystack {
parentViewController: viewController)
let chargeCardViewController = UIHostingController(
rootView: ChargeView(accessCode: accessCode)
+ .environment(\.displayAudience, config.displayAudience)
.environmentObject(visibilityContainer))
viewController.present(chargeCardViewController, animated: true)
}
diff --git a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift
index 0a356d1..08c6e40 100644
--- a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift
+++ b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift
@@ -4,7 +4,6 @@ import PaystackCore
class ZapViewModel: ObservableObject {
static var mandateWindowSeconds: Int = 5 * 60
- public static var showsOpenZapButton = true
static var failedFallbackMessage = "Something went wrong"
static var sessionExpiredCopy =
diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift
index 4401019..0ca655e 100644
--- a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift
+++ b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift
@@ -11,7 +11,8 @@ struct ZapPaymentView: View {
let remainingSeconds: Int
let onChangePaymentMethod: () -> Void
- let showsOpenZapButton: Bool
+ @Environment(\.displayAudience)
+ private var displayAudience
private var formattedRemaining: String {
let minutes = max(0, remainingSeconds) / 60
@@ -29,12 +30,13 @@ struct ZapPaymentView: View {
zapHeader
- Text("Open Zap or scan this QR code to complete this payment")
+ Text(displayAudience.text(.zapInstruction))
.font(.body16M)
.foregroundColor(.contentPrimary)
.multilineTextAlignment(.center)
qrCodeBlock
+ .visible(for: .zapQRCode)
expiresInRow
@@ -78,10 +80,9 @@ struct ZapPaymentView: View {
private var actionButtons: some View {
VStack(spacing: .singlePadding) {
- if showsOpenZapButton {
- Button("Open Zap", action: openZap)
- .buttonStyle(PrimaryButtonStyle(showLoading: false))
- }
+ Button("Open Zap", action: openZap)
+ .buttonStyle(PrimaryButtonStyle(showLoading: false))
+ .visible(for: .zapOpenAppButton)
Button("Change payment method", action: onChangePaymentMethod)
.foregroundColor(.contentSecondary)
@@ -106,20 +107,20 @@ struct ZapPaymentView_Previews: PreviewProvider {
Group {
ZapPaymentView(details: .example,
remainingSeconds: 254,
- onChangePaymentMethod: {},
- showsOpenZapButton: true)
- .previewDisplayName("Default — Open Zap + Change payment method")
+ onChangePaymentMethod: {})
+ .environment(\.displayAudience, .customerFacing)
+ .previewDisplayName("Customer-facing — Open Zap, no QR")
ZapPaymentView(details: .example,
remainingSeconds: 254,
- onChangePaymentMethod: {},
- showsOpenZapButton: false)
- .previewDisplayName("QR-only (terminal mode)")
+ onChangePaymentMethod: {})
+ .environment(\.displayAudience, .merchantFacing)
+ .previewDisplayName("Merchant-facing — QR only")
ZapPaymentView(details: .example,
remainingSeconds: 42,
- onChangePaymentMethod: {},
- showsOpenZapButton: true)
+ onChangePaymentMethod: {})
+ .environment(\.displayAudience, .merchantFacing)
.previewDisplayName("Final 60s — countdown warning colour")
}
}
diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift
index 4b69c5a..d0cc384 100644
--- a/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift
+++ b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift
@@ -25,8 +25,7 @@ struct ZapView: View {
ZapPaymentView(
details: details,
remainingSeconds: viewModel.remainingSeconds,
- onChangePaymentMethod: viewModel.userTappedChangePaymentMethod,
- showsOpenZapButton: ZapViewModel.showsOpenZapButton)
+ onChangePaymentMethod: viewModel.userTappedChangePaymentMethod)
case .sessionExpired:
ZapSessionExpiredView(
message: ZapViewModel.sessionExpiredCopy,
diff --git a/Sources/PaystackUI/Design/UIElementVisibility.swift b/Sources/PaystackUI/Design/UIElementVisibility.swift
new file mode 100644
index 0000000..be191a6
--- /dev/null
+++ b/Sources/PaystackUI/Design/UIElementVisibility.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import PaystackCore
+
+enum AudienceGatedElement: CaseIterable {
+ case zapQRCode
+ case zapOpenAppButton
+}
+
+enum AudienceCopy: CaseIterable {
+ case zapInstruction
+}
+
+extension DisplayAudience {
+
+ func shows(_ element: AudienceGatedElement) -> Bool {
+ switch element {
+ case .zapQRCode:
+ return resolve(customerFacing: false, merchantFacing: true)
+ case .zapOpenAppButton:
+ return resolve(customerFacing: true, merchantFacing: false)
+ }
+ }
+
+ func text(_ copy: AudienceCopy) -> String {
+ switch copy {
+ case .zapInstruction:
+ return resolve(customerFacing: "Open Zap to complete this payment",
+ merchantFacing: "Scan this QR code with Zap to complete this payment")
+ }
+ }
+
+ private func resolve(customerFacing: Value, merchantFacing: Value) -> Value {
+ self == .merchantFacing ? merchantFacing : customerFacing
+ }
+}
+
+private struct AudienceVisibilityModifier: ViewModifier {
+
+ @Environment(\.displayAudience)
+ private var displayAudience
+
+ let element: AudienceGatedElement
+
+ @ViewBuilder
+ func body(content: Content) -> some View {
+ if displayAudience.shows(element) {
+ content
+ }
+ }
+}
+
+extension View {
+
+ func visible(for element: AudienceGatedElement) -> some View {
+ modifier(AudienceVisibilityModifier(element: element))
+ }
+}
diff --git a/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift b/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift
new file mode 100644
index 0000000..2b6741b
--- /dev/null
+++ b/Sources/PaystackUI/Utils/DisplayAudienceEnvironment.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+import PaystackCore
+
+private struct DisplayAudienceKey: EnvironmentKey {
+ static let defaultValue: DisplayAudience = .customerFacing
+}
+
+extension EnvironmentValues {
+ var displayAudience: DisplayAudience {
+ get { self[DisplayAudienceKey.self] }
+ set { self[DisplayAudienceKey.self] = newValue }
+ }
+}
diff --git a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift
index 35104c4..b67c513 100644
--- a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift
+++ b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift
@@ -25,4 +25,32 @@ class PaystackBuilderTests: XCTestCase {
}
}
+ func testDisplayAudienceDefaultsToCustomerFacing() throws {
+ let paystack = try builder
+ .setKey("testsk_exampleKey")
+ .build()
+ XCTAssertEqual(paystack.config.displayAudience, .customerFacing)
+ }
+
+ func testSetDisplayAudienceSetsMerchantFacingOnConfig() throws {
+ let paystack = try builder
+ .setKey("testsk_exampleKey")
+ .setDisplayAudience(.merchantFacing)
+ .build()
+ XCTAssertEqual(paystack.config.displayAudience, .merchantFacing)
+ }
+
+ func testSetDisplayAudienceLastCallWins() throws {
+ let paystack = try builder
+ .setKey("testsk_exampleKey")
+ .setDisplayAudience(.merchantFacing)
+ .setDisplayAudience(.customerFacing)
+ .build()
+ XCTAssertEqual(paystack.config.displayAudience, .customerFacing)
+ }
+
+ func testDisplayAudienceValuesAreDistinct() {
+ XCTAssertNotEqual(DisplayAudience.customerFacing, DisplayAudience.merchantFacing)
+ }
+
}
diff --git a/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift
new file mode 100644
index 0000000..a4d24d3
--- /dev/null
+++ b/Tests/PaystackSDKTests/UI/Utils/UIElementVisibilityTests.swift
@@ -0,0 +1,55 @@
+import XCTest
+@testable import PaystackCore
+@testable import PaystackUI
+
+final class UIElementVisibilityTests: XCTestCase {
+
+ private let futureAudience = DisplayAudience(id: "future")
+
+ func testZapQRCodeReturnsFalseWhenCustomerFacing() {
+ XCTAssertFalse(DisplayAudience.customerFacing.shows(.zapQRCode))
+ }
+
+ func testZapQRCodeReturnsTrueWhenMerchantFacing() {
+ XCTAssertTrue(DisplayAudience.merchantFacing.shows(.zapQRCode))
+ }
+
+ func testZapQRCodeFollowsCustomerFacingForUnknownAudience() {
+ XCTAssertFalse(futureAudience.shows(.zapQRCode))
+ }
+
+ func testZapOpenAppButtonReturnsTrueWhenCustomerFacing() {
+ XCTAssertTrue(DisplayAudience.customerFacing.shows(.zapOpenAppButton))
+ }
+
+ func testZapOpenAppButtonReturnsFalseWhenMerchantFacing() {
+ XCTAssertFalse(DisplayAudience.merchantFacing.shows(.zapOpenAppButton))
+ }
+
+ func testZapOpenAppButtonFollowsCustomerFacingForUnknownAudience() {
+ XCTAssertTrue(futureAudience.shows(.zapOpenAppButton))
+ }
+
+ func testZapShowsExactlyOnePaymentAffordanceForEveryAudience() {
+ for audience in [DisplayAudience.customerFacing, .merchantFacing, futureAudience] {
+ let visible = [audience.shows(.zapQRCode), audience.shows(.zapOpenAppButton)]
+ .filter { $0 }
+ XCTAssertEqual(visible.count, 1, "Audience \(audience.id) must show exactly one Zap payment option")
+ }
+ }
+
+ func testZapInstructionTextForCustomerFacing() {
+ XCTAssertEqual(DisplayAudience.customerFacing.text(.zapInstruction),
+ "Open Zap to complete this payment")
+ }
+
+ func testZapInstructionTextForMerchantFacing() {
+ XCTAssertEqual(DisplayAudience.merchantFacing.text(.zapInstruction),
+ "Scan this QR code with Zap to complete this payment")
+ }
+
+ func testZapInstructionTextFallsBackToCustomerWordingForUnknownAudience() {
+ XCTAssertEqual(futureAudience.text(.zapInstruction),
+ DisplayAudience.customerFacing.text(.zapInstruction))
+ }
+}