Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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")) {
|
||||
"Nothing on your list yet"
|
||||
}
|
||||
p(.class("type-body-sm")) {
|
||||
"Add the issue you're missing, and CCN checks it against every listing published."
|
||||
}
|
||||
}
|
||||
}
|
||||
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.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") },
|
||||
selected: draft.minGrade?.gradeLabel ?? ""
|
||||
)
|
||||
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.gradeLabel)" },
|
||||
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.gradeLabel)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user