From 83ae7375af6cb3a48075dc30e7abcd0d081881dd Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 29 Jun 2026 13:29:14 +0200 Subject: [PATCH 1/2] Add provider logos and redesign Mobile Money channel selection list Replace the generic placeholder icons with provider-specific logos (M-Pesa, MTN, Airtel/ATL, Telecel/Vodafone) and a dedicated bank transfer logo, wiring each mobile money key to its own asset in SupportedChannel. Rework ChannelSelectionView from a two-column LazyVGrid into a full- width vertical list of tappable rows. Each channel is now a PlainButton with a leading logo and single-line, scalable title, giving a larger tap target and consistent layout regardless of channel count. If you'd prefer something shorter for a squash merge: Add provider logos and redesign Mobile Money channel selection list --- .../Views/ChannelSelectionView.swift | 41 +++++++++--------- .../Charge/Models/SupportedChannel.swift | 26 ++++++----- .../atlLogo.imageset/Airtel - Icon.svg | 3 ++ .../atlLogo.imageset/Contents.json | 21 +++++++++ .../bankTransferLogo.imageset/Contents.json | 23 ++++++++++ .../transfer 1-2.png | Bin 0 -> 1238 bytes .../transfer 1-3.png | Bin 0 -> 1601 bytes .../bankTransferLogo.imageset/transfer 1.png | Bin 0 -> 721 bytes .../mpesaLogo.imageset/Contents.json | 21 +++++++++ .../mpesaLogo.imageset/M-PESA Icon.svg | 6 +++ .../mtnLogo.imageset/Contents.json | 21 +++++++++ .../mtnLogo.imageset/MTN Logo.svg | 12 +++++ .../vodLogo.imageset/Contents.json | 21 +++++++++ .../vodLogo.imageset/Telecel-Icon.svg | 11 +++++ 14 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Airtel - Icon.svg create mode 100644 Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-2.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-3.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/mpesaLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/mpesaLogo.imageset/M-PESA Icon.svg create mode 100644 Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/MTN Logo.svg create mode 100644 Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Telecel-Icon.svg diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index b82271c..679fa93 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -1,5 +1,6 @@ import SwiftUI import PaystackCore + @available(iOS 14.0, *) struct ChannelSelectionView: View { @@ -8,7 +9,6 @@ struct ChannelSelectionView: View { @StateObject var viewModel: ChannelSelectionViewModel let supportedChannels: [SupportedChannel] - let columns = [GridItem(.flexible()), GridItem(.flexible())] init(state: Binding, supportedChannels: [SupportedChannel], @@ -25,19 +25,18 @@ struct ChannelSelectionView: View { .font(.body16M) .foregroundColor(.stackBlue) .multilineTextAlignment(.center) - GeometryReader { geo in - LazyVGrid(columns: columns) { - ForEach(supportedChannels) { channel in - ChannelView(channelTitle: channel.displayTitle, image: channel.image) - .padding(.singlePadding) - .onTapGesture { - viewModel.choose(channel) - } - .frame(width: (geo.size.width / CGFloat(supportedChannels.count)).rounded()) + + VStack(spacing: .singlePadding) { + ForEach(supportedChannels) { channel in + Button(action: { viewModel.choose(channel) }) { + ChannelView(channelTitle: channel.displayTitle, + image: channel.image) } + .buttonStyle(PlainButtonStyle()) } } } + .padding(.horizontal, .doublePadding) } } } @@ -77,21 +76,23 @@ struct ChannelView: View { var body: some View { - HStack(spacing: .singlePadding) { + HStack(spacing: .doublePadding) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSlotSize, height: imageSlotSize) - VStack(alignment: .leading, spacing: .singlePadding) { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: imageSlotSize, height: imageSlotSize) + Text(channelTitle) + .font(.body16M) + .foregroundColor(.stackBlue) + .lineLimit(1) + .minimumScaleFactor(0.85) - Text(channelTitle) - .font(.body14M) - .foregroundColor(.navy02) - } Spacer() } .padding(.doublePadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) .cornerRadius(.cornerRadius) .overlay( RoundedRectangle(cornerRadius: .cornerRadius) diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index c35e443..95c5c43 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -6,7 +6,7 @@ enum SupportedChannel: Equatable, Identifiable { case mobileMoney(MobileMoneyChannel) case bankTransfer(BankTransferConfig) case zap(ZapConfig) - + var id: String { switch self { case .card: @@ -19,7 +19,7 @@ enum SupportedChannel: Equatable, Identifiable { return "zap" } } - + var displayTitle: String { switch self { case .card: @@ -27,12 +27,12 @@ enum SupportedChannel: Equatable, Identifiable { case .mobileMoney(let channel): return channel.value case .bankTransfer: - return "Transfer" + return "Bank Transfer" case .zap: return "Zap" } } - + var image: Image { switch self { case .card: @@ -40,20 +40,24 @@ enum SupportedChannel: Equatable, Identifiable { case .mobileMoney(let channel): return Self.image(forMobileMoneyKey: channel.key) case .bankTransfer: - return Image(systemName: "building.columns") + return Image("bankTransferLogo", bundle: .current) case .zap: return Image("zapSingleLogo", bundle: .current) } } - + private static func image(forMobileMoneyKey key: String) -> Image { switch key.uppercased() { - case "MPESA", "ATL_KE": - return Image("kenyaSHLogo", bundle: .current) - case "MTN", "ATL", "VOD": - return Image("kenyaSHLogo", bundle: .current) + case "MPESA": + return Image("mpesaLogo", bundle: .current) + case "ATL_KE", "ATL": + return Image("atlLogo", bundle: .current) + case "MTN": + return Image("mtnLogo", bundle: .current) + case "VOD": + return Image("vodLogo", bundle: .current) default: - return Image(systemName: "creditcard") + return Image(systemName: "kenyaSHLogo") } } } diff --git a/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Airtel - Icon.svg b/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Airtel - Icon.svg new file mode 100644 index 0000000..60ac670 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Airtel - Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Contents.json new file mode 100644 index 0000000..64c7784 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/atlLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Airtel - Icon.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/Contents.json new file mode 100644 index 0000000..260d0b2 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "transfer 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "transfer 1-2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "transfer 1-3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-2.png b/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-2.png new file mode 100644 index 0000000000000000000000000000000000000000..da973af7512a1333c9725d9303bde4f96c5fe6d8 GIT binary patch literal 1238 zcmV;{1S$K8P)7%Q6n-;yT_@qkDyphLOC5oVlF-CflJavv zHe67-B+7vb3B^T7ATAC!gg~(+RP_RJFBKpp8zfK;RD?r;3#l*kKvJ#KDhVKH7E%aM zXctiG)b-BrW??tG_O5@{UbTmO(rV}Dz1h$2y?JlO0NZE{1lkBmS_v~BPg0efh(>#5 zXam|pP?AqDnmc{PDb8WCNmYeVk{LSKn{Gy_DS``&-y{K8 z3Y#H&30?qBWI`Zf=kotQ*i?KrL~#DXN{nzmRm+_mczR^8Kc)8<7MFAQnsAbD)a^A1 z{^!nX)0~q;okirK!M+}=t`t(~&Z|%d==KyX2Qe-6D0cJIN%7o;l_`}LSL=}zKzCLx z9*suDm{M1l@f4#)>D>9Hgx!3V1%*2)<5{N|a)9Dv9X)0%XuF&e37SK}$Hu;iyg7{%Wv?#dt(}Py@lUo{)_o?KlSr%nhEvqsZ^8vBc++}$!bar0KUQzyv zKn?8KeOGQ`^suSI1QmV5+2+Cd#ibMhGG!(>JkS?6%V4Zo!i-ZAA34~Y*ZW6aOoa(8 zodMHE>FNx^V-F9(=W}0Gf0RI>xFZms&7>@;$qyc2bTSnpB`Z0B*OJe}{=MDM*%5?O zr@yo+afo!>xbX?LEQWAQ)U6;`DkZ@t#b`SmF@E@Q7=P-CYDLat&%E?%0=y)cKR6eSuo@@Uza3W@yO&WVZGb-7)(C*bn+6Wm3p{*MS!1evWRBT~)lm~wP-Z)Dzsjcc36JXR7} zZ?}Du1OkCsXnGLv#|oYi%!x|C`TO9GoHzwpE>)f+YK$RKDyvS@FFZS5 zlN>^xIe9DxUK5;o`?!oWS5B4m@_5yOtPaJ{E(U zkC3<(>K%&(@v4_ER~#5`2K+4O`tQ1JY@^-y7qc_ZX5~yks{jB107*qoM6N<$f|5Hh AApigX literal 0 HcmV?d00001 diff --git a/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-3.png b/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1-3.png new file mode 100644 index 0000000000000000000000000000000000000000..b45c2299157ec05dddc6600d99e64feb0c079a30 GIT binary patch literal 1601 zcmV-H2EO@;P)L3VmOVXGSgm|D1 zNC?3Ljv}E33B{5600b%Fu>}-IRD~zvN~l5zDXu^$F9`Ds;_y`E!p2FRRH4YOh$tmW zV<=BVTscv+K*ck-rDaJ2N{w-_DtHzB#i1Ra8+$F)%r|;E|-51Ii2vWdWHu(#HEDV_>VqhRL|7Nq|@zo#i7I~3*qUxg+V5>0sO0L z!i2COP12!K5-%xXZoX7zBMiVPdJhgL4ulQ0wHyyZ2~ZNklCf$Ic_69;fo^d%KzeVZn{6K2*18G`}Qd-#B8uRe!=Z-ObFit2EfA%n&Y4;?@J*3 zY|F6@t?H@Bl>iFAelXmvF*6sEmfU<%0*-JwXg1W~dX4MW6Jjd-@b!=CZDcAkFY)D( zuYF#^TqPyxT1-O{8Wee&X3WcFHNm-0fj2>}KlQyH0@_|v((cbI|XP5H<%9M>2 z>sfkcPShS=s7lfsNUoQTp~G4m!@q^ju;uk%Z8MBb32W zMu@l6&qK@kp#8+L0RL}Gudr>~;I zpt!IQoMd$I8eEGn!Ee9cRBcmNcMm+_d<>2^Jr0jP@{ndaWDJ^(*8&G)| ze{(!2I)ZVKiC^u0f*}Ufae>Js1UeYMJmHNl#(LH_Hido3f&C5e;`7h+HaVT~?7DV$ zW*E%tHh98KEkvO*3bwVldR6a<%rmue6#cz=60p1PL)>*S;mt@+{{a5)E}qxjLY6&i zsfF&ovyv9s!}lMAPtUv$M~^(L+Xud7|6A{!)m@GiL3n3o)rAKq`%(XCh3v-zr-ev& zzk5d0N-QUgGvd=TW7m_7NP1^E@2j^9k;b(%cxu4!RI9R_FwTgs3I25XXZZ4)?{}{S zNju2a)`(h#rG%B)71%JCBF&h`JK)dp^p+Av9o-|^)(q)=o0~aH#WKP^{`PSejND_C zG``T2*=07c2&dk7#Wyh%1*2GASyhOm&`_>Zq(2FK4cC`$WHwewDp)2waC@lQ#w_lv zt*eq|wQ}QV;Inw@x?+S!q2M$9&f8si+wB>`%ePkH^RK?mDPISBMhe%mHT-7ZklO~E z5|}{OYcE6B>o4tIbF$~qoR?Q}($k4hz@JfQ*$g+-LL9Rm!U^;g43OncBPaE18Q~zl zc}(#b%0(L@3yNdBlwD@E@DM%?G)h)MtsKrSvXt=X7w5ugcHw;OOp(Q8LwYz$!TQZZ zo>_ji;{`Kh;~0MfY893f=Ch0nM3XdERq_ADgdkb2raIMTm!l%$@aVh7o=1mrhoAwo2h_#F z-?#sT>DjBWbmI?9!mno3agv7*9fBj6k8W#rDLLvKXdJ+66V})zSq1(AQs8-L0VHbV z`o`?fAyl=r^36pBk>wC1nGVOcZJqCj!|8>pPHP+NML(;x1hVYOE>a~-XwnON07N{+ zLmkRz19v$vr{dsW7#R7isfsG9sG^E0%835}8yBLNHHx)v00000NkvXXu0mjfHC*=9 literal 0 HcmV?d00001 diff --git a/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1.png b/Sources/PaystackUI/Images/Images.xcassets/bankTransferLogo.imageset/transfer 1.png new file mode 100644 index 0000000000000000000000000000000000000000..e8538dda308fe22d426343430d8664dbc617e718 GIT binary patch literal 721 zcmV;?0xtcDP)@r0n~xbWnHW4DJsUVP^&E7z|bk!$QZoDz|v+h zBm)<0JVX;=%kL=mhCDhHEGgpQQj8D+`(SkjFyJ0}x>xWS%K1Hj1n8X%x=o+Zm zIIU4T4v*u`Kf!qZW}ZLAArB~QZZIzWooK{E zaOMQNe)lnOzLt|%?xE2roH>0GX0rDPs#F(hxwlIzD#daLXb4Lo%n`m&(c#tnd$^oD z=TwTOL^>Tu-%wEC%*FGk+E(YcwSLr)pqH-Qgl5}{Z3#H1Ef|ePGv>`BkzN=cR6(!R zq1CqhxWK??FJ9lrufqZELKsy>ZQptD#0#0FM_>&f-oBDyjFrfb4yds5`HK(3t1|oO z*44c0ubWoZN%U!SO&qi9FImccoy9e?*=j3uT6LnfY$z69o!;;c7>N#<$B)FaArZ;M z;hGFuB!v}{mj)u + + + + + diff --git a/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/Contents.json new file mode 100644 index 0000000..a40d882 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "MTN Logo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/MTN Logo.svg b/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/MTN Logo.svg new file mode 100644 index 0000000..49cc77f --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/mtnLogo.imageset/MTN Logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Contents.json new file mode 100644 index 0000000..f63d95f --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Telecel-Icon.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Telecel-Icon.svg b/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Telecel-Icon.svg new file mode 100644 index 0000000..fac35ee --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/vodLogo.imageset/Telecel-Icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From 5785b0ad877551d6988c54ba689f8d1b0ca28466 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 30 Jun 2026 08:41:01 +0200 Subject: [PATCH 2/2] Add Pesalink branding to bank transfer flow and remove change-bank picker Brand the bank-transfer channel as "Pesalink" (Kenya rail) instead of the standard Nigeria-style Pay-with-Transfer when the transaction currency is KES. Pesalink shares the underlying PWT rail and Pusher contract; the difference is purely branding (tile title + logo) plus a customer-facing "Narration / Reason" row on the account-details screen. - Introduce BankTransferProvider (.standard / .pesalink), resolved at flow entry by ChargeViewModel based on the new pesalinkCurrencyCodes static (KES today). Currency is a stand-in for country until the backend ships a country code on verify-access-code. - Thread provider through BankTransferConfig and SupportedChannel to drive the Pesalink display title and bundled wordmark (pesalinkLogo). - Surface the Pesalink-only "Narration / Reason" row on the account details screen. - Remove the change-bank picker from the UI (backend no longer supports changing the bank mid-flow). BankPickerSheet and the view-model picker logic are preserved with re-enablement notes for later. - Add ChargeViewModel tests covering KES/NGN/USD resolution, the configurable currency-code gate, and Pesalink display title. --- .../Models/BankTransferConfig.swift | 12 ++ .../Models/BankTransferProvider.swift | 6 + .../Viewmodels/BankTransferViewModel.swift | 2 - .../BankTransferAccountDetailsView.swift | 23 ++-- .../BankTransfer/Views/BankTransferView.swift | 18 +-- .../PaystackUI/Charge/ChargeViewModel.swift | 7 +- .../Charge/Models/SupportedChannel.swift | 12 +- .../pesalinkLogo.imageset/Contents.json | 21 ++++ .../pesalinkLogo.imageset/Group 85.svg | 7 ++ .../UI/Charge/ChargeViewModelTests.swift | 105 +++++++++++++++++- 10 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift create mode 100644 Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift index 598f8e2..c0b7130 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift @@ -6,4 +6,16 @@ struct BankTransferConfig: Equatable { let transactionId: Int let availableProviders: [String] + + let provider: BankTransferProvider + + init(fulfilLateNotification: Bool, + transactionId: Int, + availableProviders: [String], + provider: BankTransferProvider = .standard) { + self.fulfilLateNotification = fulfilLateNotification + self.transactionId = transactionId + self.availableProviders = availableProviders + self.provider = provider + } } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift new file mode 100644 index 0000000..94e285a --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift @@ -0,0 +1,6 @@ +import Foundation + +enum BankTransferProvider: Equatable { + case standard + case pesalink +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift index a3d57a8..662f925 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift @@ -120,8 +120,6 @@ class BankTransferViewModel: ObservableObject { await provisionVirtualAccount() } - // MARK: - Bank picker - var availableBankSlugs: [String] { config.availableProviders } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift index d33cbf1..b2cb1aa 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift @@ -5,7 +5,7 @@ struct BankTransferAccountDetailsView: View { let details: BankTransferDetails let amount: AmountCurrency - let onChangeBank: (() -> Void)? + let provider: BankTransferProvider let onIveSentTheMoney: () async -> Void @State private var provisionedAt: Date = Date() @@ -15,11 +15,11 @@ struct BankTransferAccountDetailsView: View { init(details: BankTransferDetails, amount: AmountCurrency, - onChangeBank: (() -> Void)? = nil, + provider: BankTransferProvider, onIveSentTheMoney: @escaping () async -> Void) { self.details = details self.amount = amount - self.onChangeBank = onChangeBank + self.provider = provider self.onIveSentTheMoney = onIveSentTheMoney } @@ -48,8 +48,7 @@ struct BankTransferAccountDetailsView: View { private var accountCard: some View { VStack(spacing: 0) { AccountDetailRow(label: "BANK NAME", - value: details.bankName, - trailing: bankNameTrailing) + value: details.bankName) divider AccountDetailRow(label: "ACCOUNT NUMBER", value: details.accountNumber, @@ -58,6 +57,13 @@ struct BankTransferAccountDetailsView: View { AccountDetailRow(label: "AMOUNT", value: amount.description, trailing: .copy) + + if provider == .pesalink { + divider + AccountDetailRow(label: "NARRATION / REASON", + value: details.transactionReference, + trailing: .copy) + } } .overlay( RoundedRectangle(cornerRadius: .cornerRadius) @@ -65,13 +71,6 @@ struct BankTransferAccountDetailsView: View { ) } - private var bankNameTrailing: AccountDetailRow.Trailing { - if let handler = onChangeBank { - return .text("Change bank", handler) - } - return .none - } - private var divider: some View { Rectangle() .fill(Color.navy05) diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift index 2cb9ff4..1585a79 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift @@ -6,8 +6,6 @@ struct BankTransferView: View { @StateObject var viewModel: BankTransferViewModel - @State private var showBankPicker = false - init(chargeContainer: ChargeContainer, transactionDetails: VerifyAccessCode, config: BankTransferConfig) { @@ -26,9 +24,7 @@ struct BankTransferView: View { BankTransferAccountDetailsView( details: details, amount: viewModel.transactionDetails.amountCurrency, - onChangeBank: viewModel.availableBankSlugs.isEmpty - ? nil - : { showBankPicker = true }, + provider: viewModel.config.provider, onIveSentTheMoney: { await viewModel.userTappedIveSentTheMoney() }) .id(details.transactionReference) case .confirmingPayment(let details, let phase): @@ -70,18 +66,6 @@ struct BankTransferView: View { } } .task(viewModel.provisionVirtualAccount) - .sheet(isPresented: $showBankPicker) { - BankPickerSheet( - availableSlugs: viewModel.availableBankSlugs, - currentSlug: viewModel.currentBankSlug, - onSelect: { slug in - Task { @MainActor [viewModel] in - await viewModel.userSelectedBank(slug: slug) - } - showBankPicker = false - }, - onCancel: { showBankPicker = false }) - } } private var showsChangePaymentMethodFooter: Bool { diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 4b1a86a..6a24c7c 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -58,11 +58,15 @@ class ChargeViewModel: ObservableObject { if response.paymentChannels.contains(.bankTransfer), let transactionId = response.transactionId { + let provider: BankTransferProvider = + Self.pesalinkCurrencyCodes.contains(response.currency) + ? .pesalink : .standard let config = BankTransferConfig( fulfilLateNotification: response .merchantChannelSettings?.bankTransfer?.fulfilLateNotification ?? false, transactionId: transactionId, - availableProviders: response.channelOptions?.bankTransfer ?? []) + availableProviders: response.channelOptions?.bankTransfer ?? [], + provider: provider) result.append(.bankTransfer(config)) } @@ -126,6 +130,7 @@ extension ChargeViewModel { ] static var promotedSupportedBankCodes: Set = ["00zap"] + static var pesalinkCurrencyCodes: Set = ["KES"] } extension ChargeViewModel: ChargeContainer { diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index 95c5c43..1e15ce8 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -26,21 +26,23 @@ enum SupportedChannel: Equatable, Identifiable { return "Card" case .mobileMoney(let channel): return channel.value - case .bankTransfer: - return "Bank Transfer" + case .bankTransfer(let config): + return config.provider == .pesalink ? "Pesalink" : "Bank Transfer" case .zap: return "Zap" } } - + var image: Image { switch self { case .card: return Image("cardLogo", bundle: .current) case .mobileMoney(let channel): return Self.image(forMobileMoneyKey: channel.key) - case .bankTransfer: - return Image("bankTransferLogo", bundle: .current) + case .bankTransfer(let config): + return config.provider == .pesalink + ? Image("pesalinkLogo", bundle: .current) + : Image("bankTransferLogo", bundle: .current) case .zap: return Image("zapSingleLogo", bundle: .current) } diff --git a/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json new file mode 100644 index 0000000..34173e7 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Group 85.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg new file mode 100644 index 0000000..e71de7c --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 16fb8c7..1899b99 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -233,6 +233,108 @@ final class ChargeViewModelTests: PSTestCase { config: expectedConfig))) } + // MARK: - Pesalink branding (PR K-B) + + /// KES currency → bank-transfer config resolves with `.pesalink` + /// provider, which drives the Pesalink tile + Narration row. + func testBankTransferResolvesToPesalinkProviderForKES() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "KES", + fulfilLateNotification: false, + transactionId: 5678) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .pesalink) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// NGN currency → standard PWT provider, not Pesalink. + func testBankTransferResolvesToStandardProviderForNGN() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "NGN", + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: true, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .standard) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// Regression guard: USD (or any other non-KES currency) → standard. + /// Catches accidental over-broadening of the Pesalink gate. + func testBankTransferResolvesToStandardProviderForUSD() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "USD", + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .standard) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// The `pesalinkCurrencyCodes` static is runtime-mutable so the + /// gate can be widened to a new currency without an SDK release. + func testPesalinkCurrencyCodesStaticIsConfigurable() async { + let originalCodes = ChargeViewModel.pesalinkCurrencyCodes + defer { ChargeViewModel.pesalinkCurrencyCodes = originalCodes } + ChargeViewModel.pesalinkCurrencyCodes = ["KES", "UGX"] + + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "UGX", + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .pesalink) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// Confirms `SupportedChannel.bankTransfer(.pesalink)` produces the + /// Pesalink-branded display title. + func testPesalinkProviderProducesPesalinkDisplayTitle() { + let pesalinkConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: [], + provider: .pesalink) + let standardConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: [], + provider: .standard) + + XCTAssertEqual(SupportedChannel.bankTransfer(pesalinkConfig).displayTitle, + "Pesalink") + XCTAssertEqual(SupportedChannel.bankTransfer(standardConfig).displayTitle, + "Bank Transfer") + } + func testBankTransferDoesNotAppearWhenChannelArrayOmitsIt() async { let response = VerifyAccessCode.with( channels: [.card], @@ -492,6 +594,7 @@ private extension MobileMoneyChannel { private extension VerifyAccessCode { static func with(channels: [PaystackCore.Channel], email: String = "customer@example.com", + currency: String = "USD", mobileMoney: [MobileMoneyChannel]? = nil, bankTransferProviders: [String]? = nil, fulfilLateNotification: Bool? = nil, @@ -508,7 +611,7 @@ private extension VerifyAccessCode { } return VerifyAccessCode(email: email, amount: 10000, - currency: "USD", + currency: currency, accessCode: "test_access", paymentChannels: channels, domain: .test,