From f4dad60bda9c46d794d92c9550e81fa40eba3907 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:37:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#461]=20=EC=B6=95=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=9C=B4=EB=A5=BC=20=ED=95=99=EA=B5=90=20=EC=A0=9C=ED=9C=B4=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=EC=97=90=20=ED=95=A8=EA=BB=98=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=ED=95=98=EA=B3=A0=20=EB=8F=84=EC=9B=80=EB=A7=90=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/ko.lproj/Localizable.strings | 2 + .../Data/Firebase/FirebaseRemoteConfig.swift | 12 +- .../LikedPartnershipViewController.swift | 2 +- .../Map/View/FestivalInfoBannerView.swift | 92 +++++++++ .../Presentation/Map/View/MainMapView.swift | 54 ++++- .../MainMapViewController+Marker.swift | 20 +- .../MainMapViewController+Network.swift | 185 ++++++++++++------ .../MainMapViewController.swift | 135 +++++++++---- .../Sources/Utility/Literal/TextLiteral.swift | 8 + .../UnitTests/FestivalPartnershipTests.swift | 142 ++++++++++++++ .../UnitTests/StoreCategoryMappingTests.swift | 1 - .../festivalPrimary.colorset/Contents.json | 26 +-- .../festival_cafe_pin.pdf | Bin 6570 -> 6408 bytes .../festival_pub_pin.pdf | Bin 7358 -> 8661 bytes .../festival_restaurant_pin.pdf | Bin 8827 -> 7103 bytes .../ic_festival_info.imageset/Contents.json | 15 ++ .../ic_festival_info.pdf | Bin 0 -> 7280 bytes 17 files changed, 565 insertions(+), 129 deletions(-) create mode 100644 EATSSU/App/Sources/Presentation/Map/View/FestivalInfoBannerView.swift create mode 100644 EATSSU/Tests/UnitTests/FestivalPartnershipTests.swift create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/ic_festival_info.pdf diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 693e2bd5..2296203f 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -177,6 +177,8 @@ /// MainMapVC - "제휴 지도" "map.map" = "지도"; /// MainMapView - "전체" +/// 축제 제휴 안내 말풍선 +"map.festivalBannerMessage" = "2026 동연제 제휴 매장을 확인해보세요!\n기존 제휴와 중복 적용되지 않아요."; "map.all" = "전체"; /// "축제" "map.festival" = "축제"; diff --git a/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift b/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift index 227dee82..4c648cb6 100644 --- a/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift +++ b/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift @@ -19,9 +19,15 @@ class FirebaseRemoteConfig { return remoteConfig["app_theme"].stringValue ?? "default" } - /// 축제 탭 노출 여부 (noticeCheck 이후 호출) - var isFestivalEnabled: Bool { - return remoteConfig["festival_tab_enabled"].boolValue + /// 축제 제휴(마커·도움말) 노출 여부. 행사 기간에만 Remote Config에서 켠다 + /// 구버전 앱이 쓰는 `festival_tab_enabled`와 분리해, 켜도 이전 버전 동작에 영향이 없도록 한다 + var isFestivalPartnershipEnabled: Bool { + #if DEBUG + // 로컬 확인용: 개발 빌드에서는 항상 노출 + return true + #else + return remoteConfig["festival_partnership_enabled"].boolValue + #endif } private init() { diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index 6f9f9c41..1b8620ea 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -24,7 +24,7 @@ final class LikedPartnershipViewController: BaseViewController { } /// 찜 목록 필터 (축제 제외) - private static let filters: [PartnershipFilter] = PartnershipFilter.allCases.filter { $0 != .festival } + private static let filters: [PartnershipFilter] = PartnershipFilter.allCases // MARK: - Properties diff --git a/EATSSU/App/Sources/Presentation/Map/View/FestivalInfoBannerView.swift b/EATSSU/App/Sources/Presentation/Map/View/FestivalInfoBannerView.swift new file mode 100644 index 00000000..1be255d9 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/View/FestivalInfoBannerView.swift @@ -0,0 +1,92 @@ +// +// FestivalInfoBannerView.swift +// EATSSU +// +// Created by 황상환 on 9/13/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 축제 제휴 안내 말풍선. 도움말 아이콘 왼쪽에 붙고 꼬리가 아이콘을 향한다 +final class FestivalInfoBannerView: BaseUIView { + + // MARK: - Constants + + enum Layout { + static let height: CGFloat = 48 + static let cornerRadius: CGFloat = 24 + /// 아이콘을 향하는 꼬리 크기 (디자인 실측) + static let tailWidth: CGFloat = 6 + static let tailHeight: CGFloat = 9 + static let horizontalPadding: CGFloat = 14 + static let lineSpacing: CGFloat = 3 + } + + // MARK: - UI Components + + private let bubbleLayer = CAShapeLayer() + private let messageLabel = UILabel() + + // MARK: - View Setup + + override func configureUI() { + backgroundColor = .clear + isUserInteractionEnabled = true + + bubbleLayer.fillColor = UIColor.festivalPrimary.withAlphaComponent(0.9).cgColor + layer.insertSublayer(bubbleLayer, at: 0) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = Layout.lineSpacing + messageLabel.numberOfLines = 2 + messageLabel.attributedText = NSAttributedString( + string: TextLiteral.Map.festivalBannerMessage, + attributes: [ + .paragraphStyle: paragraphStyle, + .font: UIFont.caption2, + .foregroundColor: UIColor.white + ] + ) + + addSubview(messageLabel) + } + + override func setLayout() { + snp.makeConstraints { $0.height.equalTo(Layout.height) } + + messageLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(Layout.horizontalPadding) + // 꼬리 폭만큼 오른쪽 여백을 더 둔다 + $0.trailing.equalToSuperview().inset(Layout.horizontalPadding + Layout.tailWidth) + $0.centerY.equalToSuperview() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + bubbleLayer.path = Self.bubblePath(in: bounds).cgPath + } + + // MARK: - Drawing + + /// 둥근 말풍선 + 오른쪽 중앙의 꼬리 + private static func bubblePath(in bounds: CGRect) -> UIBezierPath { + let bodyWidth = max(bounds.width - Layout.tailWidth, 0) + let body = CGRect(x: 0, y: 0, width: bodyWidth, height: bounds.height) + let path = UIBezierPath(roundedRect: body, cornerRadius: Layout.cornerRadius) + + let centerY = bounds.midY + let tail = UIBezierPath() + tail.move(to: CGPoint(x: bodyWidth - 1, y: centerY - Layout.tailHeight / 2)) + tail.addLine(to: CGPoint(x: bounds.maxX, y: centerY)) + tail.addLine(to: CGPoint(x: bodyWidth - 1, y: centerY + Layout.tailHeight / 2)) + tail.close() + path.append(tail) + + return path + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 3ea3cf6b..1813d7d3 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -25,9 +25,8 @@ enum MapTab: Int, CaseIterable { } } -/// 학교 제휴 탭 필터. festival은 Remote Config로 노출 여부 결정 +/// 학교 제휴 탭 필터 (축제 제휴는 별도 필터 없이 기존 제휴와 함께 표시된다) enum PartnershipFilter: CaseIterable { - case festival case all case restaurant case cafe @@ -35,7 +34,6 @@ enum PartnershipFilter: CaseIterable { var title: String { switch self { - case .festival: return TextLiteral.Map.festival case .all: return TextLiteral.Map.all case .restaurant: return TextLiteral.Map.restaurant case .cafe: return TextLiteral.Map.cafe @@ -43,19 +41,30 @@ enum PartnershipFilter: CaseIterable { } } - /// 서버 restaurantType 값. 전체/축제는 nil + /// 서버 restaurantType 값. 전체는 nil var restaurantType: String? { switch self { case .restaurant: return "RESTAURANT" case .cafe: return "CAFE" case .pub: return "PUB" - case .festival, .all: return nil + case .all: return nil } } } final class MainMapView: BaseUIView { + // MARK: - Constants + + /// 디자인 실측: 도움말 28pt, 트레일링 24, 지도 하단에서 28 + private enum Layout { + static let festivalHelpSize: CGFloat = 28 + static let festivalHelpTrailing: CGFloat = 24 + static let festivalHelpBottom: CGFloat = 28 + /// 말풍선 꼬리가 아이콘에 살짝 겹치도록 + static let festivalBannerOverlap: CGFloat = 2 + } + // MARK: - UI Components let mapView = NMFNaverMapView() @@ -71,6 +80,21 @@ final class MainMapView: BaseUIView { let topTabView = UnderlineTabView(titles: MapTab.allCases.map { $0.title }) let filterChipBar = FilterChipBar() + /// 축제 제휴 안내 도움말 버튼 (축제 기간에만 노출) + let festivalHelpButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(EATSSUDesignAsset.Images.icFestivalInfo.image, for: .normal) + button.isHidden = true + return button + }() + + /// 도움말 버튼을 눌렀을 때 나오는 안내 말풍선 + let festivalBannerView: FestivalInfoBannerView = { + let view = FestivalInfoBannerView() + view.isHidden = true + return view + }() + /// 찜 탭으로 이동하는 플로팅 하트 버튼. 필터 칩과 같은 줄 오른쪽에 두고, 칩은 그 왼쪽 영역에서 스크롤된다 let likeButton: UIButton = { let button = UIButton(type: .custom) @@ -93,7 +117,7 @@ final class MainMapView: BaseUIView { mapView.showLocationButton = true mapView.mapView.positionMode = .disabled - addSubviews(mapView, blurView, topTabView, filterChipBar, likeButton) + addSubviews(mapView, blurView, topTabView, filterChipBar, likeButton, festivalBannerView, festivalHelpButton) } // MARK: - Layout Setup @@ -124,6 +148,24 @@ final class MainMapView: BaseUIView { $0.leading.equalToSuperview() $0.trailing.equalTo(likeButton.snp.leading).offset(-8) } + + festivalHelpButton.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(Layout.festivalHelpTrailing) + $0.bottom.equalTo(mapView.snp.bottom).inset(Layout.festivalHelpBottom) + $0.width.height.equalTo(Layout.festivalHelpSize) + } + + festivalBannerView.snp.makeConstraints { + $0.centerY.equalTo(festivalHelpButton) + $0.trailing.equalTo(festivalHelpButton.snp.leading).offset(Layout.festivalBannerOverlap) + $0.leading.greaterThanOrEqualToSuperview().inset(Layout.festivalHelpTrailing) + } + } + + /// 축제 도움말 버튼/말풍선 노출 여부 + func setFestivalHelpVisible(_ visible: Bool) { + festivalHelpButton.isHidden = !visible + if !visible { festivalBannerView.isHidden = true } } /// 학과 미입력 안내 중 지도 블러 표시/해제 diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 5f74804c..3270b123 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -51,15 +51,21 @@ extension MainMapViewController { // MARK: - Marker Items - /// - Parameter likeTarget: 찜 토글 대상 원본 업체 (필터로 항목이 걸러진 `partnership`과 구분). nil이면 partnership 그대로 - func makeMarkerItem(for partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) -> MapMarkerItem { - let isFestival = partnershipFilter == .festival + /// 업체에 축제 제휴가 있으면 축제 색 마커로, 찜은 일반 제휴 항목만 대상으로 한다 + func makeMarkerItem(for partnership: PartnershipDTO) -> MapMarkerItem { + let likeTarget = Self.likeTarget(for: partnership) return MapMarkerItem( title: partnership.storeName, latitude: partnership.latitude, longitude: partnership.longitude, - icon: Self.partnershipIcon(for: partnership.restaurantType, isFestival: isFestival), - onTap: { [weak self] in self?.showPartnershipDetail(for: partnership, likeTarget: likeTarget) } + icon: Self.partnershipIcon( + for: partnership.restaurantType, + isFestival: Self.isFestivalStore(partnership) + ), + onTap: { [weak self] in + self?.hideFestivalBanner() + self?.showPartnershipDetail(for: partnership, likeTarget: likeTarget) + } ) } @@ -159,7 +165,8 @@ extension MainMapViewController { let detailVC = PartnershipDetailSheetViewController( partnership: partnership, likeTarget: likeTarget, - isLikeEnabled: hasDepartment + // 축제 전용 업체는 찜할 수 없어 likeTarget이 없다 + isLikeEnabled: hasDepartment && likeTarget != nil ) detailVC.loadViewIfNeeded() presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) @@ -194,6 +201,7 @@ extension MainMapViewController { extension MainMapViewController: NMFMapViewTouchDelegate { func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) { + hideFestivalBanner() } func mapView(_ mapView: NMFMapView, didTap symbol: NMFSymbol) -> Bool { diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index 2f370f3d..f6c9297e 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -11,93 +11,150 @@ import Foundation extension MainMapViewController { - /// 전체 제휴 데이터를 받아 캐시에 저장하고 축제 제휴 마커를 표시 - /// 캐시가 있으면 재요청 없이 사용한다 (탭바 재탭 시 reloadContent가 캐시를 비움) - func refreshAllPartnerships() { - if !cachedAllPartnerships.isEmpty { - _ = beginLoad() - applyPartnershipMarkers(from: cachedAllPartnerships, periodType: .festival) + /// 내 학과 제휴와 (축제 기간이면) 축제 제휴를 함께 받아 한 지도에 표시한다 + /// 두 응답을 업체 단위로 합치고, 업종 필터는 합친 결과에 적용한다 + func fetchPartnerships() { + guard hasDepartment else { + displayMarkers([]) return } + // 캐시가 비어 있을 때만 요청한다 (찜 상세용 선조회가 실패한 경우에도 여기서 다시 받아온다) + let needsMy = cachedMyPartnerships.isEmpty + let needsFestival = isFestivalPartnershipEnabled && cachedFestivalPartnerships.isEmpty let generation = beginLoad() - NetworkService.shared.request( - PartnershipRouter.getAllPartnerships, - responseType: [PartnershipDTO].self, - useAuth: true - ) { [weak self] result in - guard let self, self.isCurrentLoad(generation) else { return } - switch result { - case .success(let partnerships): - self.cachedAllPartnerships = partnerships - self.applyPartnershipMarkers(from: partnerships, periodType: .festival) - - case .failure(let error): - print("제휴 조회 실패: \(error.localizedDescription)") - self.cachedAllPartnerships = [] - #if !DEBUG - self.displayMarkers([]) - self.showStoreLoadFailedToast() - #endif - } - #if DEBUG - // 서버에 제휴 데이터가 없거나 실패한 동안 Mock으로 대체 (DEBUG 빌드 전용, 실패 토스트는 띄우지 않음) - if self.cachedAllPartnerships.isEmpty { - self.cachedAllPartnerships = PartnershipMockData.samples - self.applyPartnershipMarkers(from: self.cachedAllPartnerships, periodType: .festival) - } - #endif + guard needsMy || needsFestival else { + applyPartnershipMarkers() + return } - } - /// 내 학과 제휴를 받아 현재 업종 필터에 맞춰 마커 표시. 캐시가 있으면 재요청 없이 필터만 적용 - func fetchMyPartnerships() { - guard hasDepartment else { - displayMarkers([]) - return + let group = DispatchGroup() + var myFailed = false + + if needsMy { + group.enter() + NetworkService.shared.request( + MyRouter.getMyPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + defer { group.leave() } + guard let self else { return } + switch result { + case .success(let partnerships): + self.cachedMyPartnerships = partnerships + self.hasFetchedMyPartnerships = true + case .failure(let error): + print("내 제휴 조회 실패: \(error.localizedDescription)") + myFailed = true + } + } } - if !cachedMyPartnerships.isEmpty { - _ = beginLoad() - applyPartnershipMarkers(from: cachedMyPartnerships, periodType: .normal) - return + if needsFestival { + group.enter() + NetworkService.shared.request( + PartnershipRouter.getAllPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + defer { group.leave() } + guard let self else { return } + switch result { + case .success(let partnerships): + self.cachedFestivalPartnerships = Self.filterPartnerships(partnerships, by: .festival) + case .failure(let error): + // 축제 제휴는 부가 정보이므로 실패해도 기존 제휴만으로 지도를 그린다 + print("축제 제휴 조회 실패: \(error.localizedDescription)") + } + } } - let generation = beginLoad() - NetworkService.shared.request( - MyRouter.getMyPartnerships, - responseType: [PartnershipDTO].self, - useAuth: true - ) { [weak self] result in + group.notify(queue: .main) { [weak self] in guard let self, self.isCurrentLoad(generation) else { return } - switch result { - case .success(let partnerships): - self.cachedMyPartnerships = partnerships - self.hasFetchedMyPartnerships = true - self.applyPartnershipMarkers(from: partnerships, periodType: .normal) - self.presentPendingDetailIfNeeded() - case .failure(let error): - print("내 제휴 조회 실패: \(error.localizedDescription)") + #if DEBUG + // 서버에 축제 데이터가 없는 동안 Mock으로 확인 (개발 빌드 전용) + if self.isFestivalPartnershipEnabled, self.cachedFestivalPartnerships.isEmpty { + self.cachedFestivalPartnerships = Self.filterPartnerships(PartnershipMockData.samples, by: .festival) + } + #endif + + guard !(myFailed && self.cachedMyPartnerships.isEmpty) else { self.displayMarkers([]) self.showStoreLoadFailedToast() + return } + + self.applyPartnershipMarkers() + self.presentPendingDetailIfNeeded() } } - /// periodType + 현재 업종 필터로 걸러서 마커 표시 - private func applyPartnershipMarkers(from partnerships: [PartnershipDTO], periodType: PartnershipPeriodType) { - var filtered = Self.filterPartnerships(partnerships, by: periodType) + /// 현재 업종 필터에 맞춰 합쳐진 제휴 마커를 표시 + func applyPartnershipMarkers() { + var merged = Self.mergedPartnerships( + my: cachedMyPartnerships, + festival: isFestivalPartnershipEnabled ? cachedFestivalPartnerships : [] + ) if let type = partnershipFilter.restaurantType { - filtered = filtered.filter { $0.restaurantType == type } + merged = merged.filter { $0.restaurantType == type } + } + displayMarkers(merged.map { makeMarkerItem(for: $0) }) + } + + /// 내 학과 제휴 + 축제 제휴를 업체(storeKey) 단위로 합친다 + /// 같은 업체가 양쪽에 있으면 제휴 항목을 합치고, 순서는 내 학과 제휴 → 축제 전용 업체 순으로 둔다 + static func mergedPartnerships( + my: [PartnershipDTO], + festival: [PartnershipDTO] + ) -> [PartnershipDTO] { + var order: [String] = [] + var byKey: [String: PartnershipDTO] = [:] + + for store in my + festival { + guard let existing = byKey[store.storeKey] else { + order.append(store.storeKey) + byKey[store.storeKey] = store + continue + } + let knownIds = Set(existing.partnershipInfos.map(\.id)) + byKey[store.storeKey] = PartnershipDTO( + storeName: existing.storeName, + longitude: existing.longitude, + latitude: existing.latitude, + restaurantType: existing.restaurantType, + naverMapUrl: existing.naverMapUrl ?? store.naverMapUrl, + kakaoMapUrl: existing.kakaoMapUrl ?? store.kakaoMapUrl, + partnershipInfos: existing.partnershipInfos + + store.partnershipInfos.filter { !knownIds.contains($0.id) } + ) } - // 시트는 필터된 항목만 보여주되, 찜은 업체의 전체 항목을 대상으로 해야 하므로 원본을 함께 넘긴다 - let fullByKey = Dictionary(partnerships.map { ($0.storeKey, $0) }, uniquingKeysWith: { first, _ in first }) - displayMarkers(filtered.map { makeMarkerItem(for: $0, likeTarget: fullByKey[$0.storeKey]) }) + return order.compactMap { byKey[$0] } + } + + /// 축제 제휴 항목이 하나라도 있으면 축제 마커로 표시한다 (기존 제휴와 겹치는 업체는 축제 우선) + static func isFestivalStore(_ store: PartnershipDTO) -> Bool { + store.partnershipInfos.contains { $0.periodType == .festival } + } + + /// 찜 대상 업체. 축제 제휴는 찜할 수 없으므로 일반 제휴 항목만 남기고, 없으면 nil(하트 숨김) + static func likeTarget(for store: PartnershipDTO) -> PartnershipDTO? { + let normalInfos = store.partnershipInfos.filter { $0.periodType == .normal } + guard !normalInfos.isEmpty else { return nil } + return PartnershipDTO( + storeName: store.storeName, + longitude: store.longitude, + latitude: store.latitude, + restaurantType: store.restaurantType, + naverMapUrl: store.naverMapUrl, + kakaoMapUrl: store.kakaoMapUrl, + partnershipInfos: normalInfos + ) } - private static func filterPartnerships( + static func filterPartnerships( _ partnerships: [PartnershipDTO], by periodType: PartnershipPeriodType ) -> [PartnershipDTO] { diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 884862e8..b77a334c 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -50,8 +50,8 @@ final class MainMapViewController: BaseViewController { var clusterer: NMCClusterer? - /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) - var cachedAllPartnerships: [PartnershipDTO] = [] + /// 축제 제휴 캐시 (전체 제휴 응답에서 FESTIVAL 항목만 추린 것) + var cachedFestivalPartnerships: [PartnershipDTO] = [] /// 내 학과 제휴 캐시 (업종 칩 필터용). 탭바 재탭·학과 변경 시 비움 var cachedMyPartnerships: [PartnershipDTO] = [] /// 내 제휴를 한 번이라도 받았는지 (찜 → 상세 진입 시 전체 단과대 원본이 노출되지 않게 대기 판단용) @@ -89,21 +89,23 @@ final class MainMapViewController: BaseViewController { private(set) var partnershipFilter: PartnershipFilter = .all private(set) var goodPriceCategory: GoodPriceCategory = .all - /// 현재 노출 중인 학교 제휴 필터 목록 (축제 활성 여부에 따라 달라짐) - private var visiblePartnershipFilters: [PartnershipFilter] { - let festivalEnabled = FirebaseRemoteConfig.shared.isFestivalEnabled - return PartnershipFilter.allCases.filter { $0 != .festival || festivalEnabled } + /// 자동 노출 예약을 무효화하기 위한 토큰 (연속 탭 시 이전 예약이 배너를 닫지 않도록) + private var festivalBannerToken = 0 + /// 축제 안내 자동 노출은 앱 실행당 한 번만 + private static var hasAutoShownFestivalBanner = false + + /// 축제 제휴를 함께 보여주는 기간인지 (Remote Config) + var isFestivalPartnershipEnabled: Bool { + FirebaseRemoteConfig.shared.isFestivalPartnershipEnabled } - /// 탭바에서 지도 탭 진입 시 보이게 될 화면이 축제인지 (click_map default_type용) + /// 지도 탭 진입 시 축제 제휴가 함께 보이는 상태인지 (click_map default_type용) var isShowingFestival: Bool { - currentTab == .partnership && partnershipFilter == .festival + currentTab == .partnership && isFestivalPartnershipEnabled } - /// 클러스터 색상: 축제 필터일 때만 축제 색 - var clusterColor: UIColor { - (currentTab == .partnership && partnershipFilter == .festival) ? .festivalPrimary : .primary - } + /// 클러스터 색상: 기존 제휴와 축제 제휴가 한 클러스터에 섞이므로 기본 색을 쓴다 + var clusterColor: UIColor { .primary } // MARK: - Init @@ -170,6 +172,7 @@ final class MainMapViewController: BaseViewController { setEntryCameraPosition() setupLocationButtonObserver() setupMarkerTapHandler() + setupFestivalHelp() applyTabUI() } @@ -182,6 +185,8 @@ final class MainMapViewController: BaseViewController { case .goodPrice: loadGoodPriceMarkers() } + // Remote Config 수신이 늦을 수 있어 진입할 때마다 다시 판단한다 + updateFestivalHelpVisibility() } override func viewDidAppear(_ animated: Bool) { @@ -190,6 +195,7 @@ final class MainMapViewController: BaseViewController { // 찜 목록에서 넘어온 업체가 있으면 화면이 붙은 뒤 시트를 띄운다 presentPendingDetailIfNeeded() + showFestivalBannerOnFirstEntryIfNeeded() } override func viewDidDisappear(_ animated: Bool) { @@ -239,7 +245,7 @@ final class MainMapViewController: BaseViewController { // 찜 원본 DTO는 모든 단과대 제휴를 담고 있어, 지도 마커와 동일하게 내 제휴 데이터로 표시한다 // (내 제휴에 없으면 — 학과 변경 등 — 원본으로 폴백, 시트에서 내용 기준 중복 제거) let display = cachedMyPartnerships.first { $0.storeKey == store.storeKey } ?? store - showPartnershipDetail(for: display, likeTarget: store) + showPartnershipDetail(for: display, likeTarget: Self.likeTarget(for: store)) } /// 찜 → 상세 진입용 내 제휴 확보. 마커 로드 세대에 영향을 주지 않도록 캐시만 채운다 @@ -293,7 +299,7 @@ final class MainMapViewController: BaseViewController { (tabBarController as? CustomTabBarContainerController)?.showLikedPartnerships(fromMap: true) } - /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드 private func refreshPartnershipTab() { applyTabUI() let generation = beginLoad() @@ -347,10 +353,11 @@ final class MainMapViewController: BaseViewController { root.topTabView.select(index: currentTab.rawValue, animated: false) // 찜은 학교 제휴 전용이라 착한 가격 탭에서는 플로팅 하트를 숨긴다 root.setLikeButtonVisible(mode == .tabbed && currentTab == .partnership) + updateFestivalHelpVisibility() switch currentTab { case .partnership: - let filters = visiblePartnershipFilters + let filters = PartnershipFilter.allCases if !filters.contains(partnershipFilter) { partnershipFilter = .all } displayedPartnershipFilters = filters root.filterChipBar.highlightColor = clusterColor @@ -403,7 +410,7 @@ final class MainMapViewController: BaseViewController { guard filters.indices.contains(index) else { return } partnershipFilter = filters[index] root.filterChipBar.highlightColor = clusterColor - logPartnershipFilterClick(partnershipFilter) + logPartnershipFilterClick() loadPartnershipMarkers() case .goodPrice: @@ -416,19 +423,10 @@ final class MainMapViewController: BaseViewController { // 필터 전환 시 카메라는 보고 있던 위치를 그대로 유지한다 (QA) } - /// 축제 → click_map_festival, 그 외 → click_map_mine /// 학교 제휴는 곧 내 학과 제휴이고 학과 없이는 칩까지 도달할 수 없으므로 전체 제휴(click_map_all) 분기는 없다 - private func logPartnershipFilterClick(_ filter: PartnershipFilter) { - switch filter { - case .festival: - MapAnalyticsManager.shared.logClickMapFestival( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) - case .all, .restaurant, .cafe, .pub: - guard let collegeId = currentCollegeId, let majorId = currentDepartmentId else { return } - MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) - } + private func logPartnershipFilterClick() { + guard let collegeId = currentCollegeId, let majorId = currentDepartmentId else { return } + MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) } // MARK: - Partnership Tab @@ -446,17 +444,14 @@ final class MainMapViewController: BaseViewController { case .loaded: displayMarkers([]) root.setMapBlurred(true) + updateFestivalHelpVisibility() presentNoDepartmentSheetIfNeeded() return } } root.setMapBlurred(false) - - if partnershipFilter == .festival { - refreshAllPartnerships() - return - } - fetchMyPartnerships() + updateFestivalHelpVisibility() + fetchPartnerships() } var hasDepartment: Bool { @@ -471,13 +466,83 @@ final class MainMapViewController: BaseViewController { present(NoDepartmentSheetViewController(), animated: true) } + // MARK: - Festival Help + + private enum FestivalBanner { + static let autoHideDelay: TimeInterval = 2.5 + static let fadeDuration: TimeInterval = 0.2 + } + + /// 축제 도움말은 학교 제휴 탭 + 축제 기간 + 학과 보유 시에만 노출 (학과 없으면 지도가 블러 처리된다) + func updateFestivalHelpVisibility() { + root.setFestivalHelpVisible( + currentTab == .partnership && isFestivalPartnershipEnabled && hasDepartment + ) + } + + private func setupFestivalHelp() { + root.festivalHelpButton.addTarget(self, action: #selector(didTapFestivalHelp), for: .touchUpInside) + root.festivalBannerView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(didTapFestivalBanner)) + ) + } + + @objc private func didTapFestivalHelp() { + if root.festivalBannerView.isHidden { + showFestivalBanner() + } else { + hideFestivalBanner() + } + } + + @objc private func didTapFestivalBanner() { + hideFestivalBanner() + } + + /// 안내 말풍선을 띄우고 일정 시간 뒤 자동으로 닫는다 + private func showFestivalBanner() { + guard isFestivalPartnershipEnabled, currentTab == .partnership else { return } + + festivalBannerToken += 1 + let token = festivalBannerToken + root.festivalBannerView.alpha = 0 + root.festivalBannerView.isHidden = false + UIView.animate(withDuration: FestivalBanner.fadeDuration) { + self.root.festivalBannerView.alpha = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + FestivalBanner.autoHideDelay) { [weak self] in + // 그 사이에 다시 열거나 닫았으면 이 예약은 버린다 + guard let self, self.festivalBannerToken == token else { return } + self.hideFestivalBanner() + } + } + + func hideFestivalBanner() { + guard !root.festivalBannerView.isHidden else { return } + festivalBannerToken += 1 + UIView.animate( + withDuration: FestivalBanner.fadeDuration, + animations: { self.root.festivalBannerView.alpha = 0 }, + completion: { _ in self.root.festivalBannerView.isHidden = true } + ) + } + + /// 축제 기간 중 지도 첫 진입 시 한 번 자동으로 안내한다 (앱 실행당 1회) + private func showFestivalBannerOnFirstEntryIfNeeded() { + guard isFestivalPartnershipEnabled, currentTab == .partnership, hasDepartment, + !Self.hasAutoShownFestivalBanner else { return } + Self.hasAutoShownFestivalBanner = true + showFestivalBanner() + } + // MARK: - Helper Methods /// 탭바에서 지도 탭을 다시 눌렀을 때 현재 탭 데이터 갱신 func reloadContent() { switch currentTab { case .partnership: - cachedAllPartnerships = [] + cachedFestivalPartnerships = [] cachedMyPartnerships = [] hasFetchedMyPartnerships = false refreshPartnershipTab() diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index a13071f8..a57d532d 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -461,6 +461,14 @@ enum TextLiteral { // MARK: - Map enum Map { + /// 축제 제휴 안내 말풍선 문구 (2줄) + static var festivalBannerMessage: String { + Localization.localized( + "map.festivalBannerMessage", + fallback: "2026 동연제 제휴 매장을 확인해보세요!\n기존 제휴와 중복 적용되지 않아요." + ) + } + /// MainMapVC - "지도" static var map: String { Localization.localized("map.map", fallback: "지도") diff --git a/EATSSU/Tests/UnitTests/FestivalPartnershipTests.swift b/EATSSU/Tests/UnitTests/FestivalPartnershipTests.swift new file mode 100644 index 00000000..8aa14a65 --- /dev/null +++ b/EATSSU/Tests/UnitTests/FestivalPartnershipTests.swift @@ -0,0 +1,142 @@ +// +// FestivalPartnershipTests.swift +// EATSSUUnitTests +// +// Created by 황상환 on 9/13/26. +// + +import XCTest + +@testable import EATSSU_DEV + +/// 축제 제휴를 기존 학교 제휴와 함께 표시하기 위한 병합·구분 로직 검증 +final class FestivalPartnershipTests: XCTestCase { + + // MARK: - Helpers + + private func makeInfo(id: Int, periodType: PartnershipPeriodType) -> PartnershipInfoDTO { + PartnershipInfoDTO( + id: id, + collegeName: "IT대학", + departmentName: nil, + likeCount: 0, + isLiked: false, + description: "제휴 내용", + startDate: "2026-09-15", + endDate: "2026-09-16", + periodType: periodType + ) + } + + private func makeStore( + name: String, + type: String = "RESTAURANT", + infos: [PartnershipInfoDTO] + ) -> PartnershipDTO { + PartnershipDTO( + storeName: name, + longitude: 126.95, + latitude: 37.49, + restaurantType: type, + naverMapUrl: nil, + kakaoMapUrl: nil, + partnershipInfos: infos + ) + } + + // MARK: - mergedPartnerships + + func test_내_학과_제휴와_축제_제휴가_한_목록으로_합쳐진다() { + let mine = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal)]) + let festival = makeStore(name: "B", infos: [makeInfo(id: 2, periodType: .festival)]) + + let merged = MainMapViewController.mergedPartnerships(my: [mine], festival: [festival]) + + XCTAssertEqual(merged.map(\.storeName), ["A", "B"]) + } + + func test_같은_업체가_양쪽에_있으면_제휴_항목이_합쳐진다() { + let mine = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal)]) + let festival = makeStore(name: "A", infos: [makeInfo(id: 2, periodType: .festival)]) + + let merged = MainMapViewController.mergedPartnerships(my: [mine], festival: [festival]) + + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(merged[0].partnershipIds, [1, 2]) + } + + func test_같은_항목_id는_중복으로_들어가지_않는다() { + let mine = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal)]) + let festival = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal), + makeInfo(id: 3, periodType: .festival)]) + + let merged = MainMapViewController.mergedPartnerships(my: [mine], festival: [festival]) + + XCTAssertEqual(merged[0].partnershipIds, [1, 3]) + } + + func test_축제_목록이_비면_내_학과_제휴만_남는다() { + let mine = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal)]) + + let merged = MainMapViewController.mergedPartnerships(my: [mine], festival: []) + + XCTAssertEqual(merged.map(\.storeName), ["A"]) + } + + // MARK: - isFestivalStore (마커 색 결정) + + func test_축제_항목이_있으면_축제_업체로_판정한다() { + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .festival)]) + + XCTAssertTrue(MainMapViewController.isFestivalStore(store)) + } + + func test_기존_제휴와_축제_제휴가_섞이면_축제_업체로_판정한다() { + // 마커 색은 축제 우선 (한시적 혜택이 눈에 띄어야 함) + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal), + makeInfo(id: 2, periodType: .festival)]) + + XCTAssertTrue(MainMapViewController.isFestivalStore(store)) + } + + func test_일반_제휴만_있으면_축제_업체가_아니다() { + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal)]) + + XCTAssertFalse(MainMapViewController.isFestivalStore(store)) + } + + // MARK: - likeTarget (축제 제휴는 찜 불가) + + func test_축제_전용_업체는_찜_대상이_없다() { + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .festival)]) + + XCTAssertNil(MainMapViewController.likeTarget(for: store)) + } + + func test_섞인_업체는_일반_제휴_항목만_찜_대상이_된다() { + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal), + makeInfo(id: 2, periodType: .festival)]) + + XCTAssertEqual(MainMapViewController.likeTarget(for: store)?.partnershipIds, [1]) + } + + func test_일반_제휴_업체는_모든_항목이_찜_대상이다() { + let store = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal), + makeInfo(id: 2, periodType: .normal)]) + + XCTAssertEqual(MainMapViewController.likeTarget(for: store)?.partnershipIds, [1, 2]) + } + + // MARK: - filterPartnerships + + func test_전체_제휴_응답에서_축제_항목만_추린다() { + let mixed = makeStore(name: "A", infos: [makeInfo(id: 1, periodType: .normal), + makeInfo(id: 2, periodType: .festival)]) + let normalOnly = makeStore(name: "B", infos: [makeInfo(id: 3, periodType: .normal)]) + + let festivalOnly = MainMapViewController.filterPartnerships([mixed, normalOnly], by: .festival) + + XCTAssertEqual(festivalOnly.map(\.storeName), ["A"]) + XCTAssertEqual(festivalOnly[0].partnershipIds, [2]) + } +} diff --git a/EATSSU/Tests/UnitTests/StoreCategoryMappingTests.swift b/EATSSU/Tests/UnitTests/StoreCategoryMappingTests.swift index d4877a6b..ee96733c 100644 --- a/EATSSU/Tests/UnitTests/StoreCategoryMappingTests.swift +++ b/EATSSU/Tests/UnitTests/StoreCategoryMappingTests.swift @@ -72,6 +72,5 @@ final class StoreCategoryMappingTests: XCTestCase { XCTAssertEqual(PartnershipFilter.cafe.restaurantType, "CAFE") XCTAssertEqual(PartnershipFilter.pub.restaurantType, "PUB") XCTAssertNil(PartnershipFilter.all.restaurantType) - XCTAssertNil(PartnershipFilter.festival.restaurantType) } } diff --git a/EATSSUDesign/EATSSUDesign/Resources/Color.xcassets/Main/festivalPrimary.colorset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Color.xcassets/Main/festivalPrimary.colorset/Contents.json index d17fafec..15e6802b 100644 --- a/EATSSUDesign/EATSSUDesign/Resources/Color.xcassets/Main/festivalPrimary.colorset/Contents.json +++ b/EATSSUDesign/EATSSUDesign/Resources/Color.xcassets/Main/festivalPrimary.colorset/Contents.json @@ -1,20 +1,20 @@ { - "colors" : [ + "colors": [ { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xAF", - "red" : "0x97" + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x19", + "green": "0x0A", + "red": "0x88" } }, - "idiom" : "universal" + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_cafe_pin.imageset/festival_cafe_pin.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_cafe_pin.imageset/festival_cafe_pin.pdf index 5244982b65a0ce85f2200bf17c5f0f44f6cbe348..4d87abb7cbb1178491ffdcb4f3ab52e4a880f4ab 100644 GIT binary patch delta 2096 zcmZWqdpMMN8#WDTC!{b4^~x9~YUZ8u_z-DF8%2~u2h$AWkaftRk3r)&C5(8H!=`q* z6rpvxut|hmcF+`(^Wk&Ya%gvF5Tb9`>Zo^W69SJiq6?uKOt>T*Kc$!6|sK z409VMLl0p3u%RB9#}>A41$_$=1_i)RK%Q`g6s>L0c4@^e007k~XhNNE)d~%16he;( z-~d0?MFaqx1+YI@SR7^svfTiJBx(noN3ea}Hj)Su6rr$JWdmymn3_u7&lz%xqVjhj zfl8JrY-+1aPPYloWd4I&GW&e-jv^2J(H_N*{nE(#S5Pf zb?$1jae1D5adzr>Dsds+f5a^{xa`N$hk#}qJLFp3%r@p9It~33hh>WrT1*sHi!zx< zUGi%$osDrjQ_O!c3ZbeVqFyV}EN;I_3wE|93;U;7_et90HRh)L)C==zv6>6Q0aty+ zAWLfYjR8-yrIN;$(FnE?F_x6y|6K(Z!==Yo^bB$$%w*P^t@BF|dX@Wgt>S0%`dTJ! zFUIW7cPa#hv*RrB=-n~SLY}l+&e1kn4=nmn?WLMD%Rl9>C){sLo^4dTle^s^>6O0{ zvhZN@E6!GM^-NqFta#bUKxhB50N0;$=|)7pmJP+z?;dSm8_Xk9)h@HwLFxYN8Lm}T zU*`DJj44ZwSs70O9@XwpV2_ukjBI zzg`>lTuNGnLQ3Q}k@ByOr?~G}bsx{hS*_y}7wK1@fjWi(F^luQ^YtA#Y(DNm3EE+2 zk)WXv-kC{P#RssHUms~qN`-Ufr157{1V?vV97+e{eR9}>N8QRxQPtTs(&FMR8f zCl7gu#55I%KWcPr_KFrdWh?rSRBnqY=x09YEUx-ps#WePm3sVExpnh7rBJiSY1{3X z0k+&k8X>FB%+=iah$!Xm6iN>)dFrVG1*b8>ipvZO+S70ED;J1eK#ndWeQc_-{n4q3 zW`~d*Z;YFAYh0SqR9&|a4)qCB-{`2Ny0}b4NhFwVDbm&iFDGQNS zRTSyeWqtVzdkCImbr}&ye0Y_GCogYkuZiEBo>7_Y`%OSTM}OXG@D$DvHaY#Jt*|ET zYGx+7a41W<;$(;C3!#x_`WgCz++LGkg@H|m+XVO(q(>G~j+ueF{l`7th*RX6saO!{CD|?+{#@H@e*`mJ%G3m-xYD+N& zq^Wy#`zoAvVU+dO#0HVt#_!?+?&P4g+J_w*ZenhgTC9j7?}w;Cw#Z!cluf}he2Dn^ z!U=W?H_OvGW(Ut)#mRNo%FC{x<+h1132$iKV$9}!ve2exe?lihUt-!H{r8i7Q$Cx# zu`z(P@^TB5xIGohRwlxzkWd+=x&g4;bZ`J`#}4!h4sc}$0whRIC7MqDnES%?XVE-(e%|0YeY$$CknvLZl;Q=5BX(NrHXUYnY zH&S;O{tI0z2^_>hzaViiJk*6Ofq1HD9qs^>vIu#!2S!ZH!3zcZ~Kg90?>#=Jhp~geU$}W5*YTNkk$I znpHz136fp=4W2^6LB{Hur|=*Sq@2T1@Dv)2iUIV$Id4u4Yv<<|1c09hg|+wMumO;O Z*xbUB{}2+uc1MtL1TsQb_j?=q{{c73W?}#U delta 2223 zcmZuzc|25mAC8Gz(@2Y9W-OgT_U4>9vzF$~GPbc~a!E**v9!>Pk-e$NOElb07;c*q zcW6jVmfYJ_QrWIldQp;6(mSc#R9A6lRNhaw_xH~^=lgqp&-49#|9GC0K`MuHjnqg0 z2ym)RPO4~_I`D&baRD++B~i?fc9n3n>5gcBkV4tPH>)A5YOA1VFb&B;=^*YXmsMoN zx#-~SXaKOb28cF01zdhqkbnyi?YVm*L%1AATjUaICDxT26)uPXNE9kf0aYxbI{??b zLYbhMlsSgkU2asUk`GOtEBgUNXFEIFpcrl_pp@eP0qCzN8p2$>Vu2FN0hml=rxpRZ zf+7gN$T5_4g&vcMjuG&=LA%iLDu=~vkuGgX`nwc#0HDXrdP2g77P4}e&sWV}2;ej1 zQ&)u&_P^L1&7r~iPZVFGc7@Bt*d!Y$q#}fYMg}8`$&n>1nJD)=|_3*Y|c;-dY2 z$f@t%vFaR4(dLriJm)s{k>`fDQd&)43>Qi-0eZ$>Z+D+iw-vX#p1+tz-WOWkblt{h z1Z_9AYJEqF#*s*9cyG*X#jfGHZ-Ob)73@H|94BV;^JAc4@8Cssqfr2+d1QdFpz1 z-sdgX{wbIL^2~wylz?q~)fZ4!guR&%Upncn_e4CB-l-v@Id?koHwYI^*kUI7$AW*f z8#j$(meyBGulpKo%qMRHatF#I!J_S4@oW=@1HmV!woE)NcP`}rp%&#Q)~^O*&MeZ2 za<^>Q15)PMTyt}a8QQInm#|9ut}@yFI$>>r>5^X=Lot;L7CqRQ6dg5~TmuqYz$)n) zZ+!Vciv+dBHAd)!ZQy4~inYQj7OJZ@NN;dU{=NNIv)drixYnby^-#vlqtVgBd&rqq z!>@2*j+PzwN3US79@Egooi4R;D>k|z*zeIjXwlOlXHPk*gotPF$Tw@x>fL?8?`*fc z>DKj*$+b^imXGK|b?-vttM5*3F7aG(;&1!l}%}!(H<%ExpRxQ#= z&RwwYI$Xf0?cs{D>@oUSoEh6e-wbo~KLfF#twzzudGXp)Elp~?@LTunQO7LwdYW$v zdPvKJac-mg|GMUX3SE#IS_HR_su32ott!%7A8QiXA|sULW%o*w@xB*C&8^~wKasRO z4TJ2`t+#x_>epGt5%G)**g=CtXY4iJ%2LrfA}MdOsjJ$6b5at?+?@XV@c$k>s}l*2pIRDW(1x(DDyY!1m##M#h2=_x6WO-momqHdGN z*Og~7h{Gi&xT5i+i+er$ipl#~9jDx$$q4XvxTMgbpEc~BHCf?PwWVs?AH4w=@#cq^ zTOos%bc0gtis5b#P~C{EDK~Kdb)8vAdN1Bf5r zYjK~VDse9@`k9|vunx4Yv9hK8v3is^?>l2%hk7O6?REQ`3h08=5uXyc`1O>dGu(M~21#egFg^_F80B#Unw>2sevSTDmeq zINX@%SbhjM2B0CeS_jnVO1noQwDsq21Rxmk!|ExI#beEl;cuChw6lSyNRrP~iUVY% z6>F_RMo`+MWlH>46}A!$BaYgj3I&PRma2b_M#NZWwfSrueW7p-iV6q*7nNU$gTfX4 zK_C*PvqpuA9M^$_6vaK23y9J{&%S^lou+8#A2kN`I~+_U)BcHrskHBLG*B^%Z~0)_ z7d{1iPMAU>eMd+kecvDjCQ%W8+-mdx1E-M`Q}|YkLMKCr7^la97*qCJ)7A)8-}JrWDsp^%<^#f8+i0^0RR91 diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_pub_pin.imageset/festival_pub_pin.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_pub_pin.imageset/festival_pub_pin.pdf index f4980674fe41f4506aced5b28e977e65ab0758dd..4b75c0ddf00a1e0fab55368718f6164df854918f 100644 GIT binary patch delta 4423 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 LAR%$pQ0xByz^DdX delta 3023 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 F{{>qIRiOX? literal 0 HcmV?d00001 From fed59fad7d1c0afebc31e99cd4f018c9641ab214 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:52:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#461]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=EB=B9=88=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EB=8F=84=20=EC=A1=B0=ED=9A=8C=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EB=A1=9C=20=EA=B8=B0=EB=A1=9D,=20=EB=8A=A6=EC=9D=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=B4=20=EC=BA=90=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=AE=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=84=B8?= =?UTF-8?q?=EB=8C=80=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MainMapViewController+Network.swift | 16 ++++++++++------ .../ViewController/MainMapViewController.swift | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index f6c9297e..97103439 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -19,9 +19,9 @@ extension MainMapViewController { return } - // 캐시가 비어 있을 때만 요청한다 (찜 상세용 선조회가 실패한 경우에도 여기서 다시 받아온다) - let needsMy = cachedMyPartnerships.isEmpty - let needsFestival = isFestivalPartnershipEnabled && cachedFestivalPartnerships.isEmpty + // 성공적으로 받아온 적이 없을 때만 요청한다 (빈 응답도 성공으로 보고 반복 요청하지 않는다) + let needsMy = !hasLoadedMyPartnerships + let needsFestival = isFestivalPartnershipEnabled && !hasLoadedFestivalPartnerships let generation = beginLoad() guard needsMy || needsFestival else { @@ -40,13 +40,15 @@ extension MainMapViewController { useAuth: true ) { [weak self] result in defer { group.leave() } - guard let self else { return } + guard let self, self.isCurrentLoad(generation) else { return } switch result { case .success(let partnerships): self.cachedMyPartnerships = partnerships - self.hasFetchedMyPartnerships = true + self.hasLoadedMyPartnerships = true + self.hasAttemptedMyPartnershipsFetch = true case .failure(let error): print("내 제휴 조회 실패: \(error.localizedDescription)") + self.hasAttemptedMyPartnershipsFetch = true myFailed = true } } @@ -60,10 +62,11 @@ extension MainMapViewController { useAuth: true ) { [weak self] result in defer { group.leave() } - guard let self else { return } + guard let self, self.isCurrentLoad(generation) else { return } switch result { case .success(let partnerships): self.cachedFestivalPartnerships = Self.filterPartnerships(partnerships, by: .festival) + self.hasLoadedFestivalPartnerships = true case .failure(let error): // 축제 제휴는 부가 정보이므로 실패해도 기존 제휴만으로 지도를 그린다 print("축제 제휴 조회 실패: \(error.localizedDescription)") @@ -186,6 +189,7 @@ extension MainMapViewController { if self.currentDepartmentId != department.departmentId { // 학과가 바뀌면 내 제휴 캐시는 더 이상 유효하지 않다 self.cachedMyPartnerships = [] + self.hasLoadedMyPartnerships = false } self.currentDepartmentName = department.departmentName self.currentDepartmentId = department.departmentId diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index b77a334c..1d5de0b6 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -54,8 +54,12 @@ final class MainMapViewController: BaseViewController { var cachedFestivalPartnerships: [PartnershipDTO] = [] /// 내 학과 제휴 캐시 (업종 칩 필터용). 탭바 재탭·학과 변경 시 비움 var cachedMyPartnerships: [PartnershipDTO] = [] - /// 내 제휴를 한 번이라도 받았는지 (찜 → 상세 진입 시 전체 단과대 원본이 노출되지 않게 대기 판단용) - var hasFetchedMyPartnerships = false + /// 내 제휴 조회를 한 번이라도 시도했는지 (실패 포함). 찜 → 상세 진입이 무한정 대기하지 않도록 쓴다 + var hasAttemptedMyPartnershipsFetch = false + /// 내 제휴를 성공적으로 받았는지 (빈 응답도 성공). 재요청 여부 판단용 + var hasLoadedMyPartnerships = false + /// 축제 제휴를 성공적으로 받았는지 (빈 응답도 성공) + var hasLoadedFestivalPartnerships = false private var isLoadingMyPartnershipsForDetail = false /// 착한가격업소 전체 목록 캐시 (카테고리 필터링용) var cachedGoodPriceStores: [GoodPriceStoreDTO] = [] @@ -232,7 +236,7 @@ final class MainMapViewController: BaseViewController { return } // 내 제휴 응답 전이면 받아온 뒤 연다 (전체 단과대 원본 시트가 잠깐 노출되는 것 방지) - if !hasFetchedMyPartnerships, cachedMyPartnerships.isEmpty { + if !hasAttemptedMyPartnershipsFetch, cachedMyPartnerships.isEmpty { loadMyPartnershipsForPendingDetail() return } @@ -262,8 +266,9 @@ final class MainMapViewController: BaseViewController { self.isLoadingMyPartnershipsForDetail = false if case .success(let partnerships) = result, self.cachedMyPartnerships.isEmpty { self.cachedMyPartnerships = partnerships + self.hasLoadedMyPartnerships = true } - self.hasFetchedMyPartnerships = true + self.hasAttemptedMyPartnershipsFetch = true self.presentPendingDetailIfNeeded() } } @@ -543,8 +548,10 @@ final class MainMapViewController: BaseViewController { switch currentTab { case .partnership: cachedFestivalPartnerships = [] + hasLoadedFestivalPartnerships = false cachedMyPartnerships = [] - hasFetchedMyPartnerships = false + hasLoadedMyPartnerships = false + hasAttemptedMyPartnershipsFetch = false refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] From 4c4cb55ee9dbf546a881a51d9450719e9a2558da Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:33:20 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[#461]=20=EB=8F=84=EC=9B=80=EB=A7=90=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=C2=B7=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EC=97=AC=EB=B0=B1=EC=9D=84=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Map/View/MainMapView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 1813d7d3..e9ff24f8 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -56,13 +56,14 @@ final class MainMapView: BaseUIView { // MARK: - Constants - /// 디자인 실측: 도움말 28pt, 트레일링 24, 지도 하단에서 28 + /// 디자인 실측: 도움말 28pt, 트레일링 24, 탭바 위로 28 private enum Layout { static let festivalHelpSize: CGFloat = 28 static let festivalHelpTrailing: CGFloat = 24 - static let festivalHelpBottom: CGFloat = 28 - /// 말풍선 꼬리가 아이콘에 살짝 겹치도록 - static let festivalBannerOverlap: CGFloat = 2 + /// safe area 하단은 탭바 상단과 같으므로(UITabBarController) 그 위로 띄운다 (디자인 실측 38) + static let festivalHelpBottom: CGFloat = 38 + /// 말풍선 꼬리 끝과 아이콘 사이 간격 (디자인 실측 5.5) + static let festivalBannerGap: CGFloat = 6 } // MARK: - UI Components @@ -151,13 +152,13 @@ final class MainMapView: BaseUIView { festivalHelpButton.snp.makeConstraints { $0.trailing.equalToSuperview().inset(Layout.festivalHelpTrailing) - $0.bottom.equalTo(mapView.snp.bottom).inset(Layout.festivalHelpBottom) + $0.bottom.equalTo(safeAreaLayoutGuide.snp.bottom).inset(Layout.festivalHelpBottom) $0.width.height.equalTo(Layout.festivalHelpSize) } festivalBannerView.snp.makeConstraints { $0.centerY.equalTo(festivalHelpButton) - $0.trailing.equalTo(festivalHelpButton.snp.leading).offset(Layout.festivalBannerOverlap) + $0.trailing.equalTo(festivalHelpButton.snp.leading).offset(-Layout.festivalBannerGap) $0.leading.greaterThanOrEqualToSuperview().inset(Layout.festivalHelpTrailing) } } From e95618ba2e768a8c2d24f29b8e861b869ecad7c6 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:47:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[#461]=20=EC=9E=90=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=ED=95=9C?= =?UTF-8?q?=EC=AA=BD=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EB=A7=88=EC=BB=A4=20=EC=9C=A0=EC=A7=80,=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=84=A0=ED=91=9C=EC=8B=9C,=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A7=84=EC=9E=85=20=EC=9D=BC=EA=B4=80=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Firebase/FirebaseRemoteConfig.swift | 4 +-- .../LikedPartnershipViewController.swift | 1 - .../MainMapViewController+Marker.swift | 4 +-- .../MainMapViewController+Network.swift | 19 ++++++------ .../MainMapViewController.swift | 29 ++++++++++++++----- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift b/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift index 4c648cb6..dc0c3118 100644 --- a/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift +++ b/EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift @@ -23,8 +23,8 @@ class FirebaseRemoteConfig { /// 구버전 앱이 쓰는 `festival_tab_enabled`와 분리해, 켜도 이전 버전 동작에 영향이 없도록 한다 var isFestivalPartnershipEnabled: Bool { #if DEBUG - // 로컬 확인용: 개발 빌드에서는 항상 노출 - return true + // 로컬 확인용: 개발 빌드는 기본 노출. 스킴 실행 인자 `-festivalPartnershipDisabled YES`로 종료 후 동작도 확인할 수 있다 + return !UserDefaults.standard.bool(forKey: "festivalPartnershipDisabled") #else return remoteConfig["festival_partnership_enabled"].boolValue #endif diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index 1b8620ea..cb3e816d 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -23,7 +23,6 @@ final class LikedPartnershipViewController: BaseViewController { static let listTop: CGFloat = 7 } - /// 찜 목록 필터 (축제 제외) private static let filters: [PartnershipFilter] = PartnershipFilter.allCases // MARK: - Properties diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 3270b123..41faa2ba 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -154,8 +154,8 @@ extension MainMapViewController { } /// 제휴점 상세 바텀시트 표시 - /// - Parameter likeTarget: 찜 토글 대상 원본 업체. nil이면 partnership 자체 - func showPartnershipDetail(for partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) { + /// - Parameter likeTarget: 찜 토글 대상 업체(일반 제휴 항목만). nil이면 찜 불가로 보고 하트를 숨긴다 + func showPartnershipDetail(for partnership: PartnershipDTO, likeTarget: PartnershipDTO?) { MapAnalyticsManager.shared.logClickPartnerRestaurant( collegeId: currentCollegeId, majorId: currentDepartmentId, diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index 97103439..bd2a2b36 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -24,10 +24,10 @@ extension MainMapViewController { let needsFestival = isFestivalPartnershipEnabled && !hasLoadedFestivalPartnerships let generation = beginLoad() - guard needsMy || needsFestival else { - applyPartnershipMarkers() - return - } + // 이미 받아둔 데이터로 먼저 그린다 (업종 필터 전환이 네트워크 응답을 기다리지 않도록) + applyPartnershipMarkers() + + guard needsMy || needsFestival else { return } let group = DispatchGroup() var myFailed = false @@ -47,8 +47,8 @@ extension MainMapViewController { self.hasLoadedMyPartnerships = true self.hasAttemptedMyPartnershipsFetch = true case .failure(let error): + // 실패는 "시도함"으로 남기지 않는다. 찜 상세 진입 시 다시 조회해 전체 단과대 원본이 노출되지 않게 한다 print("내 제휴 조회 실패: \(error.localizedDescription)") - self.hasAttemptedMyPartnershipsFetch = true myFailed = true } } @@ -84,13 +84,11 @@ extension MainMapViewController { } #endif - guard !(myFailed && self.cachedMyPartnerships.isEmpty) else { - self.displayMarkers([]) + // 한쪽이 실패해도 받아온 쪽은 그대로 그린다 (축제만 성공한 경우 지도가 비지 않도록) + self.applyPartnershipMarkers() + if myFailed, self.cachedMyPartnerships.isEmpty { self.showStoreLoadFailedToast() - return } - - self.applyPartnershipMarkers() self.presentPendingDetailIfNeeded() } } @@ -190,6 +188,7 @@ extension MainMapViewController { // 학과가 바뀌면 내 제휴 캐시는 더 이상 유효하지 않다 self.cachedMyPartnerships = [] self.hasLoadedMyPartnerships = false + self.hasAttemptedMyPartnershipsFetch = false } self.currentDepartmentName = department.departmentName self.currentDepartmentId = department.departmentId diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 1d5de0b6..5a9b2c34 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -95,6 +95,8 @@ final class MainMapViewController: BaseViewController { /// 자동 노출 예약을 무효화하기 위한 토큰 (연속 탭 시 이전 예약이 배너를 닫지 않도록) private var festivalBannerToken = 0 + /// 말풍선 노출 상태. isHidden은 페이드가 끝난 뒤에야 바뀌므로 별도로 둔다 + private var isFestivalBannerVisible = false /// 축제 안내 자동 노출은 앱 실행당 한 번만 private static var hasAutoShownFestivalBanner = false @@ -246,9 +248,13 @@ final class MainMapViewController: BaseViewController { zoom: CameraConstants.detailZoom, animated: false ) - // 찜 원본 DTO는 모든 단과대 제휴를 담고 있어, 지도 마커와 동일하게 내 제휴 데이터로 표시한다 - // (내 제휴에 없으면 — 학과 변경 등 — 원본으로 폴백, 시트에서 내용 기준 중복 제거) - let display = cachedMyPartnerships.first { $0.storeKey == store.storeKey } ?? store + // 찜 원본 DTO는 모든 단과대 제휴를 담고 있어, 지도 마커와 동일한 병합 결과로 표시한다 + // (병합 결과에 없으면 — 학과 변경 등 — 원본으로 폴백, 시트에서 내용 기준 중복 제거) + let merged = Self.mergedPartnerships( + my: cachedMyPartnerships, + festival: isFestivalPartnershipEnabled ? cachedFestivalPartnerships : [] + ) + let display = merged.first { $0.storeKey == store.storeKey } ?? store showPartnershipDetail(for: display, likeTarget: Self.likeTarget(for: store)) } @@ -493,10 +499,10 @@ final class MainMapViewController: BaseViewController { } @objc private func didTapFestivalHelp() { - if root.festivalBannerView.isHidden { - showFestivalBanner() - } else { + if isFestivalBannerVisible { hideFestivalBanner() + } else { + showFestivalBanner() } } @@ -510,6 +516,7 @@ final class MainMapViewController: BaseViewController { festivalBannerToken += 1 let token = festivalBannerToken + isFestivalBannerVisible = true root.festivalBannerView.alpha = 0 root.festivalBannerView.isHidden = false UIView.animate(withDuration: FestivalBanner.fadeDuration) { @@ -524,12 +531,18 @@ final class MainMapViewController: BaseViewController { } func hideFestivalBanner() { - guard !root.festivalBannerView.isHidden else { return } + guard isFestivalBannerVisible else { return } + isFestivalBannerVisible = false festivalBannerToken += 1 + let token = festivalBannerToken UIView.animate( withDuration: FestivalBanner.fadeDuration, animations: { self.root.festivalBannerView.alpha = 0 }, - completion: { _ in self.root.festivalBannerView.isHidden = true } + completion: { [weak self] _ in + // 페이드 중에 다시 열렸다면 그대로 둔다 + guard let self, self.festivalBannerToken == token else { return } + self.root.festivalBannerView.isHidden = true + } ) }