Implemented phase 1 for the POC prototype in the Website service.
This commit is contained in:
1 parent
6d7ab4bcba
commit
ea89ea892a
37 files changed
+4173
-43
No files matched your search
@@ -0,0 +1,352 @@
|
||||
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
|
||||
|
||||
// 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.
|
||||
/// - 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],
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user