diff --git a/SodaLive/Resources/payverse_starter.html b/SodaLive/Resources/payverse_starter.html new file mode 100644 index 0000000..d583994 --- /dev/null +++ b/SodaLive/Resources/payverse_starter.html @@ -0,0 +1,31 @@ +// +// Untitled.swift +// SodaLive +// +// Created by klaus on 9/29/25. +// + + + + + + + + + + + + + diff --git a/SodaLive/Resources/payverse_starter_debug.html b/SodaLive/Resources/payverse_starter_debug.html new file mode 100644 index 0000000..6df7103 --- /dev/null +++ b/SodaLive/Resources/payverse_starter_debug.html @@ -0,0 +1,58 @@ + + + + + + PayVerse Starter + + + + + +
+

PayVerse Starter

+

결제 모듈 로드를 준비중입니다...

+
+
+ + + diff --git a/SodaLive/Sources/App/AppViewModel.swift b/SodaLive/Sources/App/AppViewModel.swift index 36f2536..df6b5dd 100644 --- a/SodaLive/Sources/App/AppViewModel.swift +++ b/SodaLive/Sources/App/AppViewModel.swift @@ -107,4 +107,8 @@ final class AppViewModel: ObservableObject { .store(in: &subscription) } } + + func handlePayverseOpenURL(_ url: URL) { + + } } diff --git a/SodaLive/Sources/App/SodaLiveApp.swift b/SodaLive/Sources/App/SodaLiveApp.swift index 203c19d..9ff2831 100644 --- a/SodaLive/Sources/App/SodaLiveApp.swift +++ b/SodaLive/Sources/App/SodaLiveApp.swift @@ -17,9 +17,11 @@ struct SodaLiveApp: App { @ObservedObject var viewModel = AppViewModel() + @StateObject var canPgPaymentViewModel = CanPgPaymentViewModel() + var body: some Scene { WindowGroup { - ContentView() + ContentView(canPgPaymentViewModel: canPgPaymentViewModel) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in CreatorCommunityMediaPlayerManager.shared.pauseContent() } @@ -34,8 +36,16 @@ struct SodaLiveApp: App { } .onOpenURL { url in DEBUG_LOG("I have received a URL through a custom scheme! \(url.absoluteString)") - ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:]) - AppsFlyerLib.shared().handleOpen(url) + + if let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + url.scheme?.lowercased() == APPSCHEME.lowercased(), + comps.host?.lowercased() == "payverse", + comps.path.lowercased() == "/result" { + canPgPaymentViewModel.handleVerifyOpenURL(url) + } else { + ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:]) + AppsFlyerLib.shared().handleOpen(url) + } } } } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 57d1880..cadc5e8 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI struct ContentView: View { + @ObservedObject var canPgPaymentViewModel: CanPgPaymentViewModel @StateObject private var appState = AppState.shared @State private var isShowDialog = false @@ -86,6 +87,7 @@ struct ContentView: View { case .canPgPayment(let canResponse, let refresh, let afterCompletionToGoBack): CanPgPaymentView(canResponse: canResponse, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + .environmentObject(canPgPaymentViewModel) case .liveReservation: LiveReservationStatusView() @@ -304,6 +306,6 @@ struct ContentView: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView(canPgPaymentViewModel: CanPgPaymentViewModel()) } } diff --git a/SodaLive/Sources/Debug/Utils/Constants.swift b/SodaLive/Sources/Debug/Utils/Constants.swift index d58e278..61bb709 100644 --- a/SodaLive/Sources/Debug/Utils/Constants.swift +++ b/SodaLive/Sources/Debug/Utils/Constants.swift @@ -19,3 +19,6 @@ let BOOTPAY_APP_HECTO_ID = "667fca5d3bab7404f831c3e5" let NOTIFLY_PROJECT_ID = "5f7ebe90d1ce5f0392164b8a53a662bc" let NOTIFLY_USERNAME = "voiceon" let NOTIFLY_PASSWORD = "c6c585db0aaa4189be44d0467c7d66b6@A" + +let APPSCHEME = "voiceon-test" +let PAYVERSE_HTML_RESOURCE = "payverse_starter_debug" diff --git a/SodaLive/Sources/MyPage/Can/CanApi.swift b/SodaLive/Sources/MyPage/Can/CanApi.swift index 0766dfe..17bdabd 100644 --- a/SodaLive/Sources/MyPage/Can/CanApi.swift +++ b/SodaLive/Sources/MyPage/Can/CanApi.swift @@ -22,6 +22,9 @@ enum CanApi { case pgVerifyHecto(request: PgVerifyRequest) case useCanCoupon(request: UseCanCouponRequest) + + case payverseChargeCan(request: PayverseChargeRequest) + case payverseVerify(transactionId: String, orderId: String) } extension CanApi: TargetType { @@ -60,6 +63,12 @@ extension CanApi: TargetType { case .useCanCoupon: return "/can/coupon/use" + + case .payverseChargeCan: + return "/charge/payverse" + + case .payverseVerify: + return "/charge/payverse/verify" } } @@ -68,7 +77,7 @@ extension CanApi: TargetType { case .getCanStatus, .getCanChargeStatus, .getCanUseStatus, .getCans: return .get - case .chargeCan, .verify, .pgChargeCan, .pgVerify, .pgVerifyHecto, .useCanCoupon: + case .chargeCan, .verify, .pgChargeCan, .pgVerify, .pgVerifyHecto, .useCanCoupon, .payverseChargeCan, .payverseVerify: return .post } } @@ -112,6 +121,14 @@ extension CanApi: TargetType { case .useCanCoupon(let request): return .requestJSONEncodable(request) + + case .payverseChargeCan(let request): + return .requestJSONEncodable(request) + + case .payverseVerify(let transactionId, let orderId): + return .requestJSONEncodable( + PayverseVerifyRequest(transactionId: transactionId, orderId: orderId) + ) } } diff --git a/SodaLive/Sources/MyPage/Can/CanRepository.swift b/SodaLive/Sources/MyPage/Can/CanRepository.swift index 2debe48..b1dbfab 100644 --- a/SodaLive/Sources/MyPage/Can/CanRepository.swift +++ b/SodaLive/Sources/MyPage/Can/CanRepository.swift @@ -53,5 +53,13 @@ final class CanRepository { let request = UseCanCouponRequest(couponNumber: couponNumber) return api.requestPublisher(.useCanCoupon(request: request)) } + + func payverseChargeCan(canId: Int) -> AnyPublisher { + return api.requestPublisher(.payverseChargeCan(request: PayverseChargeRequest(canId: canId))) + } + + func payverseVerify(transactionId: String, orderId: String) -> AnyPublisher { + return api.requestPublisher(.payverseVerify(transactionId: transactionId, orderId: orderId)) + } } diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift index 0d7683f..4abfc1d 100644 --- a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift @@ -11,12 +11,14 @@ import BootpayUI struct CanPgPaymentView: View { - @StateObject var viewModel = CanPgPaymentViewModel() + @EnvironmentObject var viewModel: CanPgPaymentViewModel let canResponse: GetCanResponse let refresh: () -> Void let afterCompletionToGoBack: Bool + @State private var showExitConfirm: Bool = false + init(canResponse: GetCanResponse, refresh: @escaping () -> Void, afterCompletionToGoBack: Bool) { self.canResponse = canResponse self.refresh = refresh @@ -64,6 +66,37 @@ struct CanPgPaymentView: View { DEBUG_LOG("onClose") viewModel.isShowPaymentView = false } + } else if viewModel.isShowPayversePaymentView { + ZStack(alignment: .topLeading) { + PayverseWebView(startPayloadJson: viewModel.payversePayloadJson) + .ignoresSafeArea(edges: .bottom) + HStack(spacing: 8) { + Button(action: { showExitConfirm = true }) { + Text("닫기") + .font(.custom(Font.bold.rawValue, size: 14)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.6)) + .clipShape(Capsule()) + } + .padding(.leading, 16) + .padding(.top, 12) + Spacer() + } + } + .background(Color.black.ignoresSafeArea()) + .alert("결제를 종료할까요?", isPresented: $showExitConfirm) { + Button("계속", role: .cancel) { } + Button("종료", role: .destructive) { + DEBUG_LOG("Payverse: user requested to exit") + viewModel.isShowPayversePaymentView = false + // 필요 시 상위로 복귀 + AppState.shared.back() + } + } message: { + Text("진행 중인 결제를 중단하고 이전 화면으로 돌아갑니다.") + } } else { GeometryReader { proxy in VStack(spacing: 0) { @@ -102,15 +135,14 @@ struct CanPgPaymentView: View { .frame(width: screenSize().width - 26.7, alignment: .leading) .padding(.top, 26.7) - - HStack(spacing: 13.3) { - Text("카드") - .font(.custom( viewModel.paymentMethod == .card ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) - .foregroundColor(viewModel.paymentMethod == .card ? Color.button : Color.grayee) + HStack(spacing: 16.7) { + Text("통합 결제") + .font(.custom( viewModel.paymentMethod == .unified ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) + .foregroundColor(viewModel.paymentMethod == .unified ? Color.button : Color.grayee) .frame(maxWidth: .infinity) .padding(.vertical, 16.7) .background( - viewModel.paymentMethod == .card ? + viewModel.paymentMethod == .unified ? Color.button.opacity(0.3) : Color.gray23 ) @@ -118,60 +150,11 @@ struct CanPgPaymentView: View { .overlay( RoundedRectangle(cornerRadius: 10) .stroke(lineWidth: 1) - .foregroundColor(viewModel.paymentMethod == .card ? Color.button : Color.gray77) + .foregroundColor(viewModel.paymentMethod == .unified ? Color.button : Color.gray77) ) .onTapGesture { - if viewModel.paymentMethod != .card { - viewModel.paymentMethod = .card - } - } - - - Text("계좌이체") - .font(.custom( viewModel.paymentMethod == .bank ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) - .foregroundColor(viewModel.paymentMethod == .bank ? Color.button : Color.grayee) - .frame(maxWidth: .infinity) - .padding(.vertical, 16.7) - .background( - viewModel.paymentMethod == .bank ? - Color.button.opacity(0.3) : - Color.gray23 - ) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1) - .foregroundColor(viewModel.paymentMethod == .bank ? Color.button : Color.gray77) - ) - .onTapGesture { - if viewModel.paymentMethod != .bank { - viewModel.paymentMethod = .bank - } - } - } - .frame(width: screenSize().width - 26.7) - .padding(.top, 16.7) - - HStack(spacing: 13.3) { - Text("휴대폰 결제") - .font(.custom( viewModel.paymentMethod == .phone ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) - .foregroundColor(viewModel.paymentMethod == .phone ? Color.button : Color.grayee) - .frame(maxWidth: .infinity) - .padding(.vertical, 16.7) - .background( - viewModel.paymentMethod == .phone ? - Color.button.opacity(0.3) : - Color.gray23 - ) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: 1) - .foregroundColor(viewModel.paymentMethod == .phone ? Color.button : Color.gray77) - ) - .onTapGesture { - if viewModel.paymentMethod != .phone { - viewModel.paymentMethod = .phone + if viewModel.paymentMethod != .unified { + viewModel.paymentMethod = .unified } } @@ -195,6 +178,28 @@ struct CanPgPaymentView: View { viewModel.paymentMethod = .kakaopay } } + + Text("휴대폰 결제") + .font(.custom( viewModel.paymentMethod == .phone ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) + .foregroundColor(viewModel.paymentMethod == .phone ? Color.button : Color.grayee) + .frame(maxWidth: .infinity) + .padding(.vertical, 16.7) + .background( + viewModel.paymentMethod == .phone ? + Color.button.opacity(0.3) : + Color.gray23 + ) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1) + .foregroundColor(viewModel.paymentMethod == .phone ? Color.button : Color.gray77) + ) + .onTapGesture { + if viewModel.paymentMethod != .phone { + viewModel.paymentMethod = .phone + } + } } .frame(width: screenSize().width - 26.7) .padding(.top, 16.7) @@ -299,12 +304,16 @@ struct CanPgPaymentView: View { viewModel.errorMessage = "결제진행에 동의하셔야 결제가 가능합니다." viewModel.isShowPopup = true } else { - viewModel.chargeCan(canId: canResponse.id) { - viewModel.payload.orderName = canResponse.title - viewModel.payload.price = Double(canResponse.price) - viewModel.payload.taxFree = 0 - - viewModel.isShowPaymentView = true + if viewModel.paymentMethod == .unified { + viewModel.payverseChargeCan(canId: canResponse.id) + } else { + viewModel.chargeCan(canId: canResponse.id) { + viewModel.payload.orderName = canResponse.title + viewModel.payload.price = Double(canResponse.price) + viewModel.payload.taxFree = 0 + + viewModel.isShowPaymentView = true + } } } } @@ -348,6 +357,21 @@ struct CanPgPaymentView: View { LoadingView() } } + .onAppear { + viewModel.canResponse = canResponse + viewModel.refresh = refresh + viewModel.afterCompletionToGoBack = afterCompletionToGoBack + } + .onDisappear { + viewModel.canResponse = nil + viewModel.refresh = nil + viewModel.afterCompletionToGoBack = nil + viewModel.paymentMethod = nil + viewModel.isTermsAgree = false + viewModel.isShowPaymentView = false + viewModel.isShowPayversePaymentView = false + } + } } diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift index 448cb0e..152f3f8 100644 --- a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift @@ -11,8 +11,7 @@ import Combine import Bootpay enum PaymentMethod: String { - case card = "카드" - case bank = "계좌이체" + case unified = "통합 결제" case phone = "휴대폰" case kakaopay = "카카오페이" } @@ -28,9 +27,15 @@ final class CanPgPaymentViewModel: ObservableObject { @Published var isLoading = false @Published var isShowPaymentView = false + @Published var isShowPayversePaymentView = false @Published var paymentMethod: PaymentMethod? = nil let payload = Payload() + var payversePayloadJson: String = "" + + var canResponse: GetCanResponse? = nil + var refresh: (() -> Void)? = nil + var afterCompletionToGoBack: Bool? = nil func chargeCan(canId: Int, onSuccess: @escaping () -> Void) { isLoading = true @@ -137,4 +142,139 @@ final class CanPgPaymentViewModel: ObservableObject { isShowPopup = true } } + + func payverseChargeCan(canId: Int) { + isLoading = true + repository.payverseChargeCan(canId: canId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + if let payloadJson = data.payloadJson.data(using: .utf8) { + var obj = try JSONSerialization.jsonObject(with: payloadJson, options: []) as? [String: Any] ?? [:] + obj["returnUrl"] = "\(APPSCHEME)://payverse/result" + obj["webhookUrl"] = "\(BASE_URL)/charge/payverse/webhook" + obj["appScheme"] = APPSCHEME + let merged = try JSONSerialization.data(withJSONObject: obj, options: []) + self.payversePayloadJson = String(data: merged, encoding: .utf8)! + self.isShowPayversePaymentView = true + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + return + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func handleVerifyOpenURL(_ url: URL) { + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + return + } + let q = comps.queryItems?.reduce(into: [String:String]()) { $0[$1.name] = $1.value } ?? [:] + + let tid = q["tid"] ?? q["tx_id"] + let orderId = q["orderId"] + let resultStatus = q["resultStatus"] + + if resultStatus == "FAILED" || + resultStatus == "DECLINE" || + orderId == nil || orderId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true || + tid == nil || tid?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + AppState.shared.back() + } + return + } else { + payverseVerify(transactionId: tid!, orderId: orderId!) + } + } + + func payverseVerify(transactionId: String, orderId: String) { + isLoading = true + repository.payverseVerify(transactionId: transactionId, orderId: orderId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + if let canResponse = self.canResponse { + let can = UserDefaults.int(forKey: .can) + UserDefaults.set(can + canResponse.can + canResponse.rewardCan, forKey: .can) + + if let refresh = refresh { + refresh() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if let afterCompletionToGoBack = self.afterCompletionToGoBack, afterCompletionToGoBack { + AppState.shared.back() + AppState.shared.back() + } else { + if let refresh = self.refresh { + AppState.shared.setAppStep(step: .canStatus(refresh: refresh)) + } else { + AppState.shared.setAppStep(step: .canStatus(refresh: {})) + } + } + } + } else { + AppState.shared.setAppStep(step: .canStatus(refresh: {})) + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } } diff --git a/SodaLive/Sources/MyPage/Can/Payment/Payverse/PayverseChargeDto.swift b/SodaLive/Sources/MyPage/Can/Payment/Payverse/PayverseChargeDto.swift new file mode 100644 index 0000000..76bc4a6 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/Payverse/PayverseChargeDto.swift @@ -0,0 +1,20 @@ +// +// PayverseChargeDto.swift +// SodaLive +// +// Created by klaus on 9/29/25. +// + +struct PayverseChargeRequest: Encodable { + let canId: Int +} + +struct PayverseChargeResponse: Decodable { + let chargeId: Int + let payloadJson: String +} + +struct PayverseVerifyRequest: Encodable { + let transactionId: String + let orderId: String +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/PayverseWebView.swift b/SodaLive/Sources/MyPage/Can/Payment/PayverseWebView.swift new file mode 100644 index 0000000..ae1787d --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/PayverseWebView.swift @@ -0,0 +1,247 @@ +// +// PayverseWebView.swift +// SodaLive +// +// Created by klaus on 9/29/25. +// + +import SwiftUI +import WebKit + +struct PayverseWebView: UIViewRepresentable { + let startPayloadJson: String + + func makeUIView(context: Context) -> WKWebView { + // WKWebView 구성: JavaScript 허용 및 팝업 허용 + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = true + config.preferences.javaScriptCanOpenWindowsAutomatically = true + config.websiteDataStore = .default() + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + + // 빈 화면을 검은 화면으로 보기 위해 다크 모드 강제 적용 + if #available(iOS 13.0, *) { + webView.overrideUserInterfaceStyle = .dark + } + webView.isOpaque = false + webView.backgroundColor = .black + webView.scrollView.backgroundColor = .black + webView.allowsBackForwardNavigationGestures = true + webView.scrollView.contentInsetAdjustmentBehavior = .never + + // 번들 리소스 로딩 (PAYVERSE_HTML_RESOURCE.html) + if let url = Bundle.main.url(forResource: PAYVERSE_HTML_RESOURCE, + withExtension: "html") { + // 로컬 파일 접근 권한을 위해 디렉터리 권한 부여 + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + DEBUG_LOG("[DEBUG_LOG] Loading local Payverse HTML: \(url.lastPathComponent)") + } else { + DEBUG_LOG("[ERROR_LOG] Payverse HTML resource not found: \(PAYVERSE_HTML_RESOURCE).html") + } + + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(startPayloadJson: startPayloadJson) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let startPayloadJson: String + init(startPayloadJson: String) { + self.startPayloadJson = startPayloadJson + } + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow); return + } + + updateBlindViewIfNaverLogin(webView, url.absoluteString) + + // 커스텀 스킴: myapp://payverse/result?... → 앱으로 전환 + if url.scheme?.lowercased() == APPSCHEME.lowercased() { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + decisionHandler(.cancel) + } else if(url.scheme?.lowercased() == "file") { + // 로컬 리소스(file://)는 WebView에서 로드 허용 + decisionHandler(.allow) + } else if(url.absoluteString.starts(with: "about:blank")) { + decisionHandler(.allow) + } else if(isItunesURL(url.absoluteString)) { + startAppToApp(url) + decisionHandler(.cancel) + } else if(!url.absoluteString.starts(with: "http")) { + // http/https 이외의 스킴은 외부 앱/App Store 처리 + startAppToApp(url) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } + + // 리소스 로드 성공 여부 확인 + 결제 시작 호출 + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("document.readyState") { value, error in + if let state = value as? String { + DEBUG_LOG("[DEBUG_LOG] WebView readyState: \(state)") + } + if let error = error { + DEBUG_LOG("[ERROR_LOG] readyState eval error: \(error.localizedDescription)") + } + } + // startPay 가 노출되어 있는지 확인 로그 + webView.evaluateJavaScript("typeof startPay === 'function'") { value, _ in + if let ok = value as? Bool { + DEBUG_LOG("[DEBUG_LOG] startPay function available: \(ok)") + } + } + // JSON 문자열을 JS 문자열 리터럴에 안전하게 담기 위해 이스케이프 처리 + func esc(_ s: String) -> String { + var r = s.replacingOccurrences(of: "\\", with: "\\\\") + r = r.replacingOccurrences(of: "'", with: "\\'") + r = r.replacingOccurrences(of: "\n", with: "\\n") + r = r.replacingOccurrences(of: "\r", with: "") + r = r.replacingOccurrences(of: "\u{2028}", with: "\\u2028") + r = r.replacingOccurrences(of: "\u{2029}", with: "\\u2029") + r = r.replacingOccurrences(of: " Bool { + return isMatch(urlString, "\\/\\/itunes\\.apple\\.com\\/") + } + + func isMatch(_ urlString: String, _ pattern: String) -> Bool { + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let result = regex.matches(in: urlString, options: [], range: NSRange(location: 0, length: urlString.count)) + return result.count > 0 + } + + func startAppToApp(_ url: URL) { + UIApplication.shared.open(url, options: [:], completionHandler: { result in + if(result == false) { + self.startItunesToInstall(url) + } + }) + } + + func startItunesToInstall(_ url: URL) { + let sUrl = url.absoluteString + var itunesUrl = "" + + if(sUrl.starts(with: "kfc-bankpay")) { + itunesUrl = "https://apps.apple.com/kr/app/%EB%B1%85%ED%81%AC%ED%8E%98%EC%9D%B4-%EA%B8%88%EC%9C%B5%EA%B8%B0%EA%B4%80-%EA%B3%B5%EB%8F%99-%EA%B3%84%EC%A2%8C%EC%9D%B4%EC%B2%B4-%EA%B2%B0%EC%A0%9C-%EC%A0%9C%EB%A1%9C%ED%8E%98%EC%9D%B4/id398456030" + } else if(sUrl.starts(with: "ispmobile")) { + itunesUrl = "https://apps.apple.com/kr/app/isp/id369125087" + } else if(sUrl.starts(with: "hdcardappcardansimclick") || sUrl.starts(with: "smhyundaiansimclick")) { + itunesUrl = "https://apps.apple.com/kr/app/%ED%98%84%EB%8C%80%EC%B9%B4%EB%93%9C/id702653088" + } else if(sUrl.starts(with: "shinhan-sr-ansimclick") || sUrl.starts(with: "smshinhanansimclick")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%8B%A0%ED%95%9C%ED%8E%98%EC%9D%B4%ED%8C%90/id572462317" + } else if(sUrl.starts(with: "kb-acp")) { + itunesUrl = "https://apps.apple.com/kr/app/kb-pay/id695436326" + } else if(sUrl.starts(with: "liivbank")) { + itunesUrl = "https://apps.apple.com/kr/app/%EB%A6%AC%EB%B8%8C/id1126232922" + } else if(sUrl.starts(with: "mpocket.online.ansimclick") || sUrl.starts(with: "ansimclickscard") || sUrl.starts(with: "ansimclickipcollect") || sUrl.starts(with: "samsungpay") || sUrl.starts(with: "scardcertiapp")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%82%BC%EC%84%B1%EC%B9%B4%EB%93%9C/id535125356" + } else if(sUrl.starts(with: "lottesmartpay")) { + itunesUrl = "https://apps.apple.com/us/app/%EB%A1%AF%EB%8D%B0%EC%B9%B4%EB%93%9C-%EC%95%B1%EC%B9%B4%EB%93%9C/id688047200" + } else if(sUrl.starts(with: "lotteappcard")) { + itunesUrl = "https://apps.apple.com/kr/app/%EB%94%94%EC%A7%80%EB%A1%9C%EC%B9%B4-%EB%A1%AF%EB%8D%B0%EC%B9%B4%EB%93%9C/id688047200" + } else if(sUrl.starts(with: "newsmartpib")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%9A%B0%EB%A6%AC-won-%EB%B1%85%ED%82%B9/id1470181651" + } else if(sUrl.starts(with: "com.wooricard.wcard")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%9A%B0%EB%A6%ACwon%EC%B9%B4%EB%93%9C/id1499598869" + } else if(sUrl.starts(with: "citispay") || sUrl.starts(with: "citicardappkr") || sUrl.starts(with: "citimobileapp")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%94%A8%ED%8B%B0%EB%AA%A8%EB%B0%94%EC%9D%BC/id1179759666" + } else if(sUrl.starts(with: "shinsegaeeasypayment")) { + itunesUrl = "https://apps.apple.com/kr/app/ssgpay/id666237916" + } else if(sUrl.starts(with: "cloudpay")) { + itunesUrl = "https://apps.apple.com/kr/app/%ED%95%98%EB%82%98%EC%B9%B4%EB%93%9C-%EC%9B%90%ED%81%90%ED%8E%98%EC%9D%B4/id847268987" + } else if(sUrl.starts(with: "hanawalletmembers")) { + itunesUrl = "https://apps.apple.com/kr/app/n-wallet/id492190784" + } else if(sUrl.starts(with: "nhappvardansimclick")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%98%AC%EC%9B%90%ED%8E%98%EC%9D%B4-nh%EC%95%B1%EC%B9%B4%EB%93%9C/id1177889176" + } else if(sUrl.starts(with: "nhallonepayansimclick") || sUrl.starts(with: "nhappcardansimclick") || sUrl.starts(with: "nhallonepayansimclick") || sUrl.starts(with: "nonghyupcardansimclick")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%98%AC%EC%9B%90%ED%8E%98%EC%9D%B4-nh%EC%95%B1%EC%B9%B4%EB%93%9C/id1177889176" + } else if(sUrl.starts(with: "payco")) { + itunesUrl = "https://apps.apple.com/kr/app/payco/id924292102" + } else if(sUrl.starts(with: "lpayapp") || sUrl.starts(with: "lmslpay")) { + itunesUrl = "https://apps.apple.com/kr/app/l-point-with-l-pay/id473250588" + } else if(sUrl.starts(with: "naversearchapp")) { + itunesUrl = "https://apps.apple.com/kr/app/%EB%84%A4%EC%9D%B4%EB%B2%84-naver/id393499958" + } else if(sUrl.starts(with: "tauthlink")) { + itunesUrl = "https://apps.apple.com/kr/app/pass-by-skt/id1141258007" + } else if(sUrl.starts(with: "uplusauth") || sUrl.starts(with: "upluscorporation")) { + itunesUrl = "https://apps.apple.com/kr/app/pass-by-u/id1147394645" + } else if(sUrl.starts(with: "ktauthexternalcall")) { + itunesUrl = "https://apps.apple.com/kr/app/pass-by-kt/id1134371550" + } else if(sUrl.starts(with: "supertoss")) { + itunesUrl = "https://apps.apple.com/kr/app/%ED%86%A0%EC%8A%A4/id839333328" + } else if(sUrl.starts(with: "kakaotalk")) { + itunesUrl = "https://apps.apple.com/kr/app/kakaotalk/id362057947" + } else if(sUrl.starts(with: "chaipayment")) { + itunesUrl = "https://apps.apple.com/kr/app/%EC%B0%A8%EC%9D%B4/id1459979272" + } else if(sUrl.starts(with: "ukbanksmartbanknonloginpay")) { + itunesUrl = "https://itunes.apple.com/kr/developer/%EC%BC%80%EC%9D%B4%EB%B1%85%ED%81%AC/id1178872626?mt=8" + } else if(sUrl.starts(with: "newliiv")) { + itunesUrl = "https://apps.apple.com/us/app/%EB%A6%AC%EB%B8%8C-next/id1573528126" + } else if(sUrl.starts(with: "kbbank")) { + itunesUrl = "https://apps.apple.com/kr/app/kb%EC%8A%A4%ED%83%80%EB%B1%85%ED%82%B9/id373742138" + } + + if(itunesUrl.count > 0) { + if let appstore = URL(string: itunesUrl) { + startAppToApp(appstore) + } + } + } + } +} diff --git a/SodaLive/Sources/Utils/Constants.swift b/SodaLive/Sources/Utils/Constants.swift index 17e44a5..459d37f 100644 --- a/SodaLive/Sources/Utils/Constants.swift +++ b/SodaLive/Sources/Utils/Constants.swift @@ -19,3 +19,6 @@ let BOOTPAY_APP_HECTO_ID = "664c1707b18b225deca4b42a" let NOTIFLY_PROJECT_ID = "765102ec85855aa680da35f1b0f55712" let NOTIFLY_USERNAME = "voiceon" let NOTIFLY_PASSWORD = "c6c585db0aaa4189be44d0467c7d66b6@A" + +let APPSCHEME = "voiceon" +let PAYVERSE_HTML_RESOURCE = "payverse_starter"