330 lines
11 KiB
Swift
330 lines
11 KiB
Swift
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
|
|
|
|
/// 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.
|
|
/// - 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],
|
|
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.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")) {
|
|
Cover(listing.cover, modifier: "cover--hero")
|
|
}
|
|
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())"
|
|
}
|
|
}
|
|
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 viewer == nil {
|
|
a(
|
|
.class("button button--sm"),
|
|
.href(.Route.signIn(returningTo: listing.path))
|
|
) {
|
|
Icon(.bookmark, size: 16)
|
|
"Save"
|
|
}
|
|
} else {
|
|
Unavailable("Save", modifier: "button--sm")
|
|
}
|
|
}
|
|
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),
|
|
("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)
|
|
}
|
|
|
|
}
|