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..dc0c3118 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 + // 로컬 확인용: 개발 빌드는 기본 노출. 스킴 실행 인자 `-festivalPartnershipDisabled YES`로 종료 후 동작도 확인할 수 있다 + return !UserDefaults.standard.bool(forKey: "festivalPartnershipDisabled") + #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..cb3e816d 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -23,8 +23,7 @@ final class LikedPartnershipViewController: BaseViewController { static let listTop: CGFloat = 7 } - /// 찜 목록 필터 (축제 제외) - 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..e9ff24f8 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,31 @@ 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 + /// safe area 하단은 탭바 상단과 같으므로(UITabBarController) 그 위로 띄운다 (디자인 실측 38) + static let festivalHelpBottom: CGFloat = 38 + /// 말풍선 꼬리 끝과 아이콘 사이 간격 (디자인 실측 5.5) + static let festivalBannerGap: CGFloat = 6 + } + // MARK: - UI Components let mapView = NMFNaverMapView() @@ -71,6 +81,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 +118,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 +149,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(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.festivalBannerGap) + $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..41faa2ba 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) + } ) } @@ -148,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, @@ -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..bd2a2b36 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -11,93 +11,151 @@ 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 = !hasLoadedMyPartnerships + let needsFestival = isFestivalPartnershipEnabled && !hasLoadedFestivalPartnerships 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 - } + // 이미 받아둔 데이터로 먼저 그린다 (업종 필터 전환이 네트워크 응답을 기다리지 않도록) + applyPartnershipMarkers() - #if DEBUG - // 서버에 제휴 데이터가 없거나 실패한 동안 Mock으로 대체 (DEBUG 빌드 전용, 실패 토스트는 띄우지 않음) - if self.cachedAllPartnerships.isEmpty { - self.cachedAllPartnerships = PartnershipMockData.samples - self.applyPartnershipMarkers(from: self.cachedAllPartnerships, periodType: .festival) - } - #endif - } - } + guard needsMy || needsFestival else { 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, self.isCurrentLoad(generation) else { return } + switch result { + case .success(let partnerships): + self.cachedMyPartnerships = partnerships + self.hasLoadedMyPartnerships = true + self.hasAttemptedMyPartnershipsFetch = 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, 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)") + } + } } - 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)") - self.displayMarkers([]) + #if DEBUG + // 서버에 축제 데이터가 없는 동안 Mock으로 확인 (개발 빌드 전용) + if self.isFestivalPartnershipEnabled, self.cachedFestivalPartnerships.isEmpty { + self.cachedFestivalPartnerships = Self.filterPartnerships(PartnershipMockData.samples, by: .festival) + } + #endif + + // 한쪽이 실패해도 받아온 쪽은 그대로 그린다 (축제만 성공한 경우 지도가 비지 않도록) + self.applyPartnershipMarkers() + if myFailed, self.cachedMyPartnerships.isEmpty { self.showStoreLoadFailedToast() } + 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] { @@ -129,6 +187,8 @@ extension MainMapViewController { if self.currentDepartmentId != department.departmentId { // 학과가 바뀌면 내 제휴 캐시는 더 이상 유효하지 않다 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 884862e8..5a9b2c34 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -50,12 +50,16 @@ final class MainMapViewController: BaseViewController { var clusterer: NMCClusterer? - /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) - var cachedAllPartnerships: [PartnershipDTO] = [] + /// 축제 제휴 캐시 (전체 제휴 응답에서 FESTIVAL 항목만 추린 것) + 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] = [] @@ -89,21 +93,25 @@ 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 + /// 말풍선 노출 상태. isHidden은 페이드가 끝난 뒤에야 바뀌므로 별도로 둔다 + private var isFestivalBannerVisible = false + /// 축제 안내 자동 노출은 앱 실행당 한 번만 + 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 +178,7 @@ final class MainMapViewController: BaseViewController { setEntryCameraPosition() setupLocationButtonObserver() setupMarkerTapHandler() + setupFestivalHelp() applyTabUI() } @@ -182,6 +191,8 @@ final class MainMapViewController: BaseViewController { case .goodPrice: loadGoodPriceMarkers() } + // Remote Config 수신이 늦을 수 있어 진입할 때마다 다시 판단한다 + updateFestivalHelpVisibility() } override func viewDidAppear(_ animated: Bool) { @@ -190,6 +201,7 @@ final class MainMapViewController: BaseViewController { // 찜 목록에서 넘어온 업체가 있으면 화면이 붙은 뒤 시트를 띄운다 presentPendingDetailIfNeeded() + showFestivalBannerOnFirstEntryIfNeeded() } override func viewDidDisappear(_ animated: Bool) { @@ -226,7 +238,7 @@ final class MainMapViewController: BaseViewController { return } // 내 제휴 응답 전이면 받아온 뒤 연다 (전체 단과대 원본 시트가 잠깐 노출되는 것 방지) - if !hasFetchedMyPartnerships, cachedMyPartnerships.isEmpty { + if !hasAttemptedMyPartnershipsFetch, cachedMyPartnerships.isEmpty { loadMyPartnershipsForPendingDetail() return } @@ -236,10 +248,14 @@ final class MainMapViewController: BaseViewController { zoom: CameraConstants.detailZoom, animated: false ) - // 찜 원본 DTO는 모든 단과대 제휴를 담고 있어, 지도 마커와 동일하게 내 제휴 데이터로 표시한다 - // (내 제휴에 없으면 — 학과 변경 등 — 원본으로 폴백, 시트에서 내용 기준 중복 제거) - let display = cachedMyPartnerships.first { $0.storeKey == store.storeKey } ?? store - showPartnershipDetail(for: display, likeTarget: 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)) } /// 찜 → 상세 진입용 내 제휴 확보. 마커 로드 세대에 영향을 주지 않도록 캐시만 채운다 @@ -256,8 +272,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() } } @@ -293,7 +310,7 @@ final class MainMapViewController: BaseViewController { (tabBarController as? CustomTabBarContainerController)?.showLikedPartnerships(fromMap: true) } - /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드 private func refreshPartnershipTab() { applyTabUI() let generation = beginLoad() @@ -347,10 +364,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 +421,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 +434,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 +455,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,15 +477,94 @@ 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 isFestivalBannerVisible { + hideFestivalBanner() + } else { + showFestivalBanner() + } + } + + @objc private func didTapFestivalBanner() { + hideFestivalBanner() + } + + /// 안내 말풍선을 띄우고 일정 시간 뒤 자동으로 닫는다 + private func showFestivalBanner() { + guard isFestivalPartnershipEnabled, currentTab == .partnership else { return } + + festivalBannerToken += 1 + let token = festivalBannerToken + isFestivalBannerVisible = true + 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 isFestivalBannerVisible else { return } + isFestivalBannerVisible = false + festivalBannerToken += 1 + let token = festivalBannerToken + UIView.animate( + withDuration: FestivalBanner.fadeDuration, + animations: { self.root.festivalBannerView.alpha = 0 }, + completion: { [weak self] _ in + // 페이드 중에 다시 열렸다면 그대로 둔다 + guard let self, self.festivalBannerToken == token else { return } + 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 = [] + hasLoadedFestivalPartnerships = false cachedMyPartnerships = [] - hasFetchedMyPartnerships = false + hasLoadedMyPartnerships = false + hasAttemptedMyPartnershipsFetch = false refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] 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 5244982b..4d87abb7 100644 Binary files a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_cafe_pin.imageset/festival_cafe_pin.pdf and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_cafe_pin.imageset/festival_cafe_pin.pdf differ 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 f4980674..4b75c0dd 100644 Binary files a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_pub_pin.imageset/festival_pub_pin.pdf and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_pub_pin.imageset/festival_pub_pin.pdf differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_restaurant_pin.imageset/festival_restaurant_pin.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_restaurant_pin.imageset/festival_restaurant_pin.pdf index 1abb6b03..e10476cf 100644 Binary files a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_restaurant_pin.imageset/festival_restaurant_pin.pdf and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/festival_restaurant_pin.imageset/festival_restaurant_pin.pdf differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/Contents.json new file mode 100644 index 00000000..d8034d14 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_festival_info.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/ic_festival_info.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/ic_festival_info.pdf new file mode 100644 index 00000000..2883a75b Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_festival_info.imageset/ic_festival_info.pdf differ