diff --git a/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift b/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift new file mode 100644 index 00000000..b658f599 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift @@ -0,0 +1,217 @@ +import SwiftUI + +struct GiftSendView: View { + @StateObject private var viewModel: GiftSendViewModel + @State private var hasAppeared = false + + init(recipientMemberId: Int, recipientCreatorNickname: String) { + _viewModel = StateObject(wrappedValue: GiftSendViewModel( + recipientMemberId: recipientMemberId, + recipientCreatorNickname: recipientCreatorNickname, + onCreated: { applicationNo in + AppState.shared.replaceCurrentAppStep( + step: .giftDetail(applicationNo: applicationNo), + ifCurrentStep: { step in + guard case .giftSend(let memberId, let nickname) = step else { return false } + return memberId == recipientMemberId && nickname == recipientCreatorNickname + } + ) + } + )) + } + + var body: some View { + VStack(spacing: 0) { + DetailNavigationBar(title: "선물 보내기") + .frame(height: 54) + .allowsHitTesting(!viewModel.isSubmitting) + BaseView(isLoading: .constant(viewModel.isLoading || viewModel.isSubmitting)) { + ScrollView { + VStack(alignment: .leading, spacing: SodaSpacing.s24) { + GiftSendBannerView() + recipientSection + sectionDivider + sizeSection + categorySection + sectionDivider + GiftSenderFormView( + name: $viewModel.senderName, + phoneNumber: $viewModel.senderPhoneNumber, + zipCode: $viewModel.senderZipCode, + address: $viewModel.senderAddress, + addressDetail: $viewModel.senderAddressDetail + ) + sectionDivider + agreementsSection + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.vertical, SodaSpacing.s16) + } + .scrollDismissesKeyboard(.interactively) + .safeAreaInset(edge: .bottom, spacing: 0) { + Button { + viewModel.submitGift() + } label: { + SelectedButtonView( + title: viewModel.selectedSize.map { "\($0.salePriceCan)캔으로 선물 보내기" } ?? "선물 보내기", + isActive: viewModel.canSubmit, + isSelected: false + ) + } + .disabled(!viewModel.canSubmit) + .padding(SodaSpacing.s14) + .background(Color.black) + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage) + } + .background(Color.black) + .onAppear { + guard !hasAppeared else { return } + hasAppeared = true + viewModel.fetchFormOptions() + } + } + + private var recipientSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + sectionTitle("받는 크리에이터") + HStack(spacing: SodaSpacing.s12) { + Circle().fill(Color.gray800).frame(width: 38, height: 38) + .accessibilityHidden(true) + Text(viewModel.recipientCreatorNickname) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + Spacer(minLength: 0) + } + .padding(SodaSpacing.s14) + .background(Color.gray900) + .cornerRadius(SodaRadius.r8) + } + } + + private var sizeSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + sectionTitle("선물 사이즈") + helper("시작은 부담 없이 작은 크기를 선택해 주세요") + ForEach(viewModel.sizes, id: \.sizeCode) { size in + Button { + viewModel.selectedSize = size + } label: { + HStack(spacing: SodaSpacing.s12) { + VStack(alignment: .leading, spacing: SodaSpacing.s6) { + Text(sizeTitle(size.sizeCode)) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + if let description = GiftDisplayPolicy.sizeDescription(for: size.sizeCode) { + helper(description) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: SodaSpacing.s4) { + if size.basePriceCan != size.salePriceCan { + Text("\(size.basePriceCan)캔") + .strikethrough() + .appFont(size: 12, weight: .regular) + .foregroundColor(.gray500) + } + Text("\(size.salePriceCan)캔") + .appFont(size: 16, weight: .bold) + .foregroundColor(.soda400) + } + } + .padding(SodaSpacing.s16) + .frame(minHeight: 77) + .background(Color.gray900) + .cornerRadius(SodaRadius.r8) + .overlay(RoundedRectangle(cornerRadius: SodaRadius.r8) + .stroke(viewModel.selectedSize?.sizeCode == size.sizeCode ? Color.soda400 : .clear, lineWidth: 1)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(viewModel.selectedSize?.sizeCode == size.sizeCode ? .isSelected : []) + } + } + } + + private var categorySection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + sectionTitle("카테고리") + helper("택배 기간에는 제한되는 카테고리의 특별한 점들을 꼭 확인합니다") + Menu { + ForEach(viewModel.categories, id: \.categoryId) { category in + Button(category.name) { viewModel.selectCategory(category) } + } + } label: { + HStack { + Text(viewModel.selectedCategory?.name ?? "카테고리 선택") + .appFont(.body3) + Spacer() + Image(systemName: "chevron.down") + } + .foregroundColor(viewModel.selectedCategory == nil ? .gray500 : .white) + .padding(SodaSpacing.s14) + .background(Color.gray900) + .cornerRadius(SodaRadius.r14) + } + .disabled(viewModel.categories.isEmpty) + if viewModel.selectedCategory?.requiresDamageWaiver == true { + GiftAgreementRow(title: "파손 및 분실 면책 사항에 동의합니다.", isAgreed: $viewModel.damageWaiverAgreed) + } + } + } + + private var agreementsSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { + sectionTitle("이용 약관 동의") + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + Text("선물 보내기 신청 전 꼭 확인해 주세요") + .appFont(size: 14, weight: .bold) + .foregroundColor(.grayee) + ForEach(shippingRules, id: \.self) { rule in + HStack(alignment: .top, spacing: SodaSpacing.s6) { + helper("•") + helper(rule) + } + } + } + .padding(SodaSpacing.s14) + .background(Color.gray900) + .cornerRadius(SodaRadius.r8) + GiftAgreementRow(title: "소다라이브 크리에이터 선물 보내기 이용약관에 동의합니다.", + isAgreed: $viewModel.senderTermsAgreed) + GiftAgreementRow(title: "선물 보내기를 위한 개인정보 수집·이용에 동의합니다.", + isAgreed: $viewModel.senderPrivacyAgreed) + } + } + + private var shippingRules: [String] { + [ + "일반 공산품 중 세 변 합 100cm 이하, 5kg 이하, 내품가 40만원 이하의 소형 선물만 접수합니다.", + "신선식품은 접수할 수 없으며, 아크릴·피규어·응원봉 등 파손 위험이 있는 상품은 별도 동의가 필요할 수 있습니다.", + "선물 보내기에 필요한 개인정보는 회원 주소록처럼 장기 저장하지 않고 전달 절차에 필요한 기간에만 일시적으로 이용합니다.", + "선물은 반드시 배송비 선불로 발송해 주세요. 착불로 발송된 선물은 수취가 거부됩니다." + ] + } + + private var sectionDivider: some View { + Color.gray900.frame(height: 7).padding(.horizontal, -SodaSpacing.s14) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title).appFont(.heading3).foregroundColor(.white) + } + + private func helper(_ text: String) -> some View { + Text(text).appFont(size: 12, weight: .regular).foregroundColor(.gray500) + .fixedSize(horizontal: false, vertical: true) + } + + private func sizeTitle(_ code: String) -> String { + switch GiftSize(rawValue: code) { + case .small: return "소형" + case .medium: return "중형" + case .large: return "대형" + case nil: return code + } + } +} diff --git a/SodaLive/Sources/V2/Gift/Send/GiftSendViewModel.swift b/SodaLive/Sources/V2/Gift/Send/GiftSendViewModel.swift new file mode 100644 index 00000000..dffc5671 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Send/GiftSendViewModel.swift @@ -0,0 +1,168 @@ +import Foundation +import Combine + +final class GiftSendViewModel: ObservableObject { + let recipientMemberId: Int + let recipientCreatorNickname: String + + private let repository = GiftRepository() + private let onCreated: (String) -> Void + private var subscription = Set() + private var hasLoaded = false + + @Published private(set) var sizes = [GiftSizeOptionResponse]() + @Published private(set) var categories = [GiftCategoryOptionResponse]() + @Published var selectedSize: GiftSizeOptionResponse? + @Published private(set) var selectedCategory: GiftCategoryOptionResponse? + @Published var senderName = "" + @Published var senderPhoneNumber = "" + @Published var senderZipCode = "" + @Published var senderAddress = "" + @Published var senderAddressDetail = "" + @Published var senderTermsAgreed = false + @Published var senderPrivacyAgreed = false + @Published var damageWaiverAgreed = false + @Published private(set) var isLoading = false + @Published private(set) var isSubmitting = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + var isRecipientContextValid: Bool { + recipientMemberId > 0 + && !recipientCreatorNickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canSubmit: Bool { + !isSubmitting + && isRecipientContextValid + && selectedSize != nil + && selectedCategory != nil + && GiftContactInputRule.name.acceptsSubmission(senderName) + && GiftContactInputRule.phoneNumber.acceptsSubmission(senderPhoneNumber) + && [senderZipCode, senderAddress].allSatisfy { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + && senderTermsAgreed + && senderPrivacyAgreed + && (selectedCategory?.requiresDamageWaiver != true || damageWaiverAgreed) + } + + init(recipientMemberId: Int, recipientCreatorNickname: String, onCreated: @escaping (String) -> Void) { + self.recipientMemberId = recipientMemberId + self.recipientCreatorNickname = recipientCreatorNickname + self.onCreated = onCreated + } + + func selectCategory(_ category: GiftCategoryOptionResponse) { + if selectedCategory?.categoryId != category.categoryId || !category.requiresDamageWaiver { + damageWaiverAgreed = false + } + selectedCategory = category + } + + func submitGift() { + guard canSubmit, let request = makeRequest() else { return } + + isSubmitting = true + errorMessage = "" + isShowPopup = false + + repository.createGift(request: request) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + if case .failure(let error) = result { + self?.showSubmissionError(data: error.response?.data) + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + guard decoded.success, let data = decoded.data else { + self.showSubmissionError(data: response.data) + return + } + self.onCreated(data.applicationNo) + } catch { + self.showSubmissionError(data: response.data) + } + } + .store(in: &subscription) + } + + private func makeRequest() -> CreateGiftRequest? { + guard let selectedSize, let selectedCategory else { return nil } + + return CreateGiftRequest( + recipientMemberId: recipientMemberId, + senderName: senderName.trimmingCharacters(in: .whitespacesAndNewlines), + senderPhoneNumber: senderPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines), + senderZipCode: senderZipCode.trimmingCharacters(in: .whitespacesAndNewlines), + senderAddress: senderAddress.trimmingCharacters(in: .whitespacesAndNewlines), + senderAddressDetail: senderAddressDetail.trimmingCharacters(in: .whitespacesAndNewlines), + sizeCode: selectedSize.sizeCode.trimmingCharacters(in: .whitespacesAndNewlines), + categoryId: selectedCategory.categoryId, + senderTermsAgreed: senderTermsAgreed, + senderPrivacyAgreed: senderPrivacyAgreed, + damageWaiverAgreed: selectedCategory.requiresDamageWaiver && damageWaiverAgreed + ) + } + + private func showSubmissionError(data: Data?) { + struct ErrorResponse: Decodable { + let message: String? + } + + let decoded = data.flatMap { try? JSONDecoder().decode(ErrorResponse.self, from: $0) } + let message = decoded?.message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + errorMessage = message.isEmpty ? I18n.Common.commonError : message + isSubmitting = false + isShowPopup = true + } + + func fetchFormOptions() { + guard isRecipientContextValid else { + errorMessage = I18n.Common.commonError + isShowPopup = true + return + } + guard !isLoading, !hasLoaded else { return } + + isLoading = true + errorMessage = "" + isShowPopup = false + + repository.getFormOptions() + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + + self.isLoading = false + if case .failure = result { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + + if let data = decoded.data, decoded.success { + self.sizes = data.sizes + self.categories = data.categories + self.selectedSize = data.sizes.first + self.selectedCategory = nil + self.hasLoaded = true + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } +}