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) } }