feat(creator): 채널 후원 섹션을 연결한다

This commit is contained in:
Yu Sung
2026-07-03 11:45:53 +09:00
parent f514a7ac40
commit c136f5bbc3
6 changed files with 358 additions and 10 deletions

View File

@@ -28,6 +28,9 @@
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
@@ -1754,6 +1757,7 @@
CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTabBar.swift; sourceTree = "<group>"; }; CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTabBar.swift; sourceTree = "<group>"; };
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCurrentLiveSection.swift; sourceTree = "<group>"; }; CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCurrentLiveSection.swift; sourceTree = "<group>"; };
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = "<group>"; }; CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = "<group>"; };
CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationSection.swift; sourceTree = "<group>"; };
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; }; 189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; };
@@ -2675,6 +2679,7 @@
CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */, CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */,
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */, CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */,
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */, CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */,
CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -5611,6 +5616,7 @@
CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
@@ -6410,6 +6416,7 @@
CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
@@ -7225,6 +7232,7 @@
CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,

View File

@@ -3165,7 +3165,7 @@ If you block this user, the following features will be restricted.
enum CreatorChannelHome { enum CreatorChannelHome {
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") } static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") } static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
static var donation: String { pick(ko: "채널 후원", en: "Channel donations", ja: "チャンネル応援") } static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") }
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") } static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") } static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }

View File

@@ -4,8 +4,10 @@ struct CreatorChannelView: View {
let creatorId: Int let creatorId: Int
@StateObject private var viewModel = CreatorChannelViewModel() @StateObject private var viewModel = CreatorChannelViewModel()
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude @State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude
@State private var isShowChannelDonationDialog = false
private let titleBarHeight: CGFloat = 56 private let titleBarHeight: CGFloat = 56
private let tabBarHeight: CGFloat = 52 private let tabBarHeight: CGFloat = 52
@@ -65,6 +67,28 @@ struct CreatorChannelView: View {
if viewModel.isLoading { if viewModel.isLoading {
LoadingView() LoadingView()
} }
if isShowChannelDonationDialog {
LiveRoomDonationDialogView(
isShowing: $isShowChannelDonationDialog,
isAudioContentDonation: false,
messageLimit: 100,
secretLabel: I18n.MemberChannel.secretDonationLabel,
secretMinimumCanMessage: I18n.MemberChannel.secretDonationMinimumCanMessage,
shouldPrefixSecretInMessagePlaceholder: false,
onClickDonation: { can, message, isSecret in
channelDonationViewModel.postChannelDonation(
can: can,
message: message,
isSecret: isSecret,
reloadAfterSuccess: false,
onSuccess: {
viewModel.fetchHome(creatorId: creatorId)
}
)
}
)
}
} }
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
@@ -74,6 +98,7 @@ struct CreatorChannelView: View {
} }
} }
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
.sodaToast(isPresented: $channelDonationViewModel.isShowPopup, message: channelDonationViewModel.errorMessage, autohideIn: 2)
} }
private func titleBar(backgroundProgress: CGFloat) -> some View { private func titleBar(backgroundProgress: CGFloat) -> some View {
@@ -131,7 +156,8 @@ struct CreatorChannelView: View {
CreatorChannelHomeView( CreatorChannelHomeView(
response: response, response: response,
onSelectTab: selectTab, onSelectTab: selectTab,
onTapContent: showContentDetail onTapContent: showContentDetail,
onTapDonate: showDonationDialog
) )
} else { } else {
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
@@ -158,6 +184,12 @@ struct CreatorChannelView: View {
private func showContentDetail(_ contentId: Int) { private func showContentDetail(_ contentId: Int) {
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId)) AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
} }
private func showDonationDialog() {
guard let creatorId = viewModel.response?.creator.creatorId else { return }
channelDonationViewModel.setCreatorId(creatorId, shouldFetch: false)
isShowChannelDonationDialog = true
}
} }
struct CreatorChannelView_Previews: PreviewProvider { struct CreatorChannelView_Previews: PreviewProvider {
@@ -194,7 +226,36 @@ struct CreatorChannelView_Previews: PreviewProvider {
isOwned: false, isOwned: false,
isRented: false isRented: false
), ),
channelDonations: [], channelDonations: [
CreatorChannelDonationResponse(
nickname: "팬 이름 50",
profileImageUrl: "https://picsum.photos/120/120?1",
can: 50,
message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
createdAtUtc: "2026-07-03T03:44:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 100",
profileImageUrl: "https://picsum.photos/120/120?2",
can: 100,
message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:43:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 500",
profileImageUrl: "https://picsum.photos/120/120?3",
can: 500,
message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:42:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 501",
profileImageUrl: "https://picsum.photos/120/120?4",
can: 501,
message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:41:00Z"
)
],
notices: [], notices: [],
schedules: [], schedules: [],
audioContents: [], audioContents: [],

View File

@@ -0,0 +1,251 @@
import SwiftUI
struct CreatorChannelDonationSection: View {
let channelDonations: [CreatorChannelDonationResponse]
let onSelectTab: (CreatorChannelTab) -> Void
let onTapDonate: () -> Void
init(
channelDonations: [CreatorChannelDonationResponse],
onSelectTab: @escaping (CreatorChannelTab) -> Void,
onTapDonate: @escaping () -> Void = {}
) {
self.channelDonations = channelDonations
self.onSelectTab = onSelectTab
self.onTapDonate = onTapDonate
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
SectionTitle(title: I18n.CreatorChannelHome.donation) {
onSelectTab(.donation)
}
if channelDonations.isEmpty {
CreatorChannelDonationEmptyCard(onTapDonate: onTapDonate)
.padding(.horizontal, SodaSpacing.s14)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s4) {
ForEach(channelDonations.indices, id: \.self) { index in
CreatorChannelDonationCard(donation: channelDonations[index])
.frame(width: 374)
}
}
.padding(.leading, SodaSpacing.s14)
.padding(.trailing, SodaSpacing.s14)
}
Button(action: onTapDonate) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_donation")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.scaledToFit()
.frame(width: 20, height: 20)
Text(I18n.ContentDetail.DonationDialog.title)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(SodaSpacing.s12)
.overlay(
Capsule()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s14)
}
}
.padding(.top, SodaSpacing.s20)
}
}
private struct CreatorChannelDonationEmptyCard: View {
let onTapDonate: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("처음으로 크리에이터를\n후원해 보세요!")
.appFont(size: 24, weight: .bold)
.foregroundColor(.white)
.lineSpacing(0)
.padding(.top, SodaSpacing.s20)
.padding(.leading, SodaSpacing.s20)
Spacer(minLength: 0)
Button(action: onTapDonate) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_donation")
.resizable()
.renderingMode(.template)
.foregroundColor(.black)
.scaledToFit()
.frame(width: 20, height: 20)
Text(I18n.ContentDetail.DonationDialog.title)
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(SodaSpacing.s12)
.background(Color.white)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
.padding(.bottom, SodaSpacing.s20)
}
.frame(height: 196)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
}
private struct CreatorChannelDonationCard: View {
let donation: CreatorChannelDonationResponse
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
header
Text(donation.messageText)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.lineLimit(2)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, SodaSpacing.s14)
}
.padding(.bottom, SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var header: some View {
HStack(alignment: .center, spacing: SodaSpacing.s8) {
DownsampledKFImage(
url: URL(string: donation.profileImageUrl),
size: CGSize(width: 42, height: 42)
)
.background(Color.white.opacity(0.6))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(donation.nickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.black)
.lineLimit(1)
.truncationMode(.tail)
Text(donation.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray700)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
canBadge
}
.padding(SodaSpacing.s14)
.background(donation.tierColor)
}
private var canBadge: some View {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("\(donation.can)")
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
}
.padding(.horizontal, SodaSpacing.s6)
.padding(.vertical, SodaSpacing.s4)
.background(Color.gray900)
.clipShape(Capsule())
}
}
private extension CreatorChannelDonationResponse {
var messageText: String {
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedMessage.isEmpty ? "\(can)캔을 후원하였습니다" : trimmedMessage
}
func relativeTimeText(now: Date = Date()) -> String {
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
}
var tierColor: Color {
switch can {
case 1...50:
return Color(hex: "E2E2E2")
case 51...100:
return Color(hex: "73EE01")
case 101...500:
return Color(hex: "00EAFF")
default:
return Color(hex: "FF4C3C")
}
}
}
struct CreatorChannelDonationSection_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s20) {
CreatorChannelDonationSection(
channelDonations: [],
onSelectTab: { _ in }
)
CreatorChannelDonationSection(
channelDonations: [
CreatorChannelDonationResponse(
nickname: "팬 이름 501",
profileImageUrl: "https://picsum.photos/120/120?4",
can: 501,
message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:41:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 50",
profileImageUrl: "https://picsum.photos/120/120?1",
can: 50,
message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
createdAtUtc: "2026-07-03T03:44:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 100",
profileImageUrl: "https://picsum.photos/120/120?2",
can: 100,
message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:43:00Z"
),
CreatorChannelDonationResponse(
nickname: "팬 이름 500",
profileImageUrl: "https://picsum.photos/120/120?3",
can: 500,
message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.",
createdAtUtc: "2026-07-03T03:42:00Z"
)
],
onSelectTab: { _ in }
)
}
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -5,17 +5,20 @@ struct CreatorChannelHomeView: View {
let onSelectTab: (CreatorChannelTab) -> Void let onSelectTab: (CreatorChannelTab) -> Void
let onTapLive: (Int) -> Void let onTapLive: (Int) -> Void
let onTapContent: (Int) -> Void let onTapContent: (Int) -> Void
let onTapDonate: () -> Void
init( init(
response: CreatorChannelHomeResponse, response: CreatorChannelHomeResponse,
onSelectTab: @escaping (CreatorChannelTab) -> Void, onSelectTab: @escaping (CreatorChannelTab) -> Void,
onTapLive: @escaping (Int) -> Void = { _ in }, onTapLive: @escaping (Int) -> Void = { _ in },
onTapContent: @escaping (Int) -> Void = { _ in } onTapContent: @escaping (Int) -> Void = { _ in },
onTapDonate: @escaping () -> Void = {}
) { ) {
self.response = response self.response = response
self.onSelectTab = onSelectTab self.onSelectTab = onSelectTab
self.onTapLive = onTapLive self.onTapLive = onTapLive
self.onTapContent = onTapContent self.onTapContent = onTapContent
self.onTapDonate = onTapDonate
} }
var body: some View { var body: some View {
@@ -29,6 +32,12 @@ struct CreatorChannelHomeView: View {
latestAudioContent: response.latestAudioContent, latestAudioContent: response.latestAudioContent,
onTapContent: onTapContent onTapContent: onTapContent
) )
CreatorChannelDonationSection(
channelDonations: response.channelDonations,
onSelectTab: onSelectTab,
onTapDonate: onTapDonate
)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black) .background(Color.black)

View File

@@ -367,19 +367,36 @@
### Phase 7: 채널 후원 섹션 ### Phase 7: 채널 후원 섹션
- [ ] **Task 7.1: 채널 후원 섹션 구현** - [x] **Task 7.1: 채널 후원 섹션 구현**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift` - 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift` - 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
- 확인: `SodaLive/Sources/App/AppStep.swift` - 확인: `SodaLive/Sources/App/AppStep.swift`
- 확인: `SodaLive/Sources/ContentView.swift` - 확인: `SodaLive/Sources/ContentView.swift`
- 디자인 기준:
- Figma 후원 아이템: `567:17927`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=567-17927&m=dev`
- 작업 내용: - 작업 내용:
- `channelDonations.isEmpty`이면 섹션을 표시하지 않는다. - `channelDonations.isEmpty`여도 섹션을 숨기지 않고 Figma `290:8820` 기준의 빈 후원 UI를 표시한다.
- 각 item`nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 표시한다. - 각 item은 Figma 후원 아이템처럼 외곽 card 배경을 `#202020`, corner radius 14, 내부 vertical spacing 14 기준으로 구성한다.
- 전체보기는 기존 `AppStep.channelDonationAll(creatorId:)`로 연결한다. - item 상단의 팬 정보/후원 금액 영역은 `profileImageUrl`, `nickname`, `createdAtUtc`, `can`을 표시한다.
- `profileImageUrl`은 42x42 원형 이미지로 표시하고, 이미지가 없거나 로드 실패 시 기존 프로필 placeholder 패턴을 따른다.
- `nickname`은 14pt medium, `createdAtUtc` 상대 시간은 14pt regular로 표시한다.
- 후원 금액은 우측 pill에 기존 can icon과 `{can}캔` 형식으로 표시한다.
- 팬 이름과 후원 금액을 포함하는 상단 영역의 배경색은 후원 금액 `can`에 따라 다르게 적용한다.
- `1...50`: `#E2E2E2`
- `51...100`: `#73EE01`
- `101...500`: `#00EAFF`
- `501...`: `#FF4C3C`
- 상단 영역 배경이 밝은 색상이므로 `nickname`은 black, `createdAtUtc`는 기존 gray 700 계열로 표시하고, 금액 pill은 `#202020` 배경과 white text를 유지한다.
- `message`는 상단 영역 아래에 16pt regular, white text로 표시하고, 긴 문장은 화면 폭 안에서 줄바꿈한다.
- 전체보기는 기존 `AppStep.channelDonationAll(creatorId:)`로 이동하지 않고, 부모에서 주입받은 `onSelectTab(.donation)` closure로 현재 크리에이터 채널의 후원 tab을 선택한다.
- 빈 후원 UI는 section title 아래 `#202020` card, `처음으로 크리에이터를\n후원해 보세요!` 문구, white capsule `후원하기` 버튼, black `ic_new_donation` icon/text를 표시한다.
- 후원하기 버튼은 기존 `UserProfileView`에서 사용하던 `LiveRoomDonationDialogView`를 그대로 표시한다.
- Dialog 설정은 기존 채널 후원과 동일하게 `isAudioContentDonation: false`, `messageLimit: 100`, `I18n.MemberChannel.secretDonationLabel`, `I18n.MemberChannel.secretDonationMinimumCanMessage`, `shouldPrefixSecretInMessagePlaceholder: false`를 사용한다.
- 후원 전송은 기존 `ChannelDonationViewModel.postChannelDonation`을 재사용하고, 성공 시 현재 채널 홈 API를 다시 호출해 후원 섹션에 즉시 반영한다.
- 검증 기준: - 검증 기준:
- 실행 명령: `rg "struct CreatorChannelDonationSection|channelDonations|CreatorChannelDonationResponse|channelDonationAll|createdAtUtc|can" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift` - 실행 명령: `rg "struct CreatorChannelDonationSection|CreatorChannelDonationEmptyCard|처음으로 크리에이터를|ic_new_donation|LiveRoomDonationDialogView|ChannelDonationViewModel|postChannelDonation|fetchHome\(creatorId: creatorId\)|createdAtUtc|can|#E2E2E2|#73EE01|#00EAFF|#FF4C3C|onSelectTab|\\.donation|channelDonationAll" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
- 기대 결과: 빈 후원 섹션 숨김과 기존 후원 전체보기 route 연결이 확인된다. - 기대 결과: 빈 후원 UI, 후원 금액별 상단 영역 컬러, 기존 후원 Dialog 재사용, 후원 성공 후 홈 API 재조회, 후원 tab 전체보기 연결이 확인되고 신규 구현 경로에서 `channelDonationAll` route를 사용하지 않는다.
### Phase 8: 공지 섹션 ### Phase 8: 공지 섹션
@@ -620,6 +637,8 @@
- 2026-07-02: 이미 완료된 Phase 1~2 기록은 보존하고, 후속 구조 정리를 위해 Task 2.4 `공통 CreatorChannel shell로 구조 정리`를 추가했다. - 2026-07-02: 이미 완료된 Phase 1~2 기록은 보존하고, 후속 구조 정리를 위해 Task 2.4 `공통 CreatorChannel shell로 구조 정리`를 추가했다.
- 2026-07-02: 사용자 확인 사항을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_fill`)을 Task 3.1 및 최종 회귀 검색 기준에 추가했다. - 2026-07-02: 사용자 확인 사항을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_fill`)을 Task 3.1 및 최종 회귀 검색 기준에 추가했다.
- 2026-07-03: Phase 5 현재 라이브 섹션을 구현했다. Figma `290:8950` 기준으로 LIVE pill, 시작 시간, 제목, 커버 이미지/placeholder, 유료 캔 배지, 성인 표시를 반영하고 `CreatorChannelHomeView``currentLive` optional 기반으로 연결했다. - 2026-07-03: Phase 5 현재 라이브 섹션을 구현했다. Figma `290:8950` 기준으로 LIVE pill, 시작 시간, 제목, 커버 이미지/placeholder, 유료 캔 배지, 성인 표시를 반영하고 `CreatorChannelHomeView``currentLive` optional 기반으로 연결했다.
- 2026-07-03: Phase 7 채널 후원 섹션을 구현했다. Figma `296:14908`, 후원 아이템 `567:17927` 기준으로 후원 금액별 상단 컬러(`1...50` `#E2E2E2`, `51...100` `#73EE01`, `101...500` `#00EAFF`, `501...` `#FF4C3C`), `ic_new_donation` 후원하기 버튼, `.donation` tab 전체보기 전환, Preview 금액 구간별 샘플을 반영했다. `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 신규 파일 target 포함과 컴파일을 검증했다.
- 2026-07-03: 사용자 요청에 따라 Phase 7 후원하기 버튼을 기존 `LiveRoomDonationDialogView`/`ChannelDonationViewModel.postChannelDonation` 흐름으로 연결하고, 성공 시 `CreatorChannelViewModel.fetchHome(creatorId:)`로 현재 채널 홈 데이터를 다시 불러와 후원 섹션에 즉시 반영하도록 계획을 갱신했다. 후원 내역이 없는 경우 Figma `290:8820` 기준의 빈 후원 UI를 표시하도록 Task 7.1에 반영했다.
- 2026-07-03: Phase 6 최신 오디오 콘텐츠 섹션을 구현했다. Figma `296:14898`, tag `567:18272`, `567:18274` 기준으로 88px 썸네일, 상단/하단 tag overlay, `New` label, 제목, 재생시간을 반영하고 `latestAudioContent` optional 기반 숨김 및 `AppStep.contentDetail(contentId:)` 상세 진입을 연결했다. `rg` 체크리스트와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다. - 2026-07-03: Phase 6 최신 오디오 콘텐츠 섹션을 구현했다. Figma `296:14898`, tag `567:18272`, `567:18274` 기준으로 88px 썸네일, 상단/하단 tag overlay, `New` label, 제목, 재생시간을 반영하고 `latestAudioContent` optional 기반 숨김 및 `AppStep.contentDetail(contentId:)` 상세 진입을 연결했다. `rg` 체크리스트와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다.
- 2026-07-03: Phase 4를 완료해 `CreatorChannelView`에서 홈 탭은 `CreatorChannelHomeView`, 홈 외 탭은 `CreatorChannelPlaceholderTabView(title: selectedTab.title)`로 분기하고, 홈 섹션 전체보기용 tab 선택 closure가 `.audio`, `.series`, `.community`, `.fanTalk`, `.donation`으로 전환되도록 연결했다. - 2026-07-03: Phase 4를 완료해 `CreatorChannelView`에서 홈 탭은 `CreatorChannelHomeView`, 홈 외 탭은 `CreatorChannelPlaceholderTabView(title: selectedTab.title)`로 분기하고, 홈 섹션 전체보기용 tab 선택 closure가 `.audio`, `.series`, `.community`, `.fanTalk`, `.donation`으로 전환되도록 연결했다.
- 2026-07-02: 기존 `UserProfile` 구현을 확인해 title bar 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하고, `ic_bar_bell`/`ic_bar_bell_fill` 터치 시 각각 `notify: true`/`notify: false` 호출이 일어나도록 계획에 반영했다. - 2026-07-02: 기존 `UserProfile` 구현을 확인해 title bar 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하고, `ic_bar_bell`/`ic_bar_bell_fill` 터치 시 각각 `notify: true`/`notify: false` 호출이 일어나도록 계획에 반영했다.