Files
ccn/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift
T

366 lines
13 KiB
Swift
Raw Normal View History

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")) {
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" : ""
}
}
a(
.class("button button--sm"),
.href(.Route.newWant(listing: listing.slug))
) {
Icon(.bookmark, size: 16)
"Save"
}
}
if !listing.isAvailable {
p(.class("notice")) {
"This copy is sold. A sale was agreed on CCN."
}
} 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)
}
}