2026-09-21 13:32:05 +02:00
|
|
|
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")) {
|
2026-09-21 13:53:13 +02:00
|
|
|
"No wants yet"
|
2026-09-21 13:32:05 +02:00
|
|
|
}
|
|
|
|
|
p(.class("type-body-sm")) {
|
2026-09-21 13:53:13 +02:00
|
|
|
"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."
|
2026-09-21 13:32:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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",
|
2026-10-25 12:41:55 +01:00
|
|
|
options: [("", "Any grade")] + Grading.scale.map { ($0.grade.oneDecimal, "\($0.grade.oneDecimal) · \($0.code)") },
|
|
|
|
|
selected: draft.minGrade?.oneDecimal ?? ""
|
2026-09-21 13:32:05 +02:00
|
|
|
)
|
|
|
|
|
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,
|
2026-10-25 12:41:55 +01:00
|
|
|
want.minGrade.map { "grade ≥ \($0.oneDecimal)" },
|
2026-09-21 13:32:05 +02:00
|
|
|
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 {
|
2026-10-25 12:41:55 +01:00
|
|
|
parameters = parameters.setting(.gradeMin, to: minGrade.oneDecimal)
|
2026-09-21 13:32:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|