feat(gift): 선물 발송 화면을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:46 +09:00
parent c356efbed2
commit 9ee23677af
2 changed files with 385 additions and 0 deletions
@@ -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
}
}
}