feat(gift): 선물 발송 화면을 추가한다
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
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<CreateGiftResponse>.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<GiftFormOptionsResponse>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user