Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions EATSSU/App/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@
/// MainMapVC - "제휴 지도"
"map.map" = "지도";
/// MainMapView - "전체"
/// 축제 제휴 안내 말풍선
"map.festivalBannerMessage" = "2026 동연제 제휴 매장을 확인해보세요!\n기존 제휴와 중복 적용되지 않아요.";
"map.all" = "전체";
/// "축제"
"map.festival" = "축제";
Expand Down
12 changes: 9 additions & 3 deletions EATSSU/App/Sources/Data/Firebase/FirebaseRemoteConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 49 additions & 6 deletions EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,47 @@ enum MapTab: Int, CaseIterable {
}
}

/// 학교 제휴 탭 필터. festival은 Remote Config로 노출 여부 결정
/// 학교 제휴 탭 필터 (축제 제휴는 별도 필터 없이 기존 제휴와 함께 표시된다)
enum PartnershipFilter: CaseIterable {
case festival
case all
case restaurant
case cafe
case pub

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
case .pub: return TextLiteral.Map.pub
}
}

/// 서버 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()
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 }
}

/// 학과 미입력 안내 중 지도 블러 표시/해제
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)
}

Expand Down Expand Up @@ -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,
Expand All @@ -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() })
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading