From 0319884d39074975aa1fe3a0601df74261ebdeaf Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 14:43:43 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EA=B5=AC=EB=A7=A4=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=AA=A8=EB=8D=B8=20-=20Receipt.swift=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++++ Keychy/Keychy/CommonModels/User/Receipt.swift | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 Keychy/Keychy/CommonModels/User/Receipt.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index a5e7a776..27665871 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -247,6 +247,7 @@ 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 */; }; 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 +690,7 @@ 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 = ""; }; 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 +1012,7 @@ 38A596722EAFA8D20003D712 /* KeychyUser.swift */, 4CA9C6F62ECBA45200CA546B /* KeychyNotification.swift */, 382800D02EC05D4E005F1332 /* PostOffice.swift */, + 4C86A61A2F29D7C60023AA2D /* Receipt.swift */, ); path = User; sourceTree = ""; @@ -2555,6 +2558,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 */, diff --git a/Keychy/Keychy/CommonModels/User/Receipt.swift b/Keychy/Keychy/CommonModels/User/Receipt.swift new file mode 100644 index 00000000..84f3dd50 --- /dev/null +++ b/Keychy/Keychy/CommonModels/User/Receipt.swift @@ -0,0 +1,19 @@ +// +// 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 + +} From a38ae9b6ef801928b9885ff637823e8a8bc57da6 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 15:15:21 +0900 Subject: [PATCH 2/9] =?UTF-8?q?style:=20=EB=AD=89=EC=B9=98=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EA=B5=AC=EB=A7=A4=20=EB=B2=84=ED=8A=BC,?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20=ED=95=98=EC=9D=B4=ED=8C=8C=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 아이콘, 버튼크기 등 --- .../Core/Components/View/Popup/PurchasePopup.swift | 13 ++++--------- .../Views/Components/WorkshopItemActionButton.swift | 6 ++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift b/Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift index 4552e808..ea73ac73 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/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift index 3b311285..286c3518 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) From 34e9aa8f9cd0fb047d2b25b082ae49e3c151f94c Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 15:17:45 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20ItemPurchaseManager=20-=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=EB=82=B4=EC=97=AD=20=ED=8C=8C=EB=B2=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20&=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복잡했던 로직, private func로 분리 --- .../Core/Firebase/ItemPurchaseManager.swift | 154 ++++++++++++------ 1 file changed, 105 insertions(+), 49 deletions(-) diff --git a/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift b/Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift index 2a7da2dc..c2128372 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() + } + } + } } From ac3e3aee3b427abcec45910d80b21d08c2cd722d Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 15:29:38 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EA=B5=AC=EB=A7=A4=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=A3=A8=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EB=82=B4=EC=97=AD=20=EB=B7=B0=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++ .../Core/Navigation/Routes/HomeRoute.swift | 1 + .../ViewModels/PurchaseHistoryVIewModel.swift | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 27665871..c5de047c 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -248,6 +248,7 @@ 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 */; }; 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 */; }; @@ -691,6 +692,7 @@ 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 = ""; }; 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 = ""; }; @@ -1997,6 +1999,7 @@ 4CC8D0242EF11CD200317467 /* HomeViewModel.swift */, 4CC8D01E2EF0447100317467 /* MyPageViewModel.swift */, 4CC8D01D2EF0447100317467 /* ChangeNameViewModel.swift */, + 4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */, 4CC8D00A2EEFC36900317467 /* AlarmViewModel.swift */, 4CC8D0212EF110A300317467 /* NotificationGiftViewModel.swift */, ); @@ -2700,6 +2703,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/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index 2fa0a4ef..7a2c39fd 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 00000000..8ae9b671 --- /dev/null +++ b/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift @@ -0,0 +1,40 @@ +// +// PurchaseHistoryVIewModel.swift +// Keychy +// +// Created by 길지훈 on 1/28/26. +// + +import SwiftUI +import FirebaseFirestore + +@Observable +class PurchaseHistoryViewModel { + var receipts: [Receipt] = [] + var isLoading: Bool = false + + private let db = Firestore.firestore() + + /// 구매내역 가져오기 + func fetchReceipts(userId: String) async { + isLoading = true + + 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)") + } + isLoading = false + } +} From 2249a6fac7d4855cf1a0be768ea495e5fbbd97da Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 15:35:42 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20MyPageView=20-=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B2=84=ED=8A=BC=20/=20=EB=B7=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++++ .../Home/Views/MyPage/MyPageView.swift | 10 ++++++++++ .../Home/Views/MyPage/PurchaseHistoryView.swift | 16 ++++++++++++++++ .../Keychy/Presentation/Tab/Views/HomeTab.swift | 2 ++ 4 files changed, 32 insertions(+) create mode 100644 Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index c5de047c..c8b6ccf6 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -249,6 +249,7 @@ 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 */; }; @@ -693,6 +694,7 @@ 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 = ""; }; @@ -1484,6 +1486,7 @@ children = ( C6EE7AD62EB445F6002B5669 /* MyPageView.swift */, 4C65306B2EBC889B000F8154 /* ChangeNameView.swift */, + 4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */, 4C65306D2EBCF157000F8154 /* TermsView.swift */, ); path = MyPage; @@ -2667,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 */, diff --git a/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift b/Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift index 0390aab7..e0b2c5ac 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 00000000..5df7a19d --- /dev/null +++ b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift @@ -0,0 +1,16 @@ +// +// PurchaseHistoryView.swift +// Keychy +// +// Created by 길지훈 on 1/28/26. +// + +import SwiftUI + +struct PurchaseHistoryView: View { + @Bindable var router: NavigationRouter + + var body: some View { + Text("구매내역") + } +} diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 7dd0d1ee..1b6869e7 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): From a0b7a578a40da62dbabe34b4421b5f299646ad42 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 16:31:24 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20Receipt=20-=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=ED=83=80=EC=9E=85=20=ED=91=9C=EC=8B=9C,=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9D=BC=EC=8B=9C=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/CommonModels/User/Receipt.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/CommonModels/User/Receipt.swift b/Keychy/Keychy/CommonModels/User/Receipt.swift index 84f3dd50..9d8776c9 100644 --- a/Keychy/Keychy/CommonModels/User/Receipt.swift +++ b/Keychy/Keychy/CommonModels/User/Receipt.swift @@ -15,5 +15,23 @@ struct Receipt: Identifiable, Codable { 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) + } } From 05e714aea18d674dae1ca1e2961629402ccec8ab Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 16:32:07 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20PurchaseHistoryView=20-=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=EB=82=B4=EC=97=AD=20=EB=B7=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typo 추가 (suit10R) - 야무지다 잘짯다 --- .../DesignSystem/Typography/Typography.swift | 1 + .../Views/MyPage/PurchaseHistoryView.swift | 87 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index c15cd122..2759f82d 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/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift index 5df7a19d..70248695 100644 --- a/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift @@ -9,8 +9,91 @@ import SwiftUI struct PurchaseHistoryView: View { @Bindable var router: NavigationRouter - + @State private var viewModel = PurchaseHistoryViewModel() + @Environment(UserManager.self) private var userManager + var body: some View { - Text("구매내역") + 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 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) + } + } } } From 7b0ce22c63483d2b0064a5d705b7553fdb8b84fa Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 16:46:48 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추후 필요하면 디자인 받고 구현 --- .../Home/ViewModels/PurchaseHistoryVIewModel.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift index 8ae9b671..3fcb97e6 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/PurchaseHistoryVIewModel.swift @@ -11,14 +11,11 @@ import FirebaseFirestore @Observable class PurchaseHistoryViewModel { var receipts: [Receipt] = [] - var isLoading: Bool = false private let db = Firestore.firestore() /// 구매내역 가져오기 func fetchReceipts(userId: String) async { - isLoading = true - do { let snapshot = try await db .collection("User") @@ -35,6 +32,5 @@ class PurchaseHistoryViewModel { } catch { print("구매내역 로드 실패: \(error.localizedDescription)") } - isLoading = false } } From c39658bf9d398fd1d0af0d974f4fa2cc15575990 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 28 Jan 2026 16:47:02 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20PurchaseHistoryView=20-=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=EB=82=B4=EC=97=AD=20=EB=B9=88=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EB=B7=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/MyPage/PurchaseHistoryView.swift | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift index 70248695..e24e56d6 100644 --- a/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/MyPage/PurchaseHistoryView.swift @@ -13,14 +13,20 @@ struct PurchaseHistoryView: View { @Environment(UserManager.self) private var userManager var body: some View { - ScrollView { - LazyVStack(spacing: 16) { - ForEach(viewModel.receipts) { receipt in - historyCard(receipt: receipt) + 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) } } - .padding(.horizontal, 20) - .padding(.vertical, 16) } .navigationTitle("구매 내역") .navigationBarTitleDisplayMode(.inline) @@ -40,6 +46,21 @@ struct PurchaseHistoryView: View { // 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) {