import Elementary import Foundation import Infrastructure import Persistence /// The listing detail screen: one copy, its price and gated actions, its seller, and its declared details. struct ListingPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// The listing shown. let listing: Listing /// The number of other copies of the same title for sale: in this shop, and across the site. let otherCopies: (inShop: Int, everywhere: Int) /// The upcoming events the shop trades at, where the copy can be picked up. let pickups: [Event] /// Other listings from the same shop. let related: [Listing] /// The signed-in viewer, or `nil` when signed out. let viewer: Viewer? // MARK: Initializers /// Creates the detail screen of a listing. /// - Parameters: /// - listing: the listing shown. /// - related: other listings from the same shop. /// - pickups: the upcoming events the shop trades at; none by default. /// - otherCopies: the number of other copies of the same title for sale, in this shop and across the site; none by default. /// - 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( listing: Listing, related: [Listing], pickups: [Event] = [], otherCopies: (inShop: Int, everywhere: Int) = (0, 0), viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.viewer = viewer self.analytics = analytics self.assetVersion = assetVersion self.listing = listing self.locale = .init(identifier: "en") self.otherCopies = otherCopies self.pickups = pickups self.related = related } } // MARK: - Page extension ListingPage: Page { // MARK: Computed var content: some HTML { SiteLayout( active: listing.kind, viewer: viewer ) { div(.class("page listing")) { breadcrumb div(.class("listing__body")) { div(.class("listing__media")) { ListingImage(listing, modifier: "cover--hero") if listing.photos.count > 1 { ul(.class("listing__photos")) { ForEach(Array(listing.photos.dropFirst())) { photo in li { img( .class("photo"), .src(photo), .alt("Another photo of \(listing.title)"), .custom(name: "loading", value: "lazy") ) } } } } } div(.class("listing__main")) { div(.class("listing__intro")) { div(.class("listing__chips")) { Chip( listing.chip.label, tone: listing.chip.tone, isCertified: listing.chip.isCertified ) if let pageQuality = listing.pageQuality { Chip("\(pageQuality) pages") } Chip(listing.printing) } h1(.class("type-h1")) { listing.title } p(.class("listing__byline")) { "\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased()) · " a( .class("listing__series"), .href(.Route.series(listing.seriesSlug, issue: listing.issueNumber) + (listing.issueNumber == nil ? "" : "#copies")) ) { listing.issueNumber == nil ? "\(listing.seriesName) series" : "\(listing.seriesName) series · compare copies" } } } buyBox seller section(.class("listing__section")) { h2(.class("type-eyebrow")) { "Details" } dl(.class("details")) { for detail in details { dt { detail.term } dd { detail.value } } } } section(.class("listing__section")) { h2(.class("type-eyebrow")) { "Seller's notes" } p(.class("listing__notes")) { listing.sellerNotes } } } } if !related.isEmpty { section(.class("listing__related")) { div(.class("section-head")) { h2(.class("type-h2")) { "More from \(listing.shop.name)" } a( .class("link-ink"), .href(listing.shop.path) ) { "Visit the shop" } } div(.class("card-grid")) { for listing in related { ListingCard(listing, context: .shop) } } } } } } } var scripts: [any Asset] { [StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.listing] } var summary: String? { "\(listing.title), \(listing.chip.label), \(listing.priceCents.euros) from \(listing.shop.name) on CCN." } var title: String { "\(listing.title) · \(listing.chip.label) — CCN" } } // MARK: - Helpers private extension ListingPage { // MARK: Computed var breadcrumb: some HTML { nav( .class("breadcrumb"), .custom(name: "aria-label", value: "Breadcrumb") ) { a(.href(.Route.home)) { "Home" } if let format = listing.kind { span(.custom(name: "aria-hidden", value: "true")) { "/" } a(.href(format.path)) { format.label } } span(.custom(name: "aria-hidden", value: "true")) { "/" } a(.href( SearchParameters() .setting(.publisher, to: listing.publisher) .path(.Route.search) )) { listing.publisher } span(.custom(name: "aria-hidden", value: "true")) { "/" } span( .class("breadcrumb__current"), .custom(name: "aria-current", value: "page") ) { listing.title } } } /// The price and the actions. Every action is gated: signed out, each leads to the sign-in screen and back here. var buyBox: some HTML { div(.class("card buy-box")) { div(.class("buy-box__head")) { div(.class("buy-box__price")) { span(.class("type-price")) { listing.priceCents.euros } span(.class("type-body-sm buy-box__terms")) { "+ \(listing.shippingCents.euros) tracked shipping to NL" listing.acceptsOffers ? " · accepts offers" : "" if let event = pickups.first { " · " a( .class("listing__series"), .href(.Route.events) ) { "or pick up at \(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))" } } } } a( .class("button button--sm"), .href(.Route.newWant(listing: listing.slug)) ) { Icon(.bookmark, size: 16) "Save" } } if !listing.isAvailable { div(.class("notice listing__sold")) { p { strong { "This copy sold\(listing.soldAt.map { " on \($0.dayStamp)" } ?? "")." } " \(listing.shop.name) has \(otherCopies.inShop) more \(otherCopies.inShop == 1 ? "copy" : "copies") of \(listing.title), and \(otherCopies.everywhere) \(otherCopies.everywhere == 1 ? "is" : "are") listed across CCN." } div(.class("empty__actions")) { a( .class("button button--primary button--sm"), .href(SearchParameters().setting(.query, to: listing.title).setting(.shipsFrom, to: "anywhere").path(.Route.search)) ) { "Other copies" } a( .class("button button--outline button--sm"), .href(.Route.newWant(title: listing.title)) ) { Icon(.bell, size: 15) "Alert me" } } } } else if viewer?.shopSlug == listing.shop.slug { p(.class("notice")) { "This is your listing. Offers on it arrive in your " a(.href(.Route.sellerDashboard)) { "seller dashboard" } "." } } else { actions } } } /// The gated actions: buy, offer, message — and, signed out, why they lead to the sign-in. @HTMLBuilder var actions: some HTML { div(.class("buy-box__actions")) { a( .class("button button--primary button--lg"), .href(gated(.Route.buy(listing.slug))) ) { "Buy now" } if listing.acceptsOffers { a( .class("button button--outline button--lg"), .href(gated(.Route.offer(listing.slug))) ) { "Make an offer" } } a( .class("button button--bare button--lg"), .href(gated(.Route.message(listing: listing.slug))) ) { Icon(.message, size: 16) "Message seller" } } if viewer == nil { p(.class("gate-note")) { Icon(.lock, size: 16) span { "Buying, offers, saving and messages need a signed-in account. " a( .class("gate-note__link"), .href(.Route.signIn(returningTo: listing.path)) ) { "Sign in or join with a passkey" } " — browsing stays open to everyone." } } } } /// The declared details, leaving out what the listing does not state. var details: [(term: String, value: String)] { var grade = listing.chip.label if let certNumber = listing.certNumber { grade += " · cert \(certNumber)" } else if listing.grade != nil { grade += " · graded by the seller against the public grading guide" } return [ ("Title", listing.seriesTitle), ("Issue", listing.issueLabel), ("Publisher", listing.publisher), ("Printing", listing.printingDetail), (listing.grade == nil ? "Condition" : "Grade", grade), ("Page quality", listing.pageQuality), ("Declared defects", listing.defects.isEmpty ? nil : listing.defects.joined(separator: "; ")), ("Key notes", listing.keyNotes), ("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"), ].compactMap { term, value in value.map { (term, $0) } } } var seller: some HTML { div(.class("seller")) { span(.class("monogram monogram--lg")) { listing.shop.monogram } div(.class("seller__text")) { div(.class("seller__name")) { a(.href(listing.shop.path)) { listing.shop.name } if listing.shop.isVerified { span(.class("verified")) { Icon(.check, size: 12) "Verified shop" } } } span(.class("type-caption")) { listing.shop.standing } } a( .class("button button--outline button--sm"), .href(listing.shop.path) ) { "Visit shop" } } } // MARK: Methods /// The path a gated action on this listing leads to. func gated( _ path: String ) -> String { .Route.gated(path, from: listing.path, viewer: viewer) } }