Files
ccn/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift
T

359 lines
13 KiB
Swift
Raw Normal View History

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")) {
"No listings match"
}
p(.class("type-body-sm")) {
"Try fewer filters, or let listings ship from anywhere."
}
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."
}
}
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()
}
}
}