diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index a5e7a7760..c8b6ccf69 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -247,6 +247,9 @@ 4C86A6122F25C0B10023AA2D /* WorkshopBundleGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */; }; 4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */; }; 4C86A6182F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */; }; + 4C86A61B2F29D7C60023AA2D /* Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61A2F29D7C60023AA2D /* Receipt.swift */; }; + 4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */; }; + 4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */; }; 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */; }; 4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */; }; 4CA9C6D62ECB7AEA00CA546B /* BadWords.json in Resources */ = {isa = PBXBuildFile; fileRef = 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */; }; @@ -689,6 +692,9 @@ 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = ""; }; 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopKeyringGridView.swift; sourceTree = ""; }; 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTemplateSelectSheet.swift; sourceTree = ""; }; + 4C86A61A2F29D7C60023AA2D /* Receipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Receipt.swift; sourceTree = ""; }; + 4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseHistoryVIewModel.swift; sourceTree = ""; }; + 4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseHistoryView.swift; sourceTree = ""; }; 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = ""; }; 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBottom.swift"; sourceTree = ""; }; 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BadWords.json; sourceTree = ""; }; @@ -1010,6 +1016,7 @@ 38A596722EAFA8D20003D712 /* KeychyUser.swift */, 4CA9C6F62ECBA45200CA546B /* KeychyNotification.swift */, 382800D02EC05D4E005F1332 /* PostOffice.swift */, + 4C86A61A2F29D7C60023AA2D /* Receipt.swift */, ); path = User; sourceTree = ""; @@ -1479,6 +1486,7 @@ children = ( C6EE7AD62EB445F6002B5669 /* MyPageView.swift */, 4C65306B2EBC889B000F8154 /* ChangeNameView.swift */, + 4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */, 4C65306D2EBCF157000F8154 /* TermsView.swift */, ); path = MyPage; @@ -1994,6 +2002,7 @@ 4CC8D0242EF11CD200317467 /* HomeViewModel.swift */, 4CC8D01E2EF0447100317467 /* MyPageViewModel.swift */, 4CC8D01D2EF0447100317467 /* ChangeNameViewModel.swift */, + 4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */, 4CC8D00A2EEFC36900317467 /* AlarmViewModel.swift */, 4CC8D0212EF110A300317467 /* NotificationGiftViewModel.swift */, ); @@ -2555,6 +2564,7 @@ 4C4733AD2F1FA388005D2376 /* ClearSketchVM+ImageConversion.swift in Sources */, 4C4733AE2F1FA388005D2376 /* CinematicAppearanceModifier.swift in Sources */, 4C4733AF2F1FA388005D2376 /* ClearSketchVM+Effect.swift in Sources */, + 4C86A61B2F29D7C60023AA2D /* Receipt.swift in Sources */, 4C4733B02F1FA388005D2376 /* AcrylicPhotoVM+Effect.swift in Sources */, 4C4733B12F1FA388005D2376 /* PixelVM.swift in Sources */, 4C4733B22F1FA388005D2376 /* KeyringCompleteView+ReviewCheck.swift in Sources */, @@ -2660,6 +2670,7 @@ 4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */, 3828F54B2EC4D0C500F1B040 /* CollectionView+Handlers.swift in Sources */, 4CF2A96A2F0B94EA00BA9FDA /* View+PullToRefresh.swift in Sources */, + 4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */, 38C147BB2EB13B2F00A8E511 /* CircleGlassButton.swift in Sources */, AA6298542EC39065001576C0 /* BundleCreateView.swift in Sources */, 38C147C72EB1F57F00A8E511 /* StorageManager.swift in Sources */, @@ -2696,6 +2707,7 @@ C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */, 4CA9C6F72ECBA45200CA546B /* KeychyNotification.swift in Sources */, 4CF2A9682F0B91F300BA9FDA /* AnimatedGIFView.swift in Sources */, + 4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */, 4CC8D01F2EF0447100317467 /* ChangeNameViewModel.swift in Sources */, 4CC8D0202EF0447100317467 /* MyPageViewModel.swift in Sources */, 4C4734072F226B81005D2376 /* WorkshopMakeMenu.swift in Sources */, diff --git a/Keychy/Keychy/CommonModels/User/Receipt.swift b/Keychy/Keychy/CommonModels/User/Receipt.swift new file mode 100644 index 000000000..9d8776c96 --- /dev/null +++ b/Keychy/Keychy/CommonModels/User/Receipt.swift @@ -0,0 +1,37 @@ +// +// Receipt.swift +// Keychy +// +// Created by 길지훈 on 1/28/26. +// + +import Foundation +import FirebaseFirestore + +struct Receipt: Identifiable, Codable { + @DocumentID var id: String? + var itemID: String + var itemName: String + var itemType: String + var price: Int + var purchasedAt: Date + + /// 아이템 타입 표시명 + var itemTypeDisplayName: String { + switch itemType { + case "template": return "템플릿" + case "background": return "배경" + case "carabiner": return "카라비너" + case "particle": return "파티클" + case "sound": return "사운드" + default: return itemType + } + } + + /// 구매일시 포맷 (2026-01-01 22:22:22) + var purchasedAtFormatted: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.string(from: purchasedAt) + } +} diff --git a/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift b/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift index 4552e8086..ea73ac73e 100644 --- a/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift +++ b/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift @@ -35,6 +35,7 @@ struct PurchasePopup: View { Text("\(myCoin)") .typography(.nanum16EB) .foregroundColor(.main500) + .padding(.top, 3) } .padding(.bottom, 4) @@ -42,21 +43,15 @@ struct PurchasePopup: View { Button(action: onConfirm) { HStack(spacing: 4) { Image(.myCoinMini) - .resizable() - .frame(width: 34, height: 34) - .padding(.bottom, 4) Text("\(price)") .typography(.nanum18EB) .foregroundColor(.white100) - .frame(height: 32) - .padding(.top, 4) - - } - + .padding(.top, 2) + } } .frame(maxWidth: .infinity) - .frame(height: 48) + .padding(.vertical, 12.5) .background( RoundedRectangle(cornerRadius: 100) .fill(.black80) diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index c15cd1222..2759f82d7 100644 --- a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift +++ b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift @@ -63,6 +63,7 @@ struct Typography { static let suit12R25 = Typography(font: .custom(.suitRegular, size: 12), lineSpacing: 13) /// 10 + static let suit10R = Typography(font: .custom(.suitRegular, size: 10), lineSpacing: 0) static let suit10SB = Typography(font: .custom(.suitSemiBold, size: 10), lineSpacing: 0) /// 9 diff --git a/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift b/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift index 2a7da2dca..c21283726 100644 --- a/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift +++ b/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift @@ -41,76 +41,132 @@ class ItemPurchaseManager { private init() {} + // MARK: - Public Methods /// 워크샵 아이템 구매 처리 - /// - Parameters: - /// - item: 구매할 아이템 (WorkshopItem 프로토콜 준수) - /// - userManager: UserManager 인스턴스 - /// - Returns: PurchaseResult (성공, 코인부족, 실패) func purchaseWorkshopItem(_ item: any WorkshopItem, userManager: UserManager) async -> PurchaseResult { - // 1. 현재 유저 정보 확인 - guard let userId = userManager.currentUser?.id, - let userCoins = userManager.currentUser?.coin, - let itemId = item.id else { + // 1. 유저/아이템 정보 검증 + guard let purchaseInfo = validatePurchaseInfo(item: item, userManager: userManager) else { return .failed("사용자 정보를 찾을 수 없습니다") } - // 2. 재화 충분한지 확인 - guard userCoins >= item.workshopPrice else { + // 2. 로컬 코인 확인 + guard purchaseInfo.userCoins >= item.workshopPrice else { return .insufficientCoins } - // 3. Firebase 업데이트 - let db = Firestore.firestore() - let userRef = db.collection("User").document(userId) + // 3. Firebase 구매 처리 + let userRef = Firestore.firestore().collection("User").document(purchaseInfo.userId) do { - // 현재 문서 읽기 - let snapshot = try await userRef.getDocument() - - guard let data = snapshot.data() else { - return .failed("사용자 정보를 찾을 수 없습니다") - } - - let currentCoin = data["coin"] as? Int ?? 0 - - // 재화 재확인 + // 서버 코인 확인 & 업데이트 + let currentCoin = try await fetchCurrentCoin(userRef: userRef) guard currentCoin >= item.workshopPrice else { return .insufficientCoins } - // 업데이트할 데이터 준비 - var updateData: [String: Any] = [ - "coin": currentCoin - item.workshopPrice - ] - - // 아이템 타입에 따라 해당 필드에 추가 - if item is KeyringTemplate { - updateData["templates"] = FieldValue.arrayUnion([itemId]) - } else if item is Background { - updateData["backgrounds"] = FieldValue.arrayUnion([itemId]) - } else if item is Carabiner { - updateData["carabiners"] = FieldValue.arrayUnion([itemId]) - } else if item is Particle { - updateData["particleEffects"] = FieldValue.arrayUnion([itemId]) - } else if item is Sound { - updateData["soundEffects"] = FieldValue.arrayUnion([itemId]) - } - - // Firebase 업데이트 + // 코인 차감 & 아이템 추가 + let updateData = buildUpdateData(item: item, itemId: purchaseInfo.itemId, currentCoin: currentCoin) try await userRef.updateData(updateData) - // 4. UserManager 데이터 갱신 - await withCheckedContinuation { (continuation: CheckedContinuation) in - userManager.loadUserInfo(uid: userId) { _ in - continuation.resume() - } - } + // 구매내역 저장 + try await saveReceipt(item: item, itemId: purchaseInfo.itemId, userRef: userRef) + + // UserManager 갱신 + await refreshUserData(userId: purchaseInfo.userId, userManager: userManager) return .success } catch { - print("구매 실패 에러: \(error.localizedDescription)") + print("구매 실패: \(error.localizedDescription)") return .failed("구매 처리 중 오류가 발생했습니다") } } + + // MARK: - Private Methods + /// 구매에 필요한 정보 검증 + private func validatePurchaseInfo( + item: any WorkshopItem, + userManager: UserManager + ) -> (userId: String, userCoins: Int, itemId: String)? { + guard let userId = userManager.currentUser?.id, + let userCoins = userManager.currentUser?.coin, + let itemId = item.id else { + return nil + } + return (userId, userCoins, itemId) + } + + /// 서버에서 현재 코인 조회 + private func fetchCurrentCoin(userRef: DocumentReference) async throws -> Int { + let snapshot = try await userRef.getDocument() + guard let data = snapshot.data() else { + throw ItemPurchaseError.userNotFound + } + return data["coin"] as? Int ?? 0 + } + + /// 업데이트 데이터 생성 (코인 차감 + 아이템 추가) + private func buildUpdateData( + item: any WorkshopItem, + itemId: String, + currentCoin: Int + ) -> [String: Any] { + var updateData: [String: Any] = [ + "coin": currentCoin - item.workshopPrice + ] + + let fieldName = itemFieldName(for: item) + updateData[fieldName] = FieldValue.arrayUnion([itemId]) + + return updateData + } + + /// 아이템 타입에 해당하는 Firestore 필드명 + private func itemFieldName(for item: any WorkshopItem) -> String { + switch item { + case is KeyringTemplate: return "templates" + case is Background: return "backgrounds" + case is Carabiner: return "carabiners" + case is Particle: return "particleEffects" + case is Sound: return "soundEffects" + default: return "unknown" + } + } + + /// 아이템 타입 문자열 (Receipt용) + private func itemTypeName(for item: any WorkshopItem) -> String { + switch item { + case is KeyringTemplate: return "template" + case is Background: return "background" + case is Carabiner: return "carabiner" + case is Particle: return "particle" + case is Sound: return "sound" + default: return "unknown" + } + } + + /// 구매내역(Receipt) 저장 + private func saveReceipt( + item: any WorkshopItem, + itemId: String, + userRef: DocumentReference + ) async throws { + let receiptData: [String: Any] = [ + "itemID": itemId, + "itemName": item.name, + "itemType": itemTypeName(for: item), + "price": item.workshopPrice, + "purchasedAt": Timestamp(date: Date()) + ] + try await userRef.collection("Receipts").addDocument(data: receiptData) + } + + /// UserManager 데이터 갱신 + private func refreshUserData(userId: String, userManager: UserManager) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + userManager.loadUserInfo(uid: userId) { _ in + continuation.resume() + } + } + } } diff --git a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index 2fa0a4efe..7a2c39fdc 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift @@ -18,6 +18,7 @@ enum HomeRoute: Hashable, BundleRoute { case coinCharge case myPageView case changeName + case purchaseHistory case alarmView case notificationGiftView(postOfficeId: String) case introView diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift new file mode 100644 index 000000000..3fcb97e60 --- /dev/null +++ b/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift @@ -0,0 +1,36 @@ +// +// PurchaseHistoryVIewModel.swift +// Keychy +// +// Created by 길지훈 on 1/28/26. +// + +import SwiftUI +import FirebaseFirestore + +@Observable +class PurchaseHistoryViewModel { + var receipts: [Receipt] = [] + + private let db = Firestore.firestore() + + /// 구매내역 가져오기 + func fetchReceipts(userId: String) async { + do { + let snapshot = try await db + .collection("User") + .document(userId) + .collection("Receipts") + .order(by: "purchasedAt", descending: true) + .getDocuments() + + receipts = snapshot.documents.compactMap { doc in + /// 각 Firebase 문서를 Codable 모델로 변환, Receipt 타입으로 변환 + try? doc.data(as: Receipt.self) + } + + } catch { + print("구매내역 로드 실패: \(error.localizedDescription)") + } + } +} diff --git a/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift b/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift index 0390aab74..e0b2c5acb 100644 --- a/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift @@ -181,6 +181,16 @@ extension MyPageView { .foregroundStyle(.black100) } .buttonStyle(.plain) + .padding(.bottom, 30) + + Button { + router.push(.purchaseHistory) + } label: { + Text("구매내역") + .typography(.suit16M) + .foregroundStyle(.black100) + } + .buttonStyle(.plain) Divider() .padding(.top, 30) diff --git a/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift new file mode 100644 index 000000000..e24e56d63 --- /dev/null +++ b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift @@ -0,0 +1,120 @@ +// +// PurchaseHistoryView.swift +// Keychy +// +// Created by 길지훈 on 1/28/26. +// + +import SwiftUI + +struct PurchaseHistoryView: View { + @Bindable var router: NavigationRouter + @State private var viewModel = PurchaseHistoryViewModel() + @Environment(UserManager.self) private var userManager + + var body: some View { + Group { + if viewModel.receipts.isEmpty { + emptyStateView + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.receipts) { receipt in + historyCard(receipt: receipt) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + } + } + .navigationTitle("구매 내역") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .toolbar { + backToolbarItem + } + .onAppear { + Task { + if let userId = userManager.currentUser?.id { + await viewModel.fetchReceipts(userId: userId) + } + } + } + } +} + +// MARK: - 컴포넌트 +extension PurchaseHistoryView { + /// 빈 상태 뷰 + private var emptyStateView: some View { + ZStack { + VStack(spacing: 15) { + Image(.emptyViewIcon) + .padding(.trailing, 26) + Text("구매한 아이템이 없어요") + .typography(.suit15R) + .foregroundStyle(.black100) + } + } + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func historyCard(receipt: Receipt) -> some View { + VStack(spacing: 0) { + + /// 아이템 이름 & 가격 + HStack { + Text("\(receipt.itemName)") + .typography(.notosans15M) + .foregroundStyle(.black100) + Spacer() + HStack(spacing: 4) { + Image(.myCoinMini) + Text("\(receipt.price)") + .typography(.nanum16EB) + } + } + .padding(.bottom, 2) + + /// 아이템 타입 + HStack { + Text("\(receipt.itemTypeDisplayName)") + .typography(.notosans12M) + .foregroundStyle(.gray400) + .padding(.bottom, 10) + Spacer() + } + + /// 결제 일시 + HStack(spacing: 6) { + Text("결제 일시") + Text("\(receipt.purchasedAtFormatted)") + Spacer() + } + .typography(.suit10SB) + .foregroundStyle(.gray400) + } + .padding(.horizontal, 16) + .padding(.vertical, 16.62) + .background(Color.gray50) + .clipShape(.rect(cornerRadius: 12)) + } +} + + +// MARK: - Toolbar Items +extension PurchaseHistoryView { + var backToolbarItem: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + router.pop() + } label: { + Image(.backIcon) + .resizable() + .frame(width: 32, height: 32) + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 7dd0d1ee5..1b6869e76 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift @@ -49,6 +49,8 @@ struct HomeTab: View { MyPageView(router: router) case .changeName: ChangeNameView(router: router) + case .purchaseHistory: + PurchaseHistoryView(router: router) case .alarmView: AlarmView(router: router) case .notificationGiftView(let postOfficeId): diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift index 3b3112856..286c3518f 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift @@ -82,16 +82,14 @@ struct WorkshopItemActionButton: View { } label: { HStack(spacing: 5) { Image(.myCoinMini) - .resizable() - .scaledToFit() - .frame(width: 32) Text("\(item.workshopPrice)") .typography(.nanum18EB) .foregroundStyle(.white100) + .padding(.top, 2) } .frame(maxWidth: .infinity) - .frame(height: 36) + .padding(.vertical, 8) } .buttonStyle(.glassProminent) .tint(.black80)