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

293 lines
10 KiB
Swift

import Elementary
import Foundation
import Infrastructure
import Persistence
/// The want list: what the viewer is looking for, the listings that meet each want, and the form adding one.
struct WantsPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The values the add-a-want form is filled with, or `nil` to show the list alone.
let draft: Draft?
/// The signed-in viewer.
let viewer: Viewer?
/// The viewer's wants, the newest first.
let wants: [Want]
// MARK: Initializers
/// Creates the want list.
/// - Parameters:
/// - wants: the viewer's wants, the newest first.
/// - draft: the values the add-a-want form is filled with, or `nil` (the default) to show the list alone.
/// - viewer: the signed-in viewer.
/// - 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(
wants: [Want],
draft: Draft? = nil,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.draft = draft
self.locale = .init(identifier: "en")
self.viewer = viewer
self.wants = wants
}
}
// MARK: - Structs
extension WantsPage {
/// What the add-a-want form is filled with: a listing saved, a gap in a series, or nothing yet.
struct Draft: Sendable, Equatable {
var title = ""
var minGrade: Double?
var maxPriceCents: Int?
}
}
// MARK: - Page
extension WantsPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page wants")) {
div(.class("section-head")) {
h1(.class("type-h1")) {
"Wants"
}
span(.class("type-small")) {
"\(wants.count) \(wants.count == 1 ? "want" : "wants") · \(wants.reduce(0) { $0 + $1.newMatchCount }) new matches"
}
}
if let draft {
add(draft)
}
if wants.isEmpty, draft == nil {
div(.class("empty")) {
h2(.class("type-panel")) {
"No wants yet"
}
p(.class("type-body-sm")) {
"Add the issues you're hunting. We check every new listing against them and tell you the moment one appears."
}
p(.class("type-small")) {
"Tip: on any series page, a missing issue is one tap from an alert."
}
}
}
ul(.class("wants__list")) {
ForEach(wants) { want in
li(.class("card wants__want")) {
div(.class("wants__top")) {
a(
.class("wants__title"),
.href(Self.search(for: want))
) {
want.title
}
if want.newMatchCount > 0 {
span(.class("wants__new")) {
"\(want.newMatchCount) new"
}
} else {
span(.class("type-small")) {
want.matches.isEmpty ? "no matches yet" : "\(want.matches.count) listed"
}
}
}
p(.class("type-small")) {
Self.limits(of: want)
}
if let cheapest = want.matches.min(by: { $0.priceCents < $1.priceCents }) {
a(
.class("wants__match"),
.href(cheapest.path)
) {
"From \(cheapest.priceCents.euros) · \(cheapest.chip.label) · \(cheapest.shop.name)"
}
}
div(.class("wants__actions")) {
form(
.action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/alerts"),
.method(.post)
) {
button(
.class("switch"),
.type(.submit),
.custom(name: "role", value: "switch"),
.custom(name: "aria-checked", value: want.alerts ? "true" : "false")
) {
span(.class("switch__track")) {}
"Alert"
}
}
form(
.action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/remove"),
.method(.post)
) {
button(
.class("button button--bare button--sm"),
.type(.submit)
) {
"Remove"
}
}
}
}
}
}
if draft == nil {
a(
.class("wants__add"),
.href(.Route.newWant())
) {
"+ Add a want"
}
}
p(.class("type-small wants__note")) {
"Matches are checked every time a listing is published, among copies shipping from the EU. The bell in the header counts the new ones."
}
}
}
}
@HTMLBuilder
var metadata: some HTML {
meta(
.name("robots"),
.content("noindex")
)
siteMetadata
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.trade]
}
var title: String {
"Want list — CCN"
}
}
// MARK: - Helpers
private extension WantsPage {
/// The form adding a want.
func add(
_ draft: Draft
) -> some HTML {
form(
.class("card wants__form"),
.action(.Route.wants),
.method(.post)
) {
h2(.class("type-panel")) {
"Add a want"
}
div(.class("listing-form__grid")) {
TextField(
name: "title",
title: "Series and issue",
value: draft.title,
placeholder: "Nightward #7",
hint: "Every word has to appear in a listing's title",
isWide: true,
maxLength: 120
)
SelectField(
name: "min-grade",
title: "Lowest grade",
options: [("", "Any grade")] + Grading.scale.map { ($0.grade.oneDecimal, "\($0.grade.oneDecimal) · \($0.code)") },
selected: draft.minGrade?.oneDecimal ?? ""
)
TextField(
name: "max-price",
title: "Most you'd pay (EUR)",
value: draft.maxPriceCents.map { "\($0 / 100)" } ?? "",
placeholder: "40"
)
label(.class("check")) {
input(
.type(.checkbox),
.name("slabbed"),
.value("1")
)
"Slabbed copies only"
}
}
div(.class("listing-form__submit")) {
a(
.class("button"),
.href(.Route.wants)
) {
"Cancel"
}
button(
.class("button button--primary"),
.type(.submit)
) {
"Add to want list"
}
}
}
}
/// A want's limits in a line, e.g. `grade ≥ 8.0 · max €40 · ships from EU only`.
static func limits(
of want: Want
) -> String {
[
want.slabbedOnly ? "slabbed only" : nil,
want.minGrade.map { "grade ≥ \($0.oneDecimal)" },
want.maxPriceCents.map { "max \($0.euros)" },
"ships from EU only",
]
.compactMap(\.self)
.joined(separator: " · ")
}
/// The search showing a want's matches.
static func search(
for want: Want
) -> String {
var parameters = SearchParameters().setting(.query, to: want.title.replacingOccurrences(of: "·", with: " "))
if let minGrade = want.minGrade {
parameters = parameters.setting(.gradeMin, to: minGrade.oneDecimal)
}
if let maxPriceCents = want.maxPriceCents {
parameters = parameters.setting(.priceMax, to: "\(maxPriceCents / 100)")
}
if want.slabbedOnly {
parameters = parameters.setting(.grading, to: SearchParameters.Grading.slabbed.rawValue)
}
return parameters.path(.Route.search)
}
}