From c039931f34c80c71e5542cf072299f5ffccb34b0 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 1 Apr 2026 16:50:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8/=EB=8C=93=EA=B8=80=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20I18n=20?= =?UTF-8?q?=ED=82=A4=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Curation/ContentCurationViewModel.swift | 4 +- .../Detail/AudioContentDeleteDialogView.swift | 12 +- .../Detail/AudioContentReportDialogView.swift | 18 +- .../Comment/AudioContentCommentItemView.swift | 16 +- .../Comment/AudioContentCommentListView.swift | 6 +- .../AudioContentCommentListViewModel.swift | 16 +- .../Comment/AudioContentListReplyView.swift | 4 +- .../AudioContentListReplyViewModel.swift | 16 +- .../Comment/ContentDetailCommentView.swift | 6 +- .../ContentDetailInfoLimitedEditionView.swift | 8 +- .../Detail/ContentDetailInfoView.swift | 10 +- .../Detail/ContentDetailMenuView.swift | 8 +- .../Detail/ContentDetailMosaicView.swift | 4 +- .../ContentDetailOtherContentView.swift | 2 +- .../Detail/ContentDetailPlayView.swift | 2 +- ...tDetailPreviousNextContentButtonView.swift | 4 +- .../Detail/ContentDetailPurchaseButton.swift | 6 +- .../Content/Detail/ContentDetailView.swift | 6 +- .../Detail/ContentDetailViewModel.swift | 86 ++++--- .../ContentOrderConfirmDialogView.swift | 16 +- .../Detail/ContentOrderDialogView.swift | 12 +- .../Detail/LiveRoomDonationDialogView.swift | 24 +- .../Banner/ContentMainBannerViewModel.swift | 4 +- .../Content/Modify/ContentModifyView.swift | 40 +-- .../Modify/ContentModifyViewModel.swift | 20 +- .../Playlist/ContentPlaylistItemView.swift | 2 +- .../Playlist/ContentPlaylistListView.swift | 10 +- .../ContentPlaylistListViewModel.swift | 18 +- SodaLive/Sources/I18n/I18n.swift | 228 ++++++++++++++++++ docs/20260331_하드코딩텍스트_I18n통일계획.md | 97 +++++--- 30 files changed, 503 insertions(+), 202 deletions(-) diff --git a/SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift b/SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift index d15aeff..e83d9fc 100644 --- a/SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift +++ b/SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift @@ -77,13 +77,13 @@ final class ContentCurationViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } diff --git a/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift b/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift index 64c6872..a32fe40 100644 --- a/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift +++ b/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift @@ -19,11 +19,11 @@ struct AudioContentDeleteDialogView: View { var body: some View { VStack(spacing: 0) { - Text("콘텐츠 삭제") + Text(I18n.ContentDetail.DeleteDialog.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) - Text("[\(title)]을 삭제하시겠습니까?") + Text(I18n.ContentDetail.DeleteDialog.confirmQuestion(title)) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) .padding(.top, 21.3) @@ -36,7 +36,7 @@ struct AudioContentDeleteDialogView: View { isAgree.toggle() } - Text("삭제된 콘텐츠는 되돌릴 수 없음을 알고 있습니다.") + Text(I18n.ContentDetail.DeleteDialog.irreversibleAcknowledgement) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) .onTapGesture { @@ -48,7 +48,7 @@ struct AudioContentDeleteDialogView: View { .cornerRadius(6.7) .padding(.top, 13.3) - Text("콘텐츠를 삭제하더라도 이미 구매한\n사용자는 콘텐츠를 이용할 수 있습니다.") + Text(I18n.ContentDetail.DeleteDialog.purchasedUserNotice) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: "dd4500")) .fixedSize(horizontal: false, vertical: true) @@ -56,7 +56,7 @@ struct AudioContentDeleteDialogView: View { .padding(.top, 13.3) HStack(spacing: 12) { - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "9970ff")) .padding(.horizontal, 55) @@ -70,7 +70,7 @@ struct AudioContentDeleteDialogView: View { isShowing = false } - Text("확인") + Text(I18n.Common.confirm) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .padding(.horizontal, 55) diff --git a/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift b/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift index 1a91c55..49bccdb 100644 --- a/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift +++ b/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift @@ -13,15 +13,7 @@ struct AudioContentReportDialogView: View { let confirmAction: (String) -> Void @State private var selectedIndex: Int? = nil - let reasons = [ - "괴롭힘 및 사이버 폭력", - "개인정보 침해", - "명의도용", - "폭력적 위협", - "아동학대", - "보호대상 집단에 대한 증오심 표현", - "스팸 및 사기" - ] + let reasons = I18n.ContentDetail.ReportDialog.reasons var body: some View { ZStack { @@ -31,7 +23,7 @@ struct AudioContentReportDialogView: View { .onTapGesture { isShowing = false } VStack(spacing: 13.3) { - Text("콘텐츠 신고") + Text(I18n.ContentDetail.ReportDialog.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) @@ -59,13 +51,13 @@ struct AudioContentReportDialogView: View { .cornerRadius(6.7) .padding(.vertical, 21.3) - Text("신고한 콘텐츠를 관리자가 확인 후, 서비스정책을\n위반한 경우 삭제 조치할 예정입니다.") + Text(I18n.ContentDetail.ReportDialog.notice) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "dd4500")) .multilineTextAlignment(.center) HStack(spacing: 12) { - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "9970ff")) .padding(.vertical, 16) @@ -79,7 +71,7 @@ struct AudioContentReportDialogView: View { isShowing = false } - Text("신고") + Text(I18n.ContentDetail.ReportDialog.reportAction) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .padding(.vertical, 16) diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift index cb14ad7..4c513e3 100644 --- a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift @@ -51,7 +51,7 @@ struct AudioContentCommentItemView: View { .foregroundColor(Color.gray90) if commentItem.isSecret { - Text("비밀댓글") + Text(I18n.ContentDetail.Comment.secretComment) .appFont(size: 11, weight: .medium) .foregroundColor(Color.grayee) .padding(.horizontal, 4) @@ -104,7 +104,7 @@ struct AudioContentCommentItemView: View { HStack(spacing: 0) { if isModeModify { HStack(spacing: 0) { - TextField("댓글을 입력해 보세요.", text: $comment) + TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $comment) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) @@ -150,8 +150,12 @@ struct AudioContentCommentItemView: View { audioContentId: audioContentId, parentComment: commentItem ) - ) { - Text(commentItem.replyCount > 0 ? "답글 \(commentItem.replyCount)개" : "답글 쓰기") + ) { + Text( + commentItem.replyCount > 0 ? + I18n.ContentDetail.Comment.replyCount(commentItem.replyCount) : + I18n.ContentDetail.Comment.writeReply + ) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.button) } @@ -172,7 +176,7 @@ struct AudioContentCommentItemView: View { if isShowPopupMenu { VStack(spacing: 10) { if commentItem.writerId == UserDefaults.int(forKey: .userId) { - Text("수정") + Text(I18n.ContentDetail.Comment.edit) .appFont(size: 14, weight: .medium) .foregroundColor(Color.gray77) .onTapGesture { @@ -184,7 +188,7 @@ struct AudioContentCommentItemView: View { if contentCreatorId == UserDefaults.int(forKey: .userId) || commentItem.writerId == UserDefaults.int(forKey: .userId) { - Text("삭제") + Text(I18n.Common.delete) .appFont(size: 14, weight: .medium) .foregroundColor(Color.gray77) .onTapGesture { diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift index 08c6f0f..c4102eb 100644 --- a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift @@ -29,7 +29,7 @@ struct AudioContentCommentListView: View { ZStack { VStack(spacing: 0) { HStack(spacing: 0) { - Text("댓글") + Text(I18n.ContentDetail.Comment.title) .appFont(size: 14.7, weight: .medium) .foregroundColor(.white) .padding(.leading, 13.3) @@ -65,7 +65,7 @@ struct AudioContentCommentListView: View { viewModel.isSecret.toggle() } - Text("비밀댓글") + Text(I18n.ContentDetail.Comment.secretComment) .appFont(size: 12, weight: .medium) .foregroundColor(viewModel.isSecret ? Color.button : Color.grayee) .onTapGesture { @@ -85,7 +85,7 @@ struct AudioContentCommentListView: View { .clipShape(Circle()) HStack(spacing: 0) { - TextField("댓글을 입력해 보세요.", text: $viewModel.comment) + TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $viewModel.comment) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift index 0a9952d..6ee1607 100644 --- a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift @@ -64,13 +64,13 @@ class AudioContentCommentListViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } @@ -115,13 +115,13 @@ class AudioContentCommentListViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -135,13 +135,13 @@ class AudioContentCommentListViewModel: ObservableObject { isActive: Bool? = nil ) { if comment == nil && isActive == nil { - errorMessage = "변경사항이 없습니다." + errorMessage = I18n.ContentDetail.Comment.noChanges isShowPopup = true return } if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty { - errorMessage = "내용을 입력하세요." + errorMessage = I18n.ContentDetail.Comment.inputContent isShowPopup = true return } @@ -187,14 +187,14 @@ class AudioContentCommentListViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { self.isLoading = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift index 05cad04..8cb45bb 100644 --- a/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift @@ -29,7 +29,7 @@ struct AudioContentListReplyView: View { HStack(spacing: 6.7) { Image("ic_back") - Text("답글") + Text(I18n.ContentDetail.Comment.replyTitle) .appFont(size: 14.7, weight: .medium) .foregroundColor(.white) @@ -55,7 +55,7 @@ struct AudioContentListReplyView: View { .clipShape(Circle()) HStack(spacing: 0) { - TextField("댓글을 입력해 보세요.", text: $viewModel.comment) + TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $viewModel.comment) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift index 41b48e4..061ed1a 100644 --- a/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift @@ -62,13 +62,13 @@ final class AudioContentListReplyViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } @@ -113,13 +113,13 @@ final class AudioContentListReplyViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -133,13 +133,13 @@ final class AudioContentListReplyViewModel: ObservableObject { isActive: Bool? = nil ) { if comment == nil && isActive == nil { - errorMessage = "변경사항이 없습니다." + errorMessage = I18n.ContentDetail.Comment.noChanges isShowPopup = true return } if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty { - errorMessage = "내용을 입력하세요." + errorMessage = I18n.ContentDetail.Comment.inputContent isShowPopup = true return } @@ -185,14 +185,14 @@ final class AudioContentListReplyViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { self.isLoading = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift b/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift index 67e315c..095b32d 100644 --- a/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift +++ b/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift @@ -22,7 +22,7 @@ struct ContentDetailCommentView: View { var body: some View { VStack(alignment: .leading, spacing: 10.3) { HStack(spacing: 5.3) { - Text("댓글") + Text(I18n.ContentDetail.Comment.title) .appFont(size: 12, weight: .medium) .foregroundColor(.white) @@ -38,7 +38,7 @@ struct ContentDetailCommentView: View { .resizable() .frame(width: 20, height: 20) - Text("비밀댓글") + Text(I18n.ContentDetail.Comment.secretComment) .appFont(size: 12, weight: .medium) .foregroundColor(isSecret ? Color.button : Color.grayee) } @@ -71,7 +71,7 @@ struct ContentDetailCommentView: View { .padding(.leading, 3) } else { HStack(spacing: 0) { - TextField("댓글을 입력해 보세요.", text: $comment) + TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $comment) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift b/SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift index 44e0906..209a5c3 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift @@ -18,7 +18,7 @@ struct ContentDetailInfoLimitedEditionView: View { var body: some View { VStack(alignment: .leading, spacing: 13.3) { HStack(spacing: 0) { - Text("한정판") + Text(I18n.ContentDetail.LimitedEdition.title) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.button) @@ -40,7 +40,7 @@ struct ContentDetailInfoLimitedEditionView: View { .foregroundColor(Color.grayd2) .padding(.leading, 2.3) } else if (remainingContentCount <= 0) { - Text("Sold Out") + Text(I18n.Content.Status.soldOut) .appFont(size: 12, weight: .medium) .foregroundColor(Color.grayd2) .padding(.horizontal, 5.3) @@ -51,7 +51,7 @@ struct ContentDetailInfoLimitedEditionView: View { .foregroundColor(Color.grayd2) ) } else { - Text("잔여수량") + Text(I18n.ContentDetail.LimitedEdition.remainingCount) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayd2) @@ -69,7 +69,7 @@ struct ContentDetailInfoLimitedEditionView: View { .padding(.top, 13.3) if !buyerList.isEmpty { - Text("구매자") + Text(I18n.ContentDetail.LimitedEdition.buyers) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.grayee) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift b/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift index 2c114ff..86d3718 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift @@ -22,7 +22,7 @@ struct ContentDetailInfoView: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 5.3) { if let _ = audioContent.releaseDate { - Text("오픈예정") + Text(I18n.Common.openScheduled) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .padding(.horizontal, 5.3) @@ -62,7 +62,7 @@ struct ContentDetailInfoView: View { } } - Text(orderType == .KEEP ? "소장중" : "대여중") + Text(orderType == .KEEP ? I18n.Content.Status.owned : I18n.Content.Status.rented) .appFont(size: 12, weight: .medium) .foregroundColor( orderType == .KEEP ? @@ -113,7 +113,7 @@ struct ContentDetailInfoView: View { HStack(spacing: 4) { Image("ic_audio_content_share") - Text("공유") + Text(I18n.ContentDetail.Info.share) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayd2) } @@ -129,7 +129,7 @@ struct ContentDetailInfoView: View { .resizable() .frame(width: 13.3, height: 13.3) - Text("후원") + Text(I18n.ContentDetail.Info.donation) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayd2) } @@ -174,7 +174,7 @@ struct ContentDetailInfoView: View { if isShowingPreviewAlert() { HStack(spacing: 0) { - Text("미리듣기 중입니다.\n콘텐츠 구매 후 전체를 감상해 보세요.") + Text(I18n.ContentDetail.Info.previewAlertMessage) .appFont(size: 12, weight: .medium) .foregroundColor(Color.graybb) .lineSpacing(5) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift index 9577b13..8069022 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift @@ -34,7 +34,7 @@ struct ContentDetailMenuView: View { HStack(spacing: 13.3) { Image(isPin ? "ic_pin_cancel" : "ic_pin") - Text(isPin ? "내 채널에 고정 취소" : "내 채널에 고정") + Text(isPin ? I18n.ContentDetail.Menu.unpinFromMyChannel : I18n.ContentDetail.Menu.pinToMyChannel) .appFont(size: 16.7, weight: .medium) .foregroundColor(.white) @@ -51,7 +51,7 @@ struct ContentDetailMenuView: View { HStack(spacing: 13.3) { Image("ic_make_message") - Text("수정") + Text(I18n.ContentDetail.Menu.edit) .appFont(size: 16.7, weight: .medium) .foregroundColor(.white) @@ -68,7 +68,7 @@ struct ContentDetailMenuView: View { HStack(spacing: 13.3) { Image("ic_trash_can") - Text("삭제") + Text(I18n.Common.delete) .appFont(size: 16.7, weight: .medium) .foregroundColor(.white) @@ -83,7 +83,7 @@ struct ContentDetailMenuView: View { } } else { HStack(spacing: 0) { - Text("신고") + Text(I18n.ContentDetail.Menu.report) .appFont(size: 16.7, weight: .medium) .foregroundColor(.white) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift b/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift index cf8204d..5eb2d96 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift @@ -16,12 +16,12 @@ struct ContentDetailMosaicView: View { VStack(spacing: 0) { Image("ic_notice_exclamation_mark") - Text("본 콘텐츠는 만 19세 미만의 청소년이\n이용할 수 없습니다.\n본인인증 후 콘텐츠를 이용해 주세요.") + Text(I18n.ContentDetail.Mosaic.adultRestrictionNotice) .appFont(size: 18.7, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) .padding(.top, 21.7) - Text("본인인증") + Text(I18n.ContentDetail.Mosaic.verifyIdentity) .appFont(size: 18.7, weight: .medium) .foregroundColor(Color.white) .padding(.horizontal, 13.3) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift b/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift index 7cd8afa..4a697f8 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift @@ -36,7 +36,7 @@ struct ContentDetailOtherContentView: View { .resizable() .frame(width: 60, height: 60) - Text("\(title)를 준비중입니다.\n조금만 기다려주세요.") + Text(I18n.ContentDetail.OtherContent.preparingMessage(title)) .multilineTextAlignment(.center) .appFont(size: 10.7, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift index 75d4fce..2632f1b 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift @@ -62,7 +62,7 @@ struct ContentDetailPlayView: View { ) if let _ = audioContent.totalContentCount, let remainingContentCount = audioContent.remainingContentCount, remainingContentCount <= 0, audioContent.creator.creatorId != UserDefaults.int(forKey: .userId), !audioContent.existOrdered { - Text("Sold Out") + Text(I18n.Content.Status.soldOut) .appFont(size: 36.7, weight: .bold) .foregroundColor(.white) .frame( diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift b/SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift index 6c965f3..776e2dc 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift @@ -32,7 +32,7 @@ struct ContentDetailPreviousNextContentButtonView: View { .multilineTextAlignment(.leading) .lineLimit(2) - Text("이전화") + Text(I18n.ContentDetail.Navigation.previousEpisode) .appFont(size: 13.3, weight: .medium) .foregroundColor(.button) .multilineTextAlignment(.leading) @@ -71,7 +71,7 @@ struct ContentDetailPreviousNextContentButtonView: View { .multilineTextAlignment(.leading) .lineLimit(2) - Text("다음화") + Text(I18n.ContentDetail.Navigation.nextEpisode) .appFont(size: 13.3, weight: .medium) .foregroundColor(.button) .multilineTextAlignment(.leading) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift b/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift index 4767a1e..465fe71 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift @@ -26,7 +26,11 @@ struct ContentDetailPurchaseButton: View { .foregroundColor(.white) .padding(.leading, 5.3) - Text(UserDefaults.int(forKey: .userId) == 17958 ? "원으로": "캔으로") + Text( + UserDefaults.int(forKey: .userId) == 17958 ? + I18n.ContentDetail.Purchase.withWon : + I18n.ContentDetail.Purchase.withCan + ) .appFont(size: 12, weight: .light) .foregroundColor(.white) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailView.swift b/SodaLive/Sources/Content/Detail/ContentDetailView.swift index c06a972..0806c91 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailView.swift @@ -36,7 +36,7 @@ struct ContentDetailView: View { .resizable() .frame(width: 20, height: 20) - Text("콘텐츠 상세") + Text(I18n.ContentDetail.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.grayee) } @@ -120,7 +120,7 @@ struct ContentDetailView: View { audioContent.orderType == nil && audioContent.creator.creatorId != UserDefaults.int(forKey: .userId) { if let _ = audioContent.totalContentCount, let remainingContentCount = audioContent.remainingContentCount, remainingContentCount <= 0 { - Text("해당 콘텐츠가 매진되었습니다.") + Text(I18n.ContentDetail.soldOutNotice) .appFont(size: 13.3, weight: .bold) .foregroundColor(.white) .frame(maxWidth: .infinity) @@ -365,7 +365,7 @@ struct ContentDetailView: View { } }, showToast: { - viewModel.errorMessage = "동의하셔야 삭제할 수 있습니다." + viewModel.errorMessage = I18n.ContentDetail.deleteAgreementRequired viewModel.isShowPopup = true } ) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift index 373beb1..6b60217 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift @@ -69,14 +69,14 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { print(error) - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } @@ -110,13 +110,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -148,13 +148,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -186,13 +186,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -212,7 +212,7 @@ final class ContentDetailViewModel: ObservableObject { self.shareMessage = shareUrl self.isShowShareView = true } else { - self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.errorMessage = I18n.ContentDetail.shareLinkCreateFailed self.isShowPopup = true } @@ -248,13 +248,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -286,26 +286,26 @@ final class ContentDetailViewModel: ObservableObject { AppState.shared.purchasedContentOrderType = .KEEP self.orderType = nil - self.errorMessage = orderType == .RENTAL ? "대여가 완료되었습니다." : "구매가 완료되었습니다." + self.errorMessage = orderType == .RENTAL ? I18n.ContentDetail.rentalCompleted : I18n.ContentDetail.purchaseCompleted self.isShowPopup = true self.getAudioContentDetail() ContentPlayManager.shared.conditionalStopAudio(contentId: contentId) } else { if let message = decoded.message { self.errorMessage = message - if message.contains("캔이 부족합니다") { + if isInsufficientCanError(message: message, errorProperty: decoded.errorProperty) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { AppState.shared.setAppStep(step: .canCharge(refresh: {}, afterCompletionToGoBack: true)) } } } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -335,12 +335,12 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -368,20 +368,20 @@ final class ContentDetailViewModel: ObservableObject { if decoded.success { self.orderType = nil - self.errorMessage = "삭제되었습니다" + self.errorMessage = I18n.ContentDetail.deleteCompleted self.isShowPopup = true onSuccess() } else { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -390,10 +390,10 @@ final class ContentDetailViewModel: ObservableObject { func donation(can: Int, comment: String) { if can <= 0 { - self.errorMessage = "1캔 이상 후원하실 수 있습니다." + self.errorMessage = I18n.LiveRoom.atLeastOneCanDonationMessage self.isShowPopup = true } else if comment.trimmingCharacters(in: .whitespaces).isEmpty { - self.errorMessage = "함께 보낼 메시지를 입력하세요." + self.errorMessage = I18n.ContentDetail.donationMessageRequired self.isShowPopup = true } else { isLoading = true @@ -415,7 +415,7 @@ final class ContentDetailViewModel: ObservableObject { if decoded.success { UserDefaults.set(UserDefaults.int(forKey: .can) - can, forKey: .can) - self.errorMessage = "\(can)캔을 후원하셨습니다." + self.errorMessage = I18n.ContentDetail.donationCompleted(can) self.isShowPopup = true self.getAudioContentDetail() @@ -423,13 +423,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -456,7 +456,7 @@ final class ContentDetailViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "고정되었습니다" + self.errorMessage = I18n.ContentDetail.pinCompleted self.isShowPopup = true self.getAudioContentDetail() @@ -464,13 +464,13 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -496,7 +496,7 @@ final class ContentDetailViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "해제되었습니다" + self.errorMessage = I18n.ContentDetail.unpinCompleted self.isShowPopup = true self.getAudioContentDetail() @@ -504,16 +504,40 @@ final class ContentDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } .store(in: &subscription) } + + private func isInsufficientCanError(message: String, errorProperty: String?) -> Bool { + let normalizedErrorProperty = errorProperty?.lowercased() ?? "" + + if normalizedErrorProperty.contains("not_enough_can") || + normalizedErrorProperty.contains("insufficient_can") || + normalizedErrorProperty.contains("lack_of_can") + { + return true + } + + let normalizedMessage = message.lowercased() + let insufficientCanPatterns = [ + "캔이 부족", + "캔 부족", + "not enough can", + "insufficient can", + "not enough cans", + "insufficient cans", + "canが不足" + ] + + return insufficientCanPatterns.contains { normalizedMessage.contains($0) } + } } diff --git a/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift b/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift index 93c3547..73f8848 100644 --- a/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift +++ b/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift @@ -35,7 +35,7 @@ struct ContentOrderConfirmDialogView: View { .ignoresSafeArea() VStack(spacing: 0) { - Text("구매확인") + Text(I18n.ContentDetail.OrderConfirmDialog.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.grayee) @@ -100,7 +100,11 @@ struct ContentOrderConfirmDialogView: View { .cornerRadius(5.3) .padding(.top, 21.3) - Text("콘텐츠를 \(orderType == .RENTAL ? "대여" : "소장")하시겠습니까?") + Text( + orderType == .RENTAL ? + I18n.ContentDetail.OrderConfirmDialog.rentQuestion : + I18n.ContentDetail.OrderConfirmDialog.buyQuestion + ) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayee) .fixedSize(horizontal: false, vertical: true) @@ -108,7 +112,7 @@ struct ContentOrderConfirmDialogView: View { .padding(.top, 13.3) if UserDefaults.int(forKey: .userId) != 17958 { - Text("아래 금액이 차감됩니다.") + Text(I18n.ContentDetail.OrderConfirmDialog.deductionNotice) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayee) .fixedSize(horizontal: false, vertical: true) @@ -150,7 +154,7 @@ struct ContentOrderConfirmDialogView: View { } } } else { - Text("\(price * 110)원") + Text("\(price * 110)\(I18n.ContentDetail.Purchase.wonUnit)") .appFont(size: 13.3, weight: .bold) .foregroundColor(Color.grayee) } @@ -168,7 +172,7 @@ struct ContentOrderConfirmDialogView: View { .padding(.top, 13.3) HStack(spacing: 12) { - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.button) .padding(.vertical, 15.7) @@ -181,7 +185,7 @@ struct ContentOrderConfirmDialogView: View { ) .onTapGesture { isShowing = false } - Text("확인") + Text(I18n.Common.confirm) .appFont(size: 18.3, weight: .bold) .foregroundColor(.white) .padding(.vertical, 15.7) diff --git a/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift b/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift index 172bedb..d9eea0a 100644 --- a/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift +++ b/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift @@ -27,11 +27,11 @@ struct ContentOrderDialogView: View { VStack(spacing: 26.7) { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 5.3) { - Text("대여") + Text(I18n.ContentDetail.OrderDialog.rent) .appFont(size: 13.3, weight: .bold) .foregroundColor(.white) - Text("(이용기간 5일)") + Text(I18n.ContentDetail.OrderDialog.rentPeriod) .appFont(size: 12, weight: .light) .foregroundColor(.white) } @@ -56,7 +56,7 @@ struct ContentOrderDialogView: View { } if UserDefaults.int(forKey: .userId) == 17958 { - Text("원") + Text(I18n.ContentDetail.Purchase.wonUnit) .appFont(size: 13.3, weight: .bold) .foregroundColor(Color.grayee) } @@ -73,11 +73,11 @@ struct ContentOrderDialogView: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 5.3) { - Text("소장") + Text(I18n.ContentDetail.OrderDialog.buy) .appFont(size: 13.3, weight: .bold) .foregroundColor(.white) - Text("(서비스 종료시까지)") + Text(I18n.ContentDetail.OrderDialog.buyPeriod) .appFont(size: 12, weight: .light) .foregroundColor(.white) } @@ -102,7 +102,7 @@ struct ContentOrderDialogView: View { } if UserDefaults.int(forKey: .userId) == 17958 { - Text("원") + Text(I18n.ContentDetail.Purchase.wonUnit) .appFont(size: 13.3, weight: .bold) .foregroundColor(Color.grayee) } diff --git a/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift index d94c973..d1a5a3b 100644 --- a/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift +++ b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift @@ -65,7 +65,7 @@ struct LiveRoomDonationDialogView: View { .resizable() .frame(width: 26.7, height: 26.7) - Text("후원하기") + Text(I18n.ContentDetail.DonationDialog.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.grayee) @@ -80,7 +80,7 @@ struct LiveRoomDonationDialogView: View { .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.grayee) - Text("충전") + Text(I18n.ContentDetail.DonationDialog.charge) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.main) .padding(.horizontal, 13.3) @@ -126,7 +126,12 @@ struct LiveRoomDonationDialogView: View { .padding(.top, 16) } - TextField(isSecret ? "10캔 이상 입력하세요" : "1캔 이상 입력하세요", text: $donationCan) + TextField( + isSecret ? + I18n.ContentDetail.DonationDialog.minimumTenCanPlaceholder : + I18n.ContentDetail.DonationDialog.minimumOneCanPlaceholder, + text: $donationCan + ) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayee) .padding(13.3) @@ -227,7 +232,10 @@ struct LiveRoomDonationDialogView: View { ) TextField( - "함께 보낼 \((isSecret && shouldPrefixSecretInMessagePlaceholder) ? "비밀 " : "")메시지 입력(최대 \(messageLimit)자)", + I18n.ContentDetail.DonationDialog.messagePlaceholder( + isSecret: isSecret && shouldPrefixSecretInMessagePlaceholder, + limit: messageLimit + ), text: $donationMessage ) .appFont(size: 13.3, weight: .medium) @@ -242,7 +250,7 @@ struct LiveRoomDonationDialogView: View { .padding(.horizontal, 20) HStack(spacing: 13.3) { - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 15, weight: .bold) .foregroundColor(Color.button) .padding(.vertical, 16) @@ -258,7 +266,7 @@ struct LiveRoomDonationDialogView: View { isShowing = false } - Text("후원하기") + Text(I18n.ContentDetail.DonationDialog.donateAction) .appFont(size: 15, weight: .bold) .foregroundColor(.white) .padding(.vertical, 16) @@ -271,14 +279,14 @@ struct LiveRoomDonationDialogView: View { errorMessage = secretMinimumCanMessage isShowErrorPopup = true } else if can < 1 { - errorMessage = "1캔 이상 후원하실 수 있습니다." + errorMessage = I18n.LiveRoom.atLeastOneCanDonationMessage isShowErrorPopup = true } else { onClickDonation(can, donationMessage, isSecret) isShowing = false } } else { - errorMessage = "1캔 이상 후원하실 수 있습니다." + errorMessage = I18n.LiveRoom.atLeastOneCanDonationMessage isShowErrorPopup = true } } diff --git a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift index a1b2b99..fbac8a5 100644 --- a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift +++ b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift @@ -49,13 +49,13 @@ final class ContentMainBannerViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "배너를 불러오지 못했습니다. 다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Content.Banner.loadFailed } self.isShowPopup = true } } catch { - self.errorMessage = "배너를 불러오지 못했습니다. 다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Content.Banner.loadFailed self.isShowPopup = true self.isLoading = false } diff --git a/SodaLive/Sources/Content/Modify/ContentModifyView.swift b/SodaLive/Sources/Content/Modify/ContentModifyView.swift index b143ae0..7f617b9 100644 --- a/SodaLive/Sources/Content/Modify/ContentModifyView.swift +++ b/SodaLive/Sources/Content/Modify/ContentModifyView.swift @@ -21,11 +21,11 @@ struct ContentModifyView: View { GeometryReader { proxy in ZStack { VStack(spacing: 0) { - DetailNavigationBar(title: "콘텐츠 수정") + DetailNavigationBar(title: I18n.CreateContent.modifyTitle) ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { - Text("썸네일") + Text(I18n.CreateContent.thumbnail) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .frame(maxWidth: .infinity, alignment: .leading) @@ -82,12 +82,12 @@ struct ContentModifyView: View { .padding(.top, 26.7) VStack(spacing: 0) { - Text("제목") + Text(I18n.CreateContent.titleLabel) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .frame(maxWidth: .infinity, alignment: .leading) - TextField("제목을 입력하세요", text: $viewModel.title) + TextField(I18n.CreateContent.titlePlaceholder, text: $viewModel.title) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) @@ -100,16 +100,16 @@ struct ContentModifyView: View { .padding(.top, 13.3) HStack(spacing: 0) { - Text("내용") + Text(I18n.CreateContent.contentLabel) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) Spacer() - Text("\(viewModel.detail.count)자") + Text(I18n.CreateContent.characterCount(viewModel.detail.count)) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "ff5c49")) - Text(" / 최대 500자") + Text(I18n.CreateContent.max500CharactersSuffix) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "777777")) } @@ -125,13 +125,13 @@ struct ContentModifyView: View { .cornerRadius(6.7) .padding(.top, 13.3) - Text("태그") + Text(I18n.CreateContent.tagLabel) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color.grayee) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 26.7) - TextField("예: #연애 #커버곡", text: $viewModel.hashtags) + TextField(I18n.CreateContent.tagPlaceholderExample, text: $viewModel.hashtags) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) @@ -152,19 +152,19 @@ struct ContentModifyView: View { .padding(.top, 26.7) VStack(spacing: 13.3) { - Text("포인트 사용") + Text(I18n.CreateContent.pointUsageTitle) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color.grayee) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 13.3) { - SelectButtonView(title: "가능", isChecked: viewModel.isPointAvailable) { + SelectButtonView(title: I18n.CreateContent.available, isChecked: viewModel.isPointAvailable) { if !viewModel.isPointAvailable { viewModel.isPointAvailable = true } } - SelectButtonView(title: "불가능", isChecked: !viewModel.isPointAvailable) { + SelectButtonView(title: I18n.CreateContent.unavailable, isChecked: !viewModel.isPointAvailable) { if viewModel.isPointAvailable { viewModel.isPointAvailable = false } @@ -176,26 +176,26 @@ struct ContentModifyView: View { if viewModel.isAdultShowUi { VStack(spacing: 13.3) { - Text("연령 제한") + Text(I18n.CreateContent.ageRestrictionTitle) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 13.3) { - SelectButtonView(title: "전체 연령", isChecked: !viewModel.isAdult) { + SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) { if viewModel.isAdult { viewModel.isAdult = false } } - SelectButtonView(title: "19세 이상", isChecked: viewModel.isAdult) { + SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) { if !viewModel.isAdult { viewModel.isAdult = true } } } - Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.") + Text(I18n.CreateContent.adultLegalNotice) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "DD4500")) .frame(maxWidth: .infinity, alignment: .leading) @@ -206,19 +206,19 @@ struct ContentModifyView: View { } VStack(spacing: 13.3) { - Text("댓글 가능 여부") + Text(I18n.CreateContent.commentAvailabilityTitle) .appFont(size: 16.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 13.3) { - SelectButtonView(title: "댓글 가능", isChecked: viewModel.isAvailableComment) { + SelectButtonView(title: I18n.CreateContent.commentAllowed, isChecked: viewModel.isAvailableComment) { if !viewModel.isAvailableComment { viewModel.isAvailableComment = true } } - SelectButtonView(title: "댓글 불가", isChecked: !viewModel.isAvailableComment) { + SelectButtonView(title: I18n.CreateContent.commentNotAllowed, isChecked: !viewModel.isAvailableComment) { if viewModel.isAvailableComment { viewModel.isAvailableComment = false } @@ -230,7 +230,7 @@ struct ContentModifyView: View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 0) { - Text("수정") + Text(I18n.CreateContent.modifyAction) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.white) .frame(height: 50) diff --git a/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift b/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift index 0ad4db8..bcaed54 100644 --- a/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift +++ b/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift @@ -31,7 +31,7 @@ final class ContentModifyViewModel: ObservableObject { @Published var isAdultShowUi = false var contentId: Int = 0 - var placeholder = "내용을 입력하세요" + var placeholder = I18n.CreateContent.uploadContentDescriptionHint func getAudioContentDetail(onFailure: (() -> Void)? = nil) { audioContent = nil @@ -68,13 +68,13 @@ final class ContentModifyViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -112,7 +112,7 @@ final class ContentModifyViewModel: ObservableObject { mimeType: "image/*") ) } else { - errorMessage = "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요" + errorMessage = I18n.CreateContent.coverImageUploadFailed isShowPopup = true isLoading = false return @@ -139,26 +139,26 @@ final class ContentModifyViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "콘텐츠가 수정되었습니다." + self.errorMessage = I18n.CreateContent.modifySuccess self.isShowPopup = true onSuccess() } else { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } .store(in: &subscription) } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true self.isLoading = false } @@ -167,13 +167,13 @@ final class ContentModifyViewModel: ObservableObject { private func validateData() -> Bool { if title != audioContent!.title && title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - errorMessage = "제목을 입력해 주세요." + errorMessage = I18n.CreateContent.titleRequired isShowPopup = true return false } if detail != audioContent!.detail && (detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5) { - errorMessage = "내용을 5자 이상 입력해 주세요." + errorMessage = I18n.CreateContent.detailMinLengthRequired isShowPopup = true return false } diff --git a/SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift b/SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift index b267a81..a147788 100644 --- a/SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift +++ b/SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift @@ -37,7 +37,7 @@ struct ContentPlaylistItemView: View { .lineLimit(1) } - Text("총 \(item.contentCount)개") + Text(I18n.Content.Playlist.itemCount(item.contentCount)) .appFont(size: 12, weight: .medium) .foregroundColor(Color.gray90) .lineLimit(1) diff --git a/SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift b/SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift index 545606f..1060030 100644 --- a/SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift +++ b/SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift @@ -17,7 +17,7 @@ struct ContentPlaylistListView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 13.3) { - Text("+ 새 재생목록 만들기") + Text(I18n.Content.Playlist.createNewAction) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color.white) .padding(.vertical, 13.3) @@ -31,11 +31,11 @@ struct ContentPlaylistListView: View { if viewModel.playlists.isEmpty { VStack(spacing: 13.3) { - Text("재생목록이 비어있습니다.") + Text(I18n.Content.Playlist.emptyTitle) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color.grayee) - Text("자주 듣는 콘텐츠를\n재생목록으로 만들어 보세요.") + Text(I18n.Content.Playlist.emptyDescription) .appFont(size: 11, weight: .medium) .foregroundColor(Color.grayee) .multilineTextAlignment(.center) @@ -45,11 +45,11 @@ struct ContentPlaylistListView: View { .cornerRadius(4.7) } else { HStack(spacing: 5.3) { - Text("전체") + Text(I18n.Content.Playlist.totalLabel) .appFont(size: 14.7, weight: .medium) .foregroundColor(Color.white) - Text("\(viewModel.totalCount)개") + Text("\(viewModel.totalCount)\(I18n.Content.Count.countUnit)") .appFont(size: 12, weight: .medium) .foregroundColor(Color.gray90) diff --git a/SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift b/SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift index f255e88..b11f404 100644 --- a/SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift +++ b/SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift @@ -41,18 +41,18 @@ final class ContentPlaylistListViewModel: ObservableObject { self.totalCount = data.totalCount self.playlists.append(contentsOf: data.items) } else { - if let message = decoded.message { - self.errorMessage = message - } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." - } + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } self.isShowPopup = true } - } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." - self.isShowPopup = true - } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } self.isLoading = false } diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 1bb1ad3..d16a0ec 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -373,6 +373,10 @@ enum I18n { } enum ContentDetail { + static var title: String { + pick(ko: "콘텐츠 상세", en: "Content details", ja: "コンテンツ詳細") + } + static var creatorOtherContents: String { pick( ko: "크리에이터의 다른 콘텐츠", @@ -420,6 +424,208 @@ enum I18n { ja: "このコンテンツを固定しますか?チャンネルには最大3件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。" ) } + + static var soldOutNotice: String { + pick(ko: "해당 콘텐츠가 매진되었습니다.", en: "This content is sold out.", ja: "このコンテンツは売り切れです。") + } + + static var deleteAgreementRequired: String { + pick(ko: "동의하셔야 삭제할 수 있습니다.", en: "You must agree before deleting.", ja: "同意いただくと削除できます。") + } + + static var shareLinkCreateFailed: String { + pick(ko: "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요.", en: "Failed to create a share link.\nPlease try again.", ja: "共有リンクを作成できませんでした。\nもう一度お試しください。") + } + + static var rentalCompleted: String { + pick(ko: "대여가 완료되었습니다.", en: "Rental completed.", ja: "レンタルが完了しました。") + } + + static var purchaseCompleted: String { + pick(ko: "구매가 완료되었습니다.", en: "Purchase completed.", ja: "購入が完了しました。") + } + + static var deleteCompleted: String { + pick(ko: "삭제되었습니다", en: "Deleted.", ja: "削除されました。") + } + + static var pinCompleted: String { + pick(ko: "고정되었습니다", en: "Pinned.", ja: "固定されました。") + } + + static var unpinCompleted: String { + pick(ko: "해제되었습니다", en: "Unpinned.", ja: "固定解除されました。") + } + + static var donationMessageRequired: String { + pick(ko: "함께 보낼 메시지를 입력하세요.", en: "Please enter a message to send together.", ja: "一緒に送るメッセージを入力してください。") + } + + static func donationCompleted(_ can: Int) -> String { + pick(ko: "\(can)캔을 후원하셨습니다.", en: "You donated \(can) cans.", ja: "\(can)canを後援しました。") + } + + enum Info { + static var share: String { + pick(ko: "공유", en: "Share", ja: "共有") + } + + static var donation: String { + pick(ko: "후원", en: "Donate", ja: "後援") + } + + static var previewAlertMessage: String { + pick(ko: "미리듣기 중입니다.\n콘텐츠 구매 후 전체를 감상해 보세요.", en: "You are listening to a preview.\nPurchase this content to listen to the full version.", ja: "現在は試聴中です。\nコンテンツ購入後に全編をお楽しみください。") + } + } + + enum Menu { + static var pinToMyChannel: String { + pick(ko: "내 채널에 고정", en: "Pin to my channel", ja: "マイチャンネルに固定") + } + + static var unpinFromMyChannel: String { + pick(ko: "내 채널에 고정 취소", en: "Unpin from my channel", ja: "マイチャンネルの固定を解除") + } + + static var edit: String { + pick(ko: "수정", en: "Edit", ja: "編集") + } + + static var report: String { + pick(ko: "신고", en: "Report", ja: "通報") + } + } + + enum DeleteDialog { + static var title: String { + pick(ko: "콘텐츠 삭제", en: "Delete content", ja: "コンテンツ削除") + } + + static func confirmQuestion(_ title: String) -> String { + pick(ko: "[\(title)]을 삭제하시겠습니까?", en: "Do you want to delete [\(title)]?", ja: "[\(title)]を削除しますか?") + } + + static var irreversibleAcknowledgement: String { + pick(ko: "삭제된 콘텐츠는 되돌릴 수 없음을 알고 있습니다.", en: "I understand deleted content cannot be restored.", ja: "削除されたコンテンツは元に戻せないことを理解しています。") + } + + static var purchasedUserNotice: String { + pick(ko: "콘텐츠를 삭제하더라도 이미 구매한\n사용자는 콘텐츠를 이용할 수 있습니다.", en: "Even if the content is deleted, users who already purchased it can still use it.", ja: "コンテンツを削除しても、すでに購入した\nユーザーは引き続き利用できます。") + } + } + + enum ReportDialog { + static var title: String { + pick(ko: "콘텐츠 신고", en: "Report content", ja: "コンテンツ通報") + } + + static var reasons: [String] { + [ + reasonHarassment, + reasonPrivacy, + reasonImpersonation, + reasonViolentThreat, + reasonChildAbuse, + reasonHateSpeech, + reasonSpamScam + ] + } + + static var reasonHarassment: String { pick(ko: "괴롭힘 및 사이버 폭력", en: "Harassment and cyberbullying", ja: "嫌がらせ・サイバーいじめ") } + static var reasonPrivacy: String { pick(ko: "개인정보 침해", en: "Privacy violation", ja: "個人情報の侵害") } + static var reasonImpersonation: String { pick(ko: "명의도용", en: "Impersonation", ja: "なりすまし") } + static var reasonViolentThreat: String { pick(ko: "폭력적 위협", en: "Violent threats", ja: "暴力的な脅迫") } + static var reasonChildAbuse: String { pick(ko: "아동학대", en: "Child abuse", ja: "児童虐待") } + static var reasonHateSpeech: String { pick(ko: "보호대상 집단에 대한 증오심 표현", en: "Hate speech against protected groups", ja: "保護対象集団へのヘイト表現") } + static var reasonSpamScam: String { pick(ko: "스팸 및 사기", en: "Spam and scams", ja: "スパム・詐欺") } + + static var notice: String { + pick(ko: "신고한 콘텐츠를 관리자가 확인 후, 서비스정책을\n위반한 경우 삭제 조치할 예정입니다.", en: "After review by administrators, reported content that violates service policy will be removed.", ja: "通報されたコンテンツは管理者が確認し、\nサービス方針違反の場合は削除対応されます。") + } + + static var reportAction: String { + pick(ko: "신고", en: "Report", ja: "通報") + } + } + + enum Comment { + static var title: String { pick(ko: "댓글", en: "Comments", ja: "コメント") } + static var secretComment: String { pick(ko: "비밀댓글", en: "Secret comment", ja: "秘密コメント") } + static var inputPlaceholder: String { pick(ko: "댓글을 입력해 보세요.", en: "Enter a comment.", ja: "コメントを入力してください。") } + static var replyTitle: String { pick(ko: "답글", en: "Replies", ja: "返信") } + static var writeReply: String { pick(ko: "답글 쓰기", en: "Write reply", ja: "返信を書く") } + static func replyCount(_ count: Int) -> String { pick(ko: "답글 \(count)개", en: "\(count) replies", ja: "返信\(count)件") } + static var edit: String { pick(ko: "수정", en: "Edit", ja: "編集") } + static var noChanges: String { pick(ko: "변경사항이 없습니다.", en: "No changes.", ja: "変更事項がありません。") } + static var inputContent: String { pick(ko: "내용을 입력하세요.", en: "Please enter content.", ja: "内容を入力してください。") } + } + + enum LimitedEdition { + static var title: String { pick(ko: "한정판", en: "Limited edition", ja: "限定版") } + static var remainingCount: String { pick(ko: "잔여수량", en: "Remaining", ja: "残り数量") } + static var buyers: String { pick(ko: "구매자", en: "Buyers", ja: "購入者") } + } + + enum Mosaic { + static var adultRestrictionNotice: String { + pick(ko: "본 콘텐츠는 만 19세 미만의 청소년이\n이용할 수 없습니다.\n본인인증 후 콘텐츠를 이용해 주세요.", en: "This content is not available to users under 19.\nPlease verify your identity to access this content.", ja: "このコンテンツは19歳未満の方は\n利用できません。\n本人認証後にご利用ください。") + } + + static var verifyIdentity: String { + pick(ko: "본인인증", en: "Verify identity", ja: "本人認証") + } + } + + enum OtherContent { + static func preparingMessage(_ title: String) -> String { + pick(ko: "\(title)를 준비중입니다.\n조금만 기다려주세요.", en: "\(title) is being prepared.\nPlease wait a little longer.", ja: "\(title)を準備中です。\nしばらくお待ちください。") + } + } + + enum Navigation { + static var previousEpisode: String { pick(ko: "이전화", en: "Previous", ja: "前話") } + static var nextEpisode: String { pick(ko: "다음화", en: "Next", ja: "次話") } + } + + enum Purchase { + static var withWon: String { pick(ko: "원으로", en: " KRW", ja: "ウォンで") } + static var withCan: String { pick(ko: "캔으로", en: " cans", ja: "canで") } + static var wonUnit: String { pick(ko: "원", en: " KRW", ja: "ウォン") } + } + + enum OrderDialog { + static var rent: String { pick(ko: "대여", en: "Rent", ja: "レンタル") } + static var rentPeriod: String { pick(ko: "(이용기간 5일)", en: "(5-day access)", ja: "(利用期間5日)") } + static var buy: String { pick(ko: "소장", en: "Buy", ja: "購入") } + static var buyPeriod: String { pick(ko: "(서비스 종료시까지)", en: "(Until service end)", ja: "(サービス終了まで)") } + } + + enum OrderConfirmDialog { + static var title: String { pick(ko: "구매확인", en: "Purchase confirmation", ja: "購入確認") } + static var rentQuestion: String { pick(ko: "콘텐츠를 대여하시겠습니까?", en: "Do you want to rent this content?", ja: "このコンテンツをレンタルしますか?") } + static var buyQuestion: String { pick(ko: "콘텐츠를 소장하시겠습니까?", en: "Do you want to buy this content?", ja: "このコンテンツを購入しますか?") } + static var deductionNotice: String { pick(ko: "아래 금액이 차감됩니다.", en: "The amount below will be deducted.", ja: "下記金額が差し引かれます。") } + } + + enum DonationDialog { + static var title: String { pick(ko: "후원하기", en: "Donate", ja: "後援する") } + static var charge: String { pick(ko: "충전", en: "Charge", ja: "チャージ") } + static var minimumOneCanPlaceholder: String { pick(ko: "1캔 이상 입력하세요", en: "Enter 1 can or more", ja: "1can以上入力してください") } + static var minimumTenCanPlaceholder: String { pick(ko: "10캔 이상 입력하세요", en: "Enter 10 cans or more", ja: "10can以上入力してください") } + static func messagePlaceholder(isSecret: Bool, limit: Int) -> String { + let koPrefix = isSecret ? "비밀 " : "" + let enPrefix = isSecret ? "secret " : "" + let jaPrefix = isSecret ? "秘密" : "" + + return pick( + ko: "함께 보낼 \(koPrefix)메시지 입력(최대 \(limit)자)", + en: "Enter a \(enPrefix)message (max \(limit) chars)", + ja: "一緒に送る\(jaPrefix)メッセージを入力(最大\(limit)文字)" + ) + } + static var donateAction: String { pick(ko: "후원하기", en: "Donate", ja: "後援する") } + } } enum ContentBox { static var title: String { @@ -543,6 +749,25 @@ enum I18n { ) } } + + enum Banner { + static var loadFailed: String { + pick( + ko: "배너를 불러오지 못했습니다. 다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다.", + en: "Failed to load banners. Please try again.\nIf the issue persists, contact customer support.", + ja: "バナーを読み込めませんでした。もう一度お試しください。\n問題が続く場合はカスタマーサポートにお問い合わせください。" + ) + } + } + + enum Playlist { + static var createNewAction: String { pick(ko: "+ 새 재생목록 만들기", en: "+ Create new playlist", ja: "+ 新しいプレイリストを作成") } + static var emptyTitle: String { pick(ko: "재생목록이 비어있습니다.", en: "Your playlist is empty.", ja: "プレイリストが空です。") } + static var emptyDescription: String { pick(ko: "자주 듣는 콘텐츠를\n재생목록으로 만들어 보세요.", en: "Create a playlist with\nyour frequently listened content.", ja: "よく聴くコンテンツで\nプレイリストを作ってみましょう。") } + static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } + static func totalCount(_ count: Int) -> String { pick(ko: "총 \(count)개", en: "Total \(count)", ja: "全\(count)件") } + static func itemCount(_ count: Int) -> String { pick(ko: "총 \(count)개", en: "Total \(count)", ja: "全\(count)件") } + } } enum CharacterDetailGallery { @@ -2339,6 +2564,9 @@ enum I18n { static var uploadContentDescriptionHint: String { pick(ko: "내용을 입력하세요", en: "Enter the details.", ja: "内容を入力してください") } static var uploadTitle: String { pick(ko: "콘텐츠 업로드", en: "Content upload", ja: "コンテンツ投稿") } static var uploadAction: String { pick(ko: "콘텐츠 업로드", en: "Upload content", ja: "コンテンツを投稿") } + static var modifyTitle: String { pick(ko: "콘텐츠 수정", en: "Edit content", ja: "コンテンツ編集") } + static var modifyAction: String { pick(ko: "수정", en: "Save changes", ja: "修正") } + static var modifySuccess: String { pick(ko: "콘텐츠가 수정되었습니다.", en: "Content has been updated.", ja: "コンテンツが修正されました。") } static var registerTitle: String { pick(ko: "콘텐츠 등록", en: "Register content", ja: "コンテンツ登録") } static var thumbnail: String { pick(ko: "썸네일", en: "Thumbnail", ja: "サムネイル") } static var registerSectionTitle: String { pick(ko: "등록", en: "Upload", ja: "登録") } diff --git a/docs/20260331_하드코딩텍스트_I18n통일계획.md b/docs/20260331_하드코딩텍스트_I18n통일계획.md index 746abfe..ae0c085 100644 --- a/docs/20260331_하드코딩텍스트_I18n통일계획.md +++ b/docs/20260331_하드코딩텍스트_I18n통일계획.md @@ -165,40 +165,40 @@ - [x] `SodaLive/Sources/Content/Curation/ContentCurationView.swift` #### Group 3 (21-30) -- [ ] `SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift` -- [ ] `SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift` -- [ ] `SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift` -- [ ] `SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift` +- [x] `SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift` +- [x] `SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift` +- [x] `SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift` +- [x] `SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailInfoLimitedEditionView.swift` #### Group 4 (31-40) -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailView.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift` -- [ ] `SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailPreviousNextContentButtonView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailView.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift` #### Group 5 (41-50) -- [ ] `SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift` -- [ ] `SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift` -- [ ] `SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift` -- [ ] `SodaLive/Sources/Content/Main/V2/ContentMainContentThemeView.swift` -- [ ] `SodaLive/Sources/Content/Modify/ContentModifyView.swift` -- [ ] `SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift` -- [ ] `SodaLive/Sources/Content/Player/ContentPlayerView.swift` -- [ ] `SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift` -- [ ] `SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift` -- [ ] `SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift` +- [x] `SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift` +- [x] `SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift` +- [x] `SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift` +- [x] `SodaLive/Sources/Content/Main/V2/ContentMainContentThemeView.swift` +- [x] `SodaLive/Sources/Content/Modify/ContentModifyView.swift` +- [x] `SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift` +- [x] `SodaLive/Sources/Content/Player/ContentPlayerView.swift` +- [x] `SodaLive/Sources/Content/Playlist/ContentPlaylistItemView.swift` +- [x] `SodaLive/Sources/Content/Playlist/ContentPlaylistListView.swift` +- [x] `SodaLive/Sources/Content/Playlist/ContentPlaylistListViewModel.swift` #### Group 6 (51-60) - [ ] `SodaLive/Sources/Content/Playlist/Create/ContentPlaylistCreateView.swift` @@ -1050,3 +1050,40 @@ - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 test action 미구성 확인(코드 실패 아님, 스킴 제약). - 수동 QA(문구 경로 수동 점검): Group 2 변경 파일에서 사용자 노출 텍스트가 `I18n.*` 경유인지 라인 단위 검토 완료. 비노출 문자열(디버그 로그/API 파라미터)은 예외로 문서화했다. - LSP 진단 참고: SourceKit 단독 해석 환경에서 외부 모듈/프로젝트 심볼(`Kingfisher`, `ObjectBox`, `I18n`, `AppState` 등) 미해결 오류가 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증했다. + +### 23차 구현 (Content 모듈 Group 3~5, 30개 파일 처리, 2026-04-01) +- 무엇/왜/어떻게: + - 무엇: `변경 대상 파일 전체 목록`의 `Content` Group 3~5(30개 파일)를 전수 점검해 사용자 노출 하드코딩 문구를 `I18n.*` 참조로 전환했다. + - 왜: 댓글/상세/구매/후원/수정/플레이리스트 구간에서 뷰 리터럴·ViewModel 에러문구·다이얼로그 문구가 혼재되어 `I18n.swift` 단일 접근 원칙이 깨져 있었기 때문이다. + - 어떻게: 병렬 컨텍스트 수집(`explore`/`librarian`/`oracle` 백그라운드 + `grep`/`read`/`lsp_diagnostics` 직접 점검) 후, Group 3~5 범위 파일만 치환했다. 공통 오류는 `I18n.Common.commonError`로 통합하고, 도메인 전용 문구는 `I18n.ContentDetail.*`, `I18n.Content.*`, `I18n.CreateContent.*`로 정리했다. +- 실행 명령/도구: + - `task(subagent_type="explore", ...)` x2 (`bg_91507310`, `bg_db55ce36`) + - `task(subagent_type="librarian", ...)` x2 (`bg_a4a5eff9`, `bg_b2fde7b7`) + - `task(subagent_type="oracle", ...)` x1 (`bg_3ab5a233`) + - `grep("\"[^\"]*[가-힣][^\"]*\"", include=Group3~5 대상 파일)` + - `grep("String\\(localized:|NSLocalizedString\\(|LocalizedStringKey\\(", include=Group3~5 대상 파일)` + - `lsp_diagnostics(filePath=변경 파일 전체)` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: + - `I18n.swift` 확장: + - `I18n.ContentDetail.*` 대규모 추가(상세 헤더/메뉴/삭제·신고 다이얼로그/댓글/한정판/모자이크/주변 콘텐츠/이전화·다음화/구매·대여/후원 다이얼로그/완료 토스트 등) + - `I18n.Content.Banner.loadFailed` + - `I18n.Content.Playlist.*`(생성 버튼/빈 상태/개수 표기) + - `I18n.CreateContent.modifyTitle`, `modifyAction`, `modifySuccess` + - 치환 완료 파일(실치환 28개): + - Group 3: 10개 전부 실치환 완료 + - Group 4: 10개 전부 실치환 완료 + - Group 5: 8개 실치환 완료 (`ContentOrderDialogView`, `LiveRoomDonationDialogView`, `ContentMainBannerViewModel`, `ContentModifyView`, `ContentModifyViewModel`, `ContentPlaylistItemView`, `ContentPlaylistListView`, `ContentPlaylistListViewModel`) + - 점검만 수행(실치환 없음, 체크 완료 2개): + - `ContentMainContentThemeView.swift`, `ContentPlayerView.swift` (런타임 노출 하드코딩 없음, Preview 샘플 문자열만 존재) + - Group 3~5 체크박스 30개 `- [x]` 완료 반영. + - Group 3~5 재탐지 결과, 남은 한글 리터럴은 Preview 샘플 데이터 및 비노출 문자열 분기(기본 파라미터 `reason: "프로필 신고"`)만 확인. + - Oracle 후속 반영: `ContentDetailViewModel`에 `isInsufficientCanError(message:errorProperty:)` 헬퍼를 추가해 부족 캔 분기를 `errorProperty` 우선 + 메시지 보조 판별로 정리했다(기존 단일 텍스트 포함 비교 의존 완화). + - 직접 로컬라이제이션 API(`String(localized:)`, `NSLocalizedString`, `LocalizedStringKey`)는 Group 3~5 대상 파일에서 0건. + - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). + - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 test action 미구성 확인(코드 실패 아님, 스킴 제약). + - 수동 QA(문구 경로 수동 점검): Group 3~5 대상 파일 재스캔에서 사용자 노출 문자열의 `I18n.*` 경유를 확인했고, 예외(Preview/비노출 비교 문자열)만 잔존함을 확인했다. + - LSP 진단 참고: SourceKit 단독 해석 환경에서 외부 모듈/프로젝트 심볼(`Kingfisher`, `Moya`, `I18n`, `AppState` 등) 미해결 오류가 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증했다.