import Elementary import Foundation import Infrastructure import Persistence /// The search results screen: the matching listings beside the filter rail. struct SearchPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// The page of listings shown. let listings: [Listing] /// The number of listings matching the search, across every page. let matchCount: Int /// The page shown and the number of pages. let pages: (current: Int, total: Int) /// The search the screen answers. let parameters: SearchParameters /// Every publisher a listing can be filtered by. let publishers: [String] /// The number of shops the matching listings come from. let shopCount: Int /// The signed-in viewer, or `nil` when signed out. let viewer: Viewer? // MARK: Initializers /// Creates the search results screen for a search and the listings matching it. /// - Parameters: /// - parameters: the search the screen answers. /// - matches: every listing matching the search; the screen shows the page the parameters ask for. /// - publishers: every publisher a listing can be filtered by. /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. init( parameters: SearchParameters, matches: [Listing], publishers: [String], viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.viewer = viewer let slice = Pagination.slice( matches, page: parameters.page, size: SearchParameters.pageSize ) self.analytics = analytics self.assetVersion = assetVersion self.listings = slice.items self.locale = .init(identifier: "en") self.matchCount = matches.count self.pages = (slice.current, slice.total) self.parameters = parameters self.publishers = publishers self.shopCount = Set(matches.map(\.shop.slug)).count } } // MARK: - Page extension SearchPage: Page { // MARK: Constants /// The path the page is served at. static let path = String.Route.search /// The identifier tying the sort select to the filter form. private static let formID = "filters" // MARK: Computed var content: some HTML { SiteLayout( active: formats.count == 1 ? formats.first : nil, query: parameters.value(.query), viewer: viewer ) { div(.class("page search")) { div(.class("search__head")) { div(.class("search__heading")) { p(.class("type-eyebrow")) { formats.isEmpty ? "All formats" : formats.map(\.label).joined(separator: " · ") } h1(.class("type-h1")) { heading } p(.class("type-body-sm search__count")) { matchCount == 1 ? "1 listing" : "\(matchCount) listings" shopCount == 1 ? " from 1 shop" : " from \(shopCount) shops" } } SortSelect( form: Self.formID, selected: parameters.filter.sort ) } div(.class("search__body")) { filters div(.class("search__results")) { if !parameters.pills.isEmpty { ul(.class("search__pills")) { for pill in parameters.pills { li { a( .class("pill pill--active"), .href(pill.removed.path(Self.path)), .custom( name: "aria-label", value: "Remove filter: \(pill.label)" ) ) { pill.label span(.custom(name: "aria-hidden", value: "true")) { " ×" } } } } } } if listings.isEmpty { div(.class("empty")) { h2(.class("type-panel")) { parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match" } p(.class("type-body-sm")) { "Nothing listed meets that right now. Clear the filters — listings outside the EU are left out by default — or ask to be told when a copy appears." } div(.class("empty__actions")) { if let query = parameters.value(.query) { a( .class("button button--primary"), .href(.Route.newWant(title: query)) ) { Icon(.bell, size: 16) "Alert me" } } a( .class("button button--outline"), .href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path)) ) { "Clear all filters" } } } } else { div(.class("card-grid card-grid--search")) { for listing in listings { ListingCard(listing, context: .search) } } } Pagination( current: pages.current, total: pages.total ) { [parameters] in parameters.setting(.page, to: "\($0)").path(Self.path) } } } } } } var scripts: [any Asset] { [StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.search] } var title: String { "\(heading) — CCN" } } // MARK: - Helpers private extension SearchPage { // MARK: Computed /// The filter rail: a plain `GET` form inside a disclosure, which the stylesheet keeps open on desktop. var filters: some HTML { details(.class("filters")) { Elementary.summary(.class("filters__toggle button button--outline")) { Icon(.filter, size: 15) parameters.pills.isEmpty ? "Filters" : "Filters · \(parameters.pills.count)" } form( .class("filters__form"), .id(Self.formID), .action(Self.path), .method(.get) ) { div(.class("filters__head")) { h2(.class("type-eyebrow filters__title")) { "Filters" } a( .class("filters__clear"), .href(Self.path) ) { "Clear all" } } if let query = parameters.value(.query) { input( .type(.hidden), .name(SearchParameters.Name.query.rawValue), .value(query) ) } group("Format") { ForEach(Format.allCases) { format in checkbox(.format, value: format.rawValue, label: format.label) } } group("Grading") { checkbox(.grading, value: SearchParameters.Grading.slabbed.rawValue, label: "Slabbed (CGC, CBCS, PGX)") checkbox(.grading, value: SearchParameters.Grading.raw.rawValue, label: "Raw, seller-graded") div(.class("filters__range")) { field(.gradeMin, label: "Min", placeholder: "0.5", isLabelVisible: true) field(.gradeMax, label: "Max", placeholder: "10", isLabelVisible: true) } } group("Printing") { checkbox(.firstPrint, value: "1", label: "1st print only") } group("Publisher") { ForEach(publishers) { publisher in checkbox(.publisher, value: publisher, label: publisher) } } group("Price") { div(.class("filters__range")) { field(.priceMin, label: "Minimum price", placeholder: "€ min") span(.class("type-small")) { "to" } field(.priceMax, label: "Maximum price", placeholder: "€ max") } } group("Ships from") { div(.class("segmented")) { for region in ListingFilter.Region.allCases { label(.class("segmented__option")) { input( .type(.radio), .name(SearchParameters.Name.shipsFrom.rawValue), .value(region.rawValue) ) .attributes(.checked, when: region == parameters.filter.shipsFrom) span { region == .anywhere ? "Anywhere" : region.rawValue.uppercased() } } } } span(.class("type-small")) { "Outside the EU, import VAT may apply." } checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event") } group("Key issues") { checkbox(.keys, value: "1", label: "Key issues only") } group("Seller") { checkbox(.offers, value: "1", label: "Accepts offers") checkbox(.verified, value: "1", label: "Verified shop") } button( .class("button button--dark button--block"), .type(.submit) ) { "Apply filters" } } } } /// The formats the search is narrowed to. var formats: [Format] { parameters .values(.format) .compactMap(Format.init) } /// The screen's heading: the search text in quotes, else the one format searched, else every listing. var heading: String { if let query = parameters.value(.query) { return "“\(query)”" } return formats.count == 1 ? formats[0].label : "All listings" } // MARK: Methods /// A checkbox, ticked when the search carries its pair. func checkbox( _ name: SearchParameters.Name, value: String, label text: String ) -> some HTML { label(.class("check")) { input( .type(.checkbox), .name(name.rawValue), .value(value) ) .attributes(.checked, when: parameters.has(name, value: value)) text } } /// A short text field, filled with the search's value for its name. func field( _ name: SearchParameters.Name, label text: String, placeholder: String, isLabelVisible: Bool = false ) -> some HTML { label(.class("filters__field")) { span(.class(isLabelVisible ? "type-small" : "visually-hidden")) { text } input( .class("input input--sm"), .type(.text), .name(name.rawValue), .value(parameters.value(name) ?? ""), .placeholder(placeholder), .custom(name: "inputmode", value: "decimal") ) } } /// A titled group of filter controls. func group( _ title: String, @HTMLBuilder content: () -> some HTML ) -> some HTML { fieldset(.class("filters__group")) { legend(.class("type-label")) { title } content() } } }