2026-09-21 11:16:52 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-21 11:44:58 +02:00
|
|
|
|
/// The signed-in viewer, or `nil` when signed out.
|
|
|
|
|
|
let viewer: Viewer?
|
|
|
|
|
|
|
2026-09-21 11:16:52 +02:00
|
|
|
|
// 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.
|
2026-09-21 11:44:58 +02:00
|
|
|
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
2026-09-21 11:16:52 +02:00
|
|
|
|
/// - 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],
|
2026-09-21 11:44:58 +02:00
|
|
|
|
viewer: Viewer? = nil,
|
2026-09-21 11:16:52 +02:00
|
|
|
|
assetVersion: String? = nil,
|
|
|
|
|
|
analytics: Analytics? = nil
|
|
|
|
|
|
) {
|
2026-09-21 11:44:58 +02:00
|
|
|
|
self.viewer = viewer
|
2026-09-21 11:16:52 +02:00
|
|
|
|
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,
|
2026-09-21 11:44:58 +02:00
|
|
|
|
query: parameters.value(.query),
|
|
|
|
|
|
viewer: viewer
|
2026-09-21 11:16:52 +02:00
|
|
|
|
) {
|
|
|
|
|
|
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")) {
|
2026-09-21 13:53:13 +02:00
|
|
|
|
parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match"
|
2026-09-21 11:16:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
p(.class("type-body-sm")) {
|
2026-09-21 13:53:13 +02:00
|
|
|
|
"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."
|
2026-09-21 11:16:52 +02:00
|
|
|
|
}
|
2026-09-21 13:53:13 +02:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-09-21 11:16:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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."
|
|
|
|
|
|
}
|
2026-09-21 13:53:13 +02:00
|
|
|
|
checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event")
|
2026-09-21 11:16:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|