feat(gift): 수령지 입력을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:59 +09:00
parent 52f96f29ca
commit b0336902e6
4 changed files with 445 additions and 0 deletions
@@ -0,0 +1,112 @@
import SwiftUI
struct GiftRecipientAddressFormView: View {
let model: ReceivedGiftDetailDisplayModel
@ObservedObject var viewModel: GiftRecipientAddressViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: SodaSpacing.s32) {
privacyBanner
GiftInfoSectionView(title: "전달 예정 선물") {
if let nickname = model.senderNickname {
GiftInfoRow(title: "보낸 사람", value: nickname)
}
GiftInfoRow(title: "선물 사이즈", value: model.sizeName)
GiftInfoRow(title: "카테고리", value: model.categoryName)
}
addressSection
agreementsSection
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s24)
.padding(.bottom, SodaSpacing.s32)
}
.scrollDismissesKeyboard(.interactively)
.safeAreaInset(edge: .bottom, spacing: 0) {
Button {
viewModel.submit()
} label: {
SelectedButtonView(title: "배송지 입력 완료", isActive: viewModel.canSubmit, isSelected: false)
}
.disabled(!viewModel.canSubmit)
.padding(SodaSpacing.s14)
.background(Color.black)
}
.disabled(viewModel.isSubmitting || viewModel.hasSubmitted)
}
private var privacyBanner: some View {
Text("필요 정보 외의 용도로 사용하지 않으며, 전달이 완료되면 파기됩니다")
.appFont(.caption3)
.foregroundColor(.gray500)
.fixedSize(horizontal: false, vertical: true)
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
}
private var addressSection: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s20) {
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text("배송지 입력")
.appFont(.heading3)
.foregroundColor(.white)
.accessibilityAddTraits(.isHeader)
Text("제출 후 수정할 수 없으니 입력한 정보를 다시 한번 확인해 주세요")
.appFont(.caption3)
.foregroundColor(.gray500)
.fixedSize(horizontal: false, vertical: true)
}
field("이름", helper: "입력한 이름으로 선물이 전달돼요") {
GiftContactTextField(text: $viewModel.recipientName, rule: .name)
}
field("휴대폰 번호", helper: "문제 발생 시 연락이 가능한 번호를 입력해 주세요") {
GiftContactTextField(text: $viewModel.recipientPhoneNumber, rule: .phoneNumber)
}
SodaAddressInputView(zipCode: $viewModel.recipientZipCode, address: $viewModel.recipientAddress,
addressDetail: $viewModel.recipientAddressDetail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var agreementsSection: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
Text("이용 약관 동의")
.appFont(.heading3)
.foregroundColor(.white)
.accessibilityAddTraits(.isHeader)
GiftAgreementRow(title: "소다라이브 크리에이터 선물 받기 이용약관에 동의합니다.",
isAgreed: $viewModel.recipientTermsAgreed)
GiftAgreementRow(title: "선물 받기를 위한 개인정보 수집·이용에 동의합니다.",
isAgreed: $viewModel.recipientPrivacyAgreed)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func field<Content: View>(
_ title: String,
helper: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(title)
.appFont(.body5)
.foregroundColor(.grayee)
content()
.appFont(.body3)
.foregroundColor(.white)
.padding(SodaSpacing.s14)
.background(Color.gray900)
.cornerRadius(SodaRadius.r14)
.accessibilityLabel(title)
Text(helper)
.appFont(.caption3)
.foregroundColor(.gray500)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -0,0 +1,98 @@
import Combine
import Foundation
final class GiftRecipientAddressViewModel: ObservableObject {
@Published var recipientName: String = ""
@Published var recipientPhoneNumber: String = ""
@Published var recipientZipCode: String = ""
@Published var recipientAddress: String = ""
@Published var recipientAddressDetail: String = ""
@Published var recipientTermsAgreed: Bool = false
@Published var recipientPrivacyAgreed: Bool = false
@Published private(set) var isSubmitting = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowSuccessToast = false
let successMessage = "배송지 입력 완료"
private let applicationNo: String
private let onSubmitted: () -> Void
private let repository = GiftRepository()
private var mutationSubscription: AnyCancellable?
@Published private(set) var hasSubmitted = false
private var hasNotifiedSubmission = false
init(applicationNo: String, onSubmitted: @escaping () -> Void) {
self.applicationNo = applicationNo
self.onSubmitted = onSubmitted
}
var canSubmit: Bool {
!isSubmitting && !hasSubmitted && recipientTermsAgreed && recipientPrivacyAgreed
&& GiftContactInputRule.name.acceptsSubmission(recipientName)
&& GiftContactInputRule.phoneNumber.acceptsSubmission(recipientPhoneNumber)
&& [recipientZipCode, recipientAddress].allSatisfy {
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
func submit() {
guard canSubmit else { return }
isSubmitting = true
errorMessage = ""
isShowPopup = false
let request = SubmitGiftRecipientAddressRequest(
recipientName: recipientName.trimmingCharacters(in: .whitespacesAndNewlines),
recipientPhoneNumber: recipientPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines),
recipientZipCode: recipientZipCode.trimmingCharacters(in: .whitespacesAndNewlines),
recipientAddress: recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines),
recipientAddressDetail: recipientAddressDetail.trimmingCharacters(in: .whitespacesAndNewlines),
recipientTermsAgreed: recipientTermsAgreed,
recipientPrivacyAgreed: recipientPrivacyAgreed
)
mutationSubscription = repository.submitRecipientAddress(applicationNo: applicationNo, request: request)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self, !self.hasSubmitted else { return }
if case .failure(let error) = result {
self.finishFailure(data: error.response?.data)
}
} receiveValue: { [weak self] response in
guard let self, !self.hasSubmitted else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
guard decoded.success else {
self.finishFailure(data: response.data)
return
}
self.hasSubmitted = true
self.isSubmitting = false
self.isShowSuccessToast = true
} catch {
self.finishFailure(data: response.data)
}
}
}
func successToastDidDismiss() {
guard hasSubmitted, !isShowSuccessToast, !hasNotifiedSubmission else { return }
hasNotifiedSubmission = true
onSubmitted()
}
private func finishFailure(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) ?? ""
isSubmitting = false
errorMessage = message.isEmpty ? I18n.Common.commonError : message
isShowPopup = true
}
}