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

360 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}
}