import Elementary import Foundation import Infrastructure import Persistence /// A series: its issues with what is listed of each — and what is not, one tap from an alert — the copies of one issue, and its collected editions. struct SeriesPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// The issue whose copies are listed, or `nil` for none. let issue: Int? /// Every listing of the series still for sale. let listings: [Listing] /// The issues the viewer owns of the series, or `nil` signed out. let owned: Set? /// The series shown. let series: Series /// The signed-in viewer, or `nil` when signed out. let viewer: Viewer? // MARK: Initializers /// Creates a series' screen. /// - Parameters: /// - series: the series shown. /// - listings: every listing of the series still for sale. /// - issue: the issue whose copies are listed, or `nil` (the default) for none. /// - owned: the issues the viewer owns of the series, or `nil` (the default) signed out. /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. /// - 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( series: Series, listings: [Listing], issue: Int? = nil, owned: Set? = nil, viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.issue = issue self.listings = listings self.locale = .init(identifier: "en") self.owned = owned self.series = series self.viewer = viewer } } // MARK: - Page extension SeriesPage: Page { // MARK: Constants /// The most copies compared side by side. static let maxCompared = 3 // MARK: Computed var content: some HTML { SiteLayout(viewer: viewer) { div(.class("page series")) { nav( .class("breadcrumb"), .custom(name: "aria-label", value: "Breadcrumb") ) { a(.href(.Route.home)) { "Home" } span(.custom(name: "aria-hidden", value: "true")) { "/" } a(.href(.Route.series)) { "Series" } span(.custom(name: "aria-hidden", value: "true")) { "/" } span( .class("breadcrumb__current"), .custom(name: "aria-current", value: "page") ) { series.name } } section(.class("series__head")) { if let cover = listings.first(where: { $0.issueNumber != nil }) ?? listings.first { ListingImage(cover, modifier: "cover--series") } div(.class("series__intro")) { p(.class("type-eyebrow")) { "Series · \(series.publisher) · \(series.years)\(series.issueCount > 0 ? " · \(series.issueCount) issues" : "")" } h1(.class("type-h1")) { series.name } p(.class("type-lead")) { "\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now." } if let owned, series.issueCount > 0 { div(.class("card series__collection")) { p(.class("type-eyebrow")) { "Your collection" } p(.class("type-panel")) { "\(owned.count) of \(series.issueCount) owned" } p(.class("type-small")) { "\(gaps.count) missing · \(gaps.filter { !$0.copies.isEmpty }.count) of them listed now" } } } ul(.class("series__keys")) { ForEach(keys) { key in li(.class("chip chip--warn")) { "#\(key.issue) · \(key.notes)" } } } } } if !issues.isEmpty { section(.class("series__section")) { div(.class("section-head")) { h2(.class("type-h2")) { series.issueCount > 0 ? "All \(series.issueCount) issues" : "Issues listed" } span(.class("type-small")) { "Cheapest listed copy per issue" } } ol(.class("series__issues")) { ForEach(issues) { entry in li(.class(issueClass(entry))) { span(.class("series__number")) { "#\(entry.number)" } if let cheapest = entry.copies.min(by: { $0.priceCents < $1.priceCents }) { a(.href(.Route.series(series.slug, issue: entry.number) + "#copies")) { "\(entry.copies.count) listed · from \(cheapest.priceCents.euros)" } .attributes(.custom(name: "aria-current", value: "true"), when: entry.number == issue) } else { a(.href(.Route.gated( .Route.newWant(title: "\(series.name) #\(entry.number)"), from: .Route.series(series.slug), viewer: viewer ))) { "None listed · alert me" } } if let owned { form( .action("\(String.Route.series(series.slug))/own"), .method(.post) ) { input( .type(.hidden), .name("issue"), .value("\(entry.number)") ) button( .class("series__own"), .type(.submit), .custom(name: "aria-pressed", value: owned.contains(entry.number) ? "true" : "false") ) { owned.contains(entry.number) ? "Owned ✓" : "I own this" } } } } } } } } if let issue, !copies.isEmpty { section( .class("series__section"), .id("copies") ) { h2(.class("type-h2")) { "Copies of #\(issue) — \(copies.count) listed" } // A plain `GET` form: the copies ticked become the comparison's query. form( .class("series__copies"), .action(.Route.compare), .method(.get) ) { div(.class("table-scroll")) { table(.class("table")) { thead { tr { th { span(.class("visually-hidden")) { "Compare" } } ForEach(["Grade", "Printing", "Pages", "Defects", "Price", "Ship to NL", "Shop", ""]) { heading in th { heading } } } } tbody { ForEach(copies) { copy in tr { td { input( .type(.checkbox), .name("copy"), .value(copy.slug), .custom(name: "aria-label", value: "Compare the \(copy.chip.label) from \(copy.shop.name)") ) } td { Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified) } td { copy.printing } td { copy.pageQuality ?? "—" } td { copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ") } td { strong { copy.priceCents.euros } } td { "\(copy.shippingCents.euros) · \(copy.shop.country)" } td { a(.href(copy.shop.path)) { copy.shop.name } } td { a( .class("link-ink"), .href(copy.path) ) { "View" } } } } } } } button( .class("button button--dark"), .type(.submit) ) { "Compare the copies ticked (up to \(Self.maxCompared))" } } } } if !collected.isEmpty { section(.class("series__section")) { h2(.class("type-h2")) { "Collected editions" } div(.class("card-grid")) { ForEach(collected) { listing in ListingCard(listing) } } } } } } } var scripts: [any Asset] { [StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.collecting] } var summary: String? { "\(series.name), \(series.publisher), \(series.years): every issue, the copies listed of each, and the gaps." } var title: String { "\(series.name) — CCN" } } // MARK: - Helpers private extension SeriesPage { /// The issues of the run the viewer does not own. var gaps: [(number: Int, copies: [Listing])] { issues.filter { !(owned ?? []).contains($0.number) } } /// An issue's tile: a gap reads quieter, and an owned issue is dimmed. func issueClass( _ entry: (number: Int, copies: [Listing]) ) -> String { [ "series__issue", entry.copies.isEmpty ? "series__issue--gap" : nil, owned?.contains(entry.number) == true ? "series__issue--owned" : nil, ] .compactMap(\.self) .joined(separator: " ") } /// The series' manga, trade paperbacks, and hardcovers. var collected: [Listing] { listings.filter { $0.issueNumber == nil } } /// The copies of the issue picked, cheapest delivered first. var copies: [Listing] { listings .filter { $0.issueNumber == issue } .sorted { $0.priceCents + $0.shippingCents < $1.priceCents + $1.shippingCents } } /// Every issue of the run with the copies listed of it; for a series without a known run, the issues listed alone. var issues: [(number: Int, copies: [Listing])] { let listed = Dictionary(grouping: listings.filter { $0.issueNumber != nil }) { $0.issueNumber ?? 0 } let numbers = series.issueCount > 0 ? Array(1...series.issueCount) : listed.keys.sorted() return numbers.map { ($0, listed[$0] ?? []) } } /// The key issues, as the listings of them say. var keys: [(issue: Int, notes: String)] { var seen = Set() return listings .compactMap { listing in guard let issue = listing.issueNumber, let notes = listing.keyNotes, seen.insert(issue).inserted else { return nil } return (issue, notes) } .sorted { $0.issue < $1.issue } } }