diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index ac54e2c5..52d6ea7c 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -165,5 +165,9 @@ public struct DataAssembly: Assembly { pushTokenStore: pushTokenStore ) } + + container.register(type: ExternalLinkRepository.self) { _ in + return DefaultExternalLinkRepository() + } } } diff --git a/src/Projects/BKData/Sources/Repository/DefaultExternalLinkRepository.swift b/src/Projects/BKData/Sources/Repository/DefaultExternalLinkRepository.swift new file mode 100644 index 00000000..251996f8 --- /dev/null +++ b/src/Projects/BKData/Sources/Repository/DefaultExternalLinkRepository.swift @@ -0,0 +1,37 @@ +// Copyright © 2026 Booket. All rights reserved + +import BKCore +import BKDomain +import Combine +import UIKit + +final class DefaultExternalLinkRepository: ExternalLinkRepository { + func canOpen(_ urlString: String) -> Bool { + guard let url = URL(string: urlString) else { + Log.error("유효하지 않은 URL 형식: \(urlString)", logger: AppLogger.network) + return false + } + return UIApplication.shared.canOpenURL(url) + } + + func open(_ urlString: String) -> AnyPublisher { + return Future { promise in + guard let url = URL(string: urlString) else { + Log.error("URL 객체 생성 실패: \(urlString)", logger: AppLogger.network) + promise(.success(false)) + return + } + + DispatchQueue.main.async { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + promise(.success(success)) + } + } else { + promise(.success(false)) + } + } + } + .eraseToAnyPublisher() + } +} diff --git a/src/Projects/BKDomain/Sources/DomainAssembly.swift b/src/Projects/BKDomain/Sources/DomainAssembly.swift index dbf3d40b..9961b0a0 100644 --- a/src/Projects/BKDomain/Sources/DomainAssembly.swift +++ b/src/Projects/BKDomain/Sources/DomainAssembly.swift @@ -255,5 +255,10 @@ public struct DomainAssembly: Assembly { notificationRepository: notificationRepository ) } + + container.register(type: OpenExternalLinkUseCase.self) { _ in + @Autowired var repository: ExternalLinkRepository + return DefaultOpenExternalLinkUseCase(repository: repository) + } } } diff --git a/src/Projects/BKDomain/Sources/Interface/Repository/ExternalLinkRepository.swift b/src/Projects/BKDomain/Sources/Interface/Repository/ExternalLinkRepository.swift new file mode 100644 index 00000000..e09f7b8a --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Repository/ExternalLinkRepository.swift @@ -0,0 +1,20 @@ +// Copyright © 2026 Booket. All rights reserved + +import Combine +import Foundation + +/// 외부 시스템(앱 스킴 또는 웹 브라우저)으로 링크를 연결하고 상태를 확인하는 인터페이스입니다. +public protocol ExternalLinkRepository { + + /// 전달받은 URL 문자열이 현재 시스템에서 실행 가능한지 여부를 확인합니다. + /// + /// - Parameter urlString: 확인할 대상 URL 문자열 (예: "kakaoplus://...", "https://...") + /// - Returns: 실행 가능 여부 (true: 실행 가능, false: 실행 불가 또는 스킴 미등록) + func canOpen(_ urlString: String) -> Bool + + /// 전달받은 URL 문자열을 통해 외부 링크를 실행합니다. + /// + /// - Parameter urlString: 실행할 대상 URL 문자열 + /// - Returns: 실행 성공 여부를 전달하는 Publisher (true: 실행 성공, false: 실행 실패) + func open(_ urlString: String) -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/Interface/Usecase/OpenExternalLinkUseCase.swift b/src/Projects/BKDomain/Sources/Interface/Usecase/OpenExternalLinkUseCase.swift new file mode 100644 index 00000000..3affdb0e --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Usecase/OpenExternalLinkUseCase.swift @@ -0,0 +1,10 @@ +// Copyright © 2026 Booket. All rights reserved + +import Combine +import Foundation + +public protocol OpenExternalLinkUseCase { + /// 외부 링크를 실행합니다. + /// appScheme이 있고 실행 가능한 경우 우선 실행하며, 실패 시 urlString을 실행합니다. + func execute(urlString: String, appScheme: String?) -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/UseCase/DefaultOpenExternalLinkUseCase.swift b/src/Projects/BKDomain/Sources/UseCase/DefaultOpenExternalLinkUseCase.swift new file mode 100644 index 00000000..60874a60 --- /dev/null +++ b/src/Projects/BKDomain/Sources/UseCase/DefaultOpenExternalLinkUseCase.swift @@ -0,0 +1,20 @@ +// Copyright © 2026 Booket. All rights reserved + +import Combine +import Foundation + +public struct DefaultOpenExternalLinkUseCase: OpenExternalLinkUseCase { + private let repository: ExternalLinkRepository + + init(repository: ExternalLinkRepository) { + self.repository = repository + } + + public func execute(urlString: String, appScheme: String?) -> AnyPublisher { + if let appScheme = appScheme, repository.canOpen(appScheme) { + return repository.open(appScheme) + } + + return repository.open(urlString) + } +} diff --git a/src/Projects/BKPresentation/Sources/ArchiveFlow/View/ArchiveView.swift b/src/Projects/BKPresentation/Sources/ArchiveFlow/View/ArchiveView.swift index 034e8d89..41281e37 100644 --- a/src/Projects/BKPresentation/Sources/ArchiveFlow/View/ArchiveView.swift +++ b/src/Projects/BKPresentation/Sources/ArchiveFlow/View/ArchiveView.swift @@ -73,7 +73,7 @@ final class ArchiveView: BaseView, UIGestureRecognizerDelegate { setupLayout() updateEmptyState() - emptyStateView.onTapLogin = { [weak self] in + emptyStateView.onTapActionButton = { [weak self] in self?.eventPublisher.send(.loginButtonTapped) } diff --git a/src/Projects/BKPresentation/Sources/ArchiveFlow/View/EmptyStateView.swift b/src/Projects/BKPresentation/Sources/ArchiveFlow/View/EmptyStateView.swift index 20ccf6c0..e3a0ec68 100644 --- a/src/Projects/BKPresentation/Sources/ArchiveFlow/View/EmptyStateView.swift +++ b/src/Projects/BKPresentation/Sources/ArchiveFlow/View/EmptyStateView.swift @@ -6,7 +6,11 @@ import UIKit import SnapKit final class EmptyStateView: BaseView { - var onTapLogin: (() -> Void)? + var onTapActionButton: (() -> Void)? + + private var memberCustomTitle: String? + private var memberCustomDescription: String? + private var memberCustomActionTitle: String? private var cancellables = Set() private let titleLabel = BKLabel( @@ -23,13 +27,13 @@ final class EmptyStateView: BaseView { alignment: .center ) - private let loginButton: BKButton = { + private let actionButton: BKButton = { let button = BKButton(style: .secondary, size: .small) return button }() private lazy var stackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel, loginButton]) + let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel, actionButton]) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 8 @@ -50,18 +54,28 @@ final class EmptyStateView: BaseView { } override func configure() { - loginButton.title = Constants.loginButtonTitle - loginButton.addTarget(self, action: #selector(tapLogin), for: .touchUpInside) + actionButton.addTarget(self, action: #selector(tapActionButton), for: .touchUpInside) AccessModeCenter.shared.mode .receive(on: DispatchQueue.main) .sink { [weak self] mode in - self?.apply(mode: mode) + self?.updateUI(for: mode) } .store(in: &cancellables) - apply(mode: AccessModeCenter.shared.mode.value) + updateUI(for: AccessModeCenter.shared.mode.value) } + + public func setContent(title: String, description: String, actionTitle: String? = nil) { + self.memberCustomTitle = title + self.memberCustomDescription = description + self.memberCustomActionTitle = actionTitle + + if AccessModeCenter.shared.mode.value == .member { + updateUI(for: .member) + } + } + } private extension EmptyStateView { @@ -73,20 +87,31 @@ private extension EmptyStateView { static let loginButtonTitle = "로그인하기" } - func apply(mode: AppAccessMode) { + private func updateUI(for mode: AppAccessMode) { switch mode { case .guest: titleLabel.setText(text: Constants.guestTitle) descriptionLabel.setText(text: Constants.guestSubtitle) - loginButton.isHidden = false + actionButton.title = Constants.loginButtonTitle + actionButton.isHidden = false + case .member: - titleLabel.setText(text: Constants.memberTitle) - descriptionLabel.setText(text: Constants.memberSubtitle) - loginButton.isHidden = true + let title = memberCustomTitle ?? Constants.memberTitle + let desc = memberCustomDescription ?? Constants.memberSubtitle + + titleLabel.setText(text: title) + descriptionLabel.setText(text: desc) + + if let actionTitle = memberCustomActionTitle { + actionButton.title = actionTitle + actionButton.isHidden = false + } else { + actionButton.isHidden = true + } } } - @objc func tapLogin() { - onTapLogin?() + @objc func tapActionButton() { + onTapActionButton?() } } diff --git a/src/Projects/BKPresentation/Sources/Constant/URLConstants.swift b/src/Projects/BKPresentation/Sources/Constant/URLConstants.swift new file mode 100644 index 00000000..9a0dd0ab --- /dev/null +++ b/src/Projects/BKPresentation/Sources/Constant/URLConstants.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Booket. All rights reserved + +import Foundation + +private final class PresentationBundleToken {} + +public enum URLConstants { + private static let bundle = Bundle(for: PresentationBundleToken.self) + + private static let kakaoAccount: String = { + guard let value = bundle.object(forInfoDictionaryKey: "KAKAO_ACCOUNT") as? String else { + fatalError("Can't load KAKAO_ACCOUNT") + } + return value + }() + + public static let kakaoAppScheme = "kakaoplus://plusfriend/home/\(kakaoAccount)" + public static let kakaoChatURL = "https://pf.kakao.com/\(kakaoAccount)/chat" +} diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift b/src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift index e3a71a2b..109bb033 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift @@ -1,5 +1,9 @@ // Copyright © 2025 Booket. All rights reserved +import BKCore +import BKDesign +import BKDomain +import Combine import UIKit final class SearchCoordinator: Coordinator { @@ -8,6 +12,9 @@ final class SearchCoordinator: Coordinator { var navigationController: UINavigationController private let searchViewType: SearchViewType + private var cancellables = Set() + + @Autowired var openExternalLinkUseCase: OpenExternalLinkUseCase init( parentCoordinator: Coordinator?, @@ -57,4 +64,22 @@ extension SearchCoordinator { addChildCoordinator(bookDetailCoordinator) bookDetailCoordinator.start() } + + func showRequestPage() { + let webURL = URLConstants.kakaoChatURL + let appScheme = URLConstants.kakaoAppScheme + + Log.debug("외부 링크 오픈 시도 - Web: \(webURL), App: \(appScheme)", logger: AppLogger.ui) + + openExternalLinkUseCase.execute(urlString: webURL, appScheme: appScheme) + .receive(on: DispatchQueue.main) + .sink { success in + if success { + Log.debug("외부 링크 오픈 성공", logger: AppLogger.ui) + } else { + Log.error("외부 링크 오픈 실패 (URL 스킴 확인 필요)", logger: AppLogger.ui) + } + } + .store(in: &cancellables) + } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift index e153b08a..01b20fd1 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift @@ -26,6 +26,7 @@ final class SearchView: BaseView { private let divider = BKDivider(type: .medium) private let header = SearchSectionHeaderView() + private let emptyView = EmptyStateView() private lazy var collectionView: UICollectionView = { return setupCollectionView() @@ -38,13 +39,23 @@ final class SearchView: BaseView { private var layoutMode = CollectionLayoutMode.beforeSearch override func setupView() { - addSubviews(searchBar, divider, header, collectionView) + addSubviews(searchBar, divider, header, collectionView, emptyView) } override func configure() { searchBar.setOnReturn { [weak self] text in self?.eventPublisher.send(.search(text)) } + + emptyView.onTapActionButton = { [weak self] in + let currentMode = AccessModeCenter.shared.mode.value + + if currentMode == .guest { + self?.eventPublisher.send(.goToLogin) + } else { + self?.eventPublisher.send(.goToRequestPage) + } + } } override func setupLayout() { @@ -72,6 +83,12 @@ final class SearchView: BaseView { .offset(LayoutConstants.headerOffset) $0.leading.trailing.bottom.equalToSuperview() } + + emptyView.snp.makeConstraints { + $0.top.equalTo(header.snp.bottom) + .offset(LayoutConstants.headerOffset) + $0.leading.trailing.bottom.equalToSuperview() + } } func setSearchBarPlaceholder(with placeholder: String) { @@ -90,6 +107,9 @@ final class SearchView: BaseView { switch state { case .recent(let state): + emptyView.isHidden = true + collectionView.isHidden = false + if state.queries.isEmpty { collectionView.backgroundView = makeEmptyLabel(state.placeholder) } else { @@ -102,12 +122,15 @@ final class SearchView: BaseView { case .result(let state): let isEmpty = state.books.isEmpty && state.bookInfos.isEmpty if isEmpty { - header.layoutIfNeeded() - let headerHeight = header.bounds.height - let offset = -(headerHeight / 2.0) - collectionView.backgroundView = makeEmptyLabel(state.placeholder, verticalOffset: offset) + updateEmptyState( + isHidden: false, + title: "아직 등록된 책이 없어요", + description: "카카오톡 채널로 문의를 남겨주세요", + buttonTitle: "문의하기" + ) searchBar.setClearButtonMode(.whileEditing) } else { + updateEmptyState(isHidden: true) collectionView.backgroundView = nil snapshot.appendSections([.result]) // Book과 BookInfo 모두 처리 @@ -273,11 +296,27 @@ private extension SearchView { $0.centerX.equalToSuperview() $0.centerY.equalToSuperview().offset(verticalOffset) } - + return container } - @objc func searchButtonTapped() { + private func updateEmptyState( + isHidden: Bool, + title: String = "", + description: String = "", + buttonTitle: String = "" + ) { + emptyView.isHidden = isHidden + collectionView.isHidden = !isHidden + + if !isHidden { + emptyView.setContent(title: title, description: description, actionTitle: buttonTitle) + bringSubviewToFront(emptyView) + } + } + + @objc + func searchButtonTapped() { guard let text = searchBar.text, !text.isEmpty else { return } eventPublisher.send(.search(text)) } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift index 5dfe24c4..388b0045 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift @@ -11,6 +11,8 @@ enum SearchViewEvent: Equatable { case deleteRecentQuery(String) case upsertBook(String) case goToBookDetail(isbn: String, userBookId: String) + case goToLogin + case goToRequestPage } final class SearchViewController: BaseViewController, ScreenLoggable { @@ -70,6 +72,23 @@ final class SearchViewController: BaseViewController, ScreenLoggable } .store(in: &cancellable) + contentView.eventPublisher + .filter { $0 == .goToLogin } + .sink { [weak self] _ in + print("LOG: 게스트 모드 - 로그인 화면으로 이동") + self?.coordinator?.notifyAuthenticationRequired { } + } + .store(in: &cancellable) + + contentView.eventPublisher + .filter { $0 == .goToRequestPage } + .sink { [weak self] _ in + self?.viewModel.send(.requestPageTapped) + + self?.coordinator?.showRequestPage() + } + .store(in: &cancellable) + contentView.eventPublisher .compactMap { event -> String? in if case let .deleteRecentQuery(query) = event { return query } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift b/src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift index 7a06c0f3..98173e90 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift @@ -70,7 +70,6 @@ enum SearchViewType: String { } } - final class SearchViewModel: BaseViewModel { enum SearchState: Equatable { case recent(RecentState) @@ -114,6 +113,7 @@ final class SearchViewModel: BaseViewModel { case errorHandled case retryTapped case lastRetryTapped + case requestPageTapped } enum SideEffect { @@ -317,6 +317,9 @@ final class SearchViewModel: BaseViewModel { case .errorHandled: newState.error = nil + + case .requestPageTapped: + Log.debug("문의하기 버튼 클릭 액션 수신", logger: AppLogger.viewModel) } return (newState, effects) diff --git a/src/SupportingFiles/Booket/Info.plist b/src/SupportingFiles/Booket/Info.plist index 22b7c5c4..607cf91b 100644 --- a/src/SupportingFiles/Booket/Info.plist +++ b/src/SupportingFiles/Booket/Info.plist @@ -43,6 +43,8 @@ KAKAO_NATIVE_APP_KEY $(KAKAO_NATIVE_APP_KEY) + KAKAO_ACCOUNT + $(KAKAO_ACCOUNT) LSApplicationQueriesSchemes kakaokompassauth diff --git a/src/Workspace.swift b/src/Workspace.swift index b179edf5..ec7a4f49 100644 --- a/src/Workspace.swift +++ b/src/Workspace.swift @@ -6,5 +6,5 @@ let workspace = Workspace( projects: BKModule.allCases.map { .relativeToRoot("Projects/\($0.rawValue)") }, - fileHeaderTemplate: "Copyright © 2025 Booket. All rights reserved" + fileHeaderTemplate: "Copyright © 2026 Booket. All rights reserved" )