diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 11fa49d..3325905 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -47,4 +47,8 @@ enum AppStep { case canPayment(canProduct: SKProduct, refresh: () -> Void, afterCompletionToGoBack: Bool = false) case canPgPayment(canResponse: GetCanResponse, refresh: () -> Void, afterCompletionToGoBack: Bool = false) + + case liveReservation + + case liveReservationCancel(reservationId: Int) } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 2218428..4e07bf5 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -74,6 +74,12 @@ struct ContentView: View { case .canPgPayment(let canResponse, let refresh, let afterCompletionToGoBack): CanPgPaymentView(canResponse: canResponse, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + case .liveReservation: + LiveReservationStatusView() + + case .liveReservationCancel(let reservationId): + LiveReservationCancelView(reservationId: reservationId) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Live/LiveApi.swift b/SodaLive/Sources/Live/LiveApi.swift index 4c93c12..3aaefa8 100644 --- a/SodaLive/Sources/Live/LiveApi.swift +++ b/SodaLive/Sources/Live/LiveApi.swift @@ -11,6 +11,9 @@ import Moya enum LiveApi { case roomList(request: GetRoomListRequest) case recentVisitRoomUsers + case getReservations(isActive: Bool) + case getReservation(reservationId: Int) + case cancelReservation(request: CancelLiveReservationRequest) } extension LiveApi: TargetType { @@ -25,13 +28,25 @@ extension LiveApi: TargetType { case .recentVisitRoomUsers: return "/live/room/recent_visit_room/users" + + case .getReservations: + return "/live/reservation" + + case .getReservation(let reservationId): + return "/live/reservation/\(reservationId)" + + case .cancelReservation: + return "/live/reservation/cancel" } } var method: Moya.Method { switch self { - case .roomList, .recentVisitRoomUsers: + case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation: return .get + + case .cancelReservation: + return .put } } @@ -55,6 +70,28 @@ extension LiveApi: TargetType { case .recentVisitRoomUsers: return .requestPlain + + case .getReservations(let isActive): + let parameters = [ + "timezone": TimeZone.current.identifier, + "isActive": isActive + ] as [String : Any] + + return .requestParameters( + parameters: parameters, + encoding: URLEncoding.queryString + ) + + case .getReservation: + let parameters = ["timezone": TimeZone.current.identifier] as [String : Any] + + return .requestParameters( + parameters: parameters, + encoding: URLEncoding.queryString + ) + + case .cancelReservation(let request): + return .requestJSONEncodable(request) } } diff --git a/SodaLive/Sources/Live/LiveRepository.swift b/SodaLive/Sources/Live/LiveRepository.swift index 0555789..d5d6566 100644 --- a/SodaLive/Sources/Live/LiveRepository.swift +++ b/SodaLive/Sources/Live/LiveRepository.swift @@ -20,4 +20,16 @@ final class LiveRepository { func recentVisitRoomUsers() -> AnyPublisher { return api.requestPublisher(.recentVisitRoomUsers) } + + func getReservations(isActive: Bool) -> AnyPublisher { + return api.requestPublisher(.getReservations(isActive: isActive)) + } + + func getReservation(reservationId: Int) -> AnyPublisher { + return api.requestPublisher(.getReservation(reservationId: reservationId)) + } + + func cancelReservation(reservationId: Int, reason: String) -> AnyPublisher { + return api.requestPublisher(.cancelReservation(request: CancelLiveReservationRequest(reservationId: reservationId, reason: reason))) + } } diff --git a/SodaLive/Sources/MyPage/ReservationStatus/Cancel/LiveReservationCancelView.swift b/SodaLive/Sources/MyPage/ReservationStatus/Cancel/LiveReservationCancelView.swift new file mode 100644 index 0000000..4636bbd --- /dev/null +++ b/SodaLive/Sources/MyPage/ReservationStatus/Cancel/LiveReservationCancelView.swift @@ -0,0 +1,235 @@ +// +// LiveReservationCancelView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct LiveReservationCancelView: View { + + @StateObject var viewModel = LiveReservationStatusViewModel() + + let reservationId: Int + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: viewModel.isCancelComplete ? "예약취소 확인" : "예약취소") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + if let item = viewModel.selectedReservationStatusItem { + if viewModel.isCancelComplete { + Text("예약취소가 완료되었습니다.") + .font(.custom(Font.bold.rawValue, size: 20)) + .foregroundColor(Color(hex: "a285eb")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .padding(.top, 33.3) + + if item.price > 0 { + Text("결제한 \(item.price)캔이\n환불처리 되었습니다.") + .font(.custom(Font.medium.rawValue, size: 20)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .padding(.top, 20) + } + + HStack(spacing: 13.3) { + Text("다른 라이브 예약하기") + .font(.custom(Font.medium.rawValue, size: 15)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + Color(hex: "9970ff"), + lineWidth: 1 + ) + ) + .onTapGesture { + AppState.shared.setAppStep(step: .main) + } + + Text("캔내역 확인하기") + .font(.custom(Font.medium.rawValue, size: 15)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + AppState.shared.setAppStep(step: .canStatus(refresh: {})) + } + } + .frame(width: screenSize().width - 26.7, alignment: .leading) + .padding(.top, 20) + } else { + HStack(spacing: 20) { + KFImage(URL(string: item.coverImageUrl)) + .resizable() + .scaledToFill() + .frame(width: 80, height: 116.7, alignment: .top) + .cornerRadius(4.7) + + VStack(alignment: .leading, spacing: 0) { + Text(item.beginDateTime) + .font(.custom(Font.medium.rawValue, size: 9.3)) + .foregroundColor(Color(hex: "ffd300")) + + Text(item.masterNickname) + .font(.custom(Font.medium.rawValue, size: 11.3)) + .foregroundColor(Color(hex: "bbbbbb")) + .padding(.top, 10) + + Text(item.title) + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "e2e2e2")) + .lineLimit(2) + .padding(.top, 4.3) + + Text( + item.price > 0 ? + "\(item.price)캔" : + "무료" + ) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "e2e2e2").opacity(0.4)) + .padding(.top, 15.3) + } + } + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + .padding(.top, 20) + + Rectangle() + .foregroundColor(Color(hex: "909090").opacity(0.5)) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width, height: 1) + .padding(.top, 13.3) + + VStack(spacing: 13.3) { + Text("예약을 취소하시겠습니까?") + .font(.custom(Font.bold.rawValue, size: 20)) + .foregroundColor(Color(hex: "a285eb")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + + Text("예약취소 이유를 선택해주세요. 서비스 개선에 중요한 자료로 활용하겠습니다.") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + } + .padding(.top, 40) + + VStack(alignment: .leading, spacing: 16.7) { + ForEach(0.. 0 ? + "\(item.price)캔" : + "무료" + ) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "e2e2e2").opacity(0.4)) + + Spacer() + + if !item.cancelable { + Text("예약 취소 불가") + .font(.custom(Font.light.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "777777")) + } + } + .padding(.top, 15.3) + } + .padding(.leading, 20) + .padding(.trailing, 16.3) + + if item.cancelable { + Spacer() + + Text("예약\n취소") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "9970ff")) + .padding(10.7) + .overlay( + RoundedRectangle(cornerRadius: 6.7) + .stroke( + Color(hex: "9970ff"), + lineWidth: 1 + ) + ) + .onTapGesture { + AppState.shared.setAppStep( + step: .liveReservationCancel(reservationId: item.reservationId) + ) + } + } + } + .padding(13.3) + .background(Color.black) + } +} + +struct LiveReservationStatusItemView_Previews: PreviewProvider { + static var previews: some View { + LiveReservationStatusItemView( + item: GetLiveReservationResponse( + reservationId: 0, + roomId: 1, + title: "여자들이 좋아하는 남자 스타일은??여자들이 좋아하는 남자 스타일은??여자들이 좋아하는 남자 스타일은??", + coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + price: 0, + masterNickname: "사냥꾼1004", + beginDateTime: "2021.05.20 SUN 10p.m", + cancelable: false + ) + ) + } +} diff --git a/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusView.swift b/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusView.swift new file mode 100644 index 0000000..89a4667 --- /dev/null +++ b/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusView.swift @@ -0,0 +1,65 @@ +// +// LiveReservationStatusView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct LiveReservationStatusView: View { + + @StateObject var viewModel = LiveReservationStatusViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "라이브 예약 현황") + + if viewModel.reservationStatusItems.count > 0 { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 13.3) { + ForEach(viewModel.reservationStatusItems, id: \.self) { item in + LiveReservationStatusItemView(item: item) + } + } + .padding(.vertical, 13.3) + } + } else { + Text("예약한 라이브가 없습니다.") + .font(.custom(Font.medium.rawValue, size: 15)) + .foregroundColor(Color(hex: "bbbbbb")) + .frame(maxHeight: .infinity) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getSudaReservationStatus() + } + } + } +} + +struct LiveReservationStatusView_Previews: PreviewProvider { + static var previews: some View { + LiveReservationStatusView() + } +} diff --git a/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusViewModel.swift b/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusViewModel.swift new file mode 100644 index 0000000..f6a2ab1 --- /dev/null +++ b/SodaLive/Sources/MyPage/ReservationStatus/LiveReservationStatusViewModel.swift @@ -0,0 +1,149 @@ +// +// LiveReservationStatusViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +final class LiveReservationStatusViewModel: ObservableObject { + + private let repository = LiveRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var reservationStatusItems = [GetLiveReservationResponse]() + @Published var selectedReservationStatusItem: GetLiveReservationResponse? + + let cancelReasons = ["중요한 개인일정이 생겨서", "다른 라이브에 참여하고 싶어서", "라이브 참여자 중 불편한 사람이 있어서", "참여비용이 부담되서", "기타"] + @Published var cancelReasonSelectedIndex = -1 + @Published var reason = "" + + @Published var isCancelComplete = false + + func getSudaReservationStatus() { + isLoading = true + + repository.getReservations(isActive: true) + .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<[GetLiveReservationResponse]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.reservationStatusItems.append(contentsOf: data) + } 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 getReservation(reservationId: Int) { + isLoading = true + + repository.getReservation(reservationId: reservationId) + .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 { + self.selectedReservationStatusItem = data + } 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 cancelReservation(reservationId: Int) { + if cancelReasonSelectedIndex < 0 || (cancelReasonSelectedIndex == self.cancelReasons.count - 1 && self.reason.trimmingCharacters(in: .whitespaces).count <= 0) { + self.errorMessage = "취소이유를 선택해주세요." + self.isShowPopup = true + return + } + + isLoading = true + + let reason = cancelReasonSelectedIndex == cancelReasons.count - 1 ? self.reason : cancelReasons[cancelReasonSelectedIndex] + repository.cancelReservation(reservationId: reservationId, reason: reason) + .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 { + self.isCancelComplete = true + } 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/ReservationStatusView.swift b/SodaLive/Sources/MyPage/ReservationStatusView.swift index 96a95e1..3673316 100644 --- a/SodaLive/Sources/MyPage/ReservationStatusView.swift +++ b/SodaLive/Sources/MyPage/ReservationStatusView.swift @@ -40,6 +40,7 @@ struct ReservationStatusView: View { .stroke(Color(hex: "9970ff"), lineWidth: 1.3) ) .onTapGesture { + AppState.shared.setAppStep(step: .liveReservation) } } }