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
4 changes: 4 additions & 0 deletions src/Projects/BKData/Sources/DataAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,9 @@ public struct DataAssembly: Assembly {
pushTokenStore: pushTokenStore
)
}

container.register(type: ExternalLinkRepository.self) { _ in
return DefaultExternalLinkRepository()
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Bool, Never> {
return Future<Bool, Never> { 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()
}
}
5 changes: 5 additions & 0 deletions src/Projects/BKDomain/Sources/DomainAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,5 +255,10 @@ public struct DomainAssembly: Assembly {
notificationRepository: notificationRepository
)
}

container.register(type: OpenExternalLinkUseCase.self) { _ in
@Autowired var repository: ExternalLinkRepository
return DefaultOpenExternalLinkUseCase(repository: repository)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Bool, Never>
}
Original file line number Diff line number Diff line change
@@ -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<Bool, Never>
}
Original file line number Diff line number Diff line change
@@ -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<Bool, Never> {
if let appScheme = appScheme, repository.canOpen(appScheme) {
return repository.open(appScheme)
}

return repository.open(urlString)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
private let titleLabel = BKLabel(
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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?()
}
}
19 changes: 19 additions & 0 deletions src/Projects/BKPresentation/Sources/Constant/URLConstants.swift
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// Copyright © 2025 Booket. All rights reserved

import BKCore
import BKDesign
import BKDomain
import Combine
import UIKit

final class SearchCoordinator: Coordinator {
Expand All @@ -8,6 +12,9 @@ final class SearchCoordinator: Coordinator {
var navigationController: UINavigationController

private let searchViewType: SearchViewType
private var cancellables = Set<AnyCancellable>()

@Autowired var openExternalLinkUseCase: OpenExternalLinkUseCase

init(
parentCoordinator: Coordinator?,
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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 모두 처리
Expand Down Expand Up @@ -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))
}
Expand Down
Loading