372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
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()
|
||
}
|
||
}
|
||
|
||
}
|