2026-09-21 11:16:52 +02:00
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
2026-09-21 13:53:13 +02:00
/// 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 ]
2026-09-21 11:16:52 +02:00
/// Other listings from the same shop.
let related : [ Listing ]
2026-09-21 11:44:58 +02:00
/// The signed-in viewer, or `nil` when signed out.
let viewer : Viewer ?
2026-09-21 11:16:52 +02:00
// MARK: Initializers
/// Creates the detail screen of a listing.
/// - Parameters:
/// - listing: the listing shown.
/// - related: other listings from the same shop.
2026-09-21 13:53:13 +02:00
/// - 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.
2026-09-21 11:44:58 +02:00
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
2026-09-21 11:16:52 +02:00
/// - 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 ],
2026-09-21 13:53:13 +02:00
pickups : [ Event ] = [],
otherCopies : ( inShop : Int , everywhere : Int ) = ( 0 , 0 ),
2026-09-21 11:44:58 +02:00
viewer : Viewer ? = nil ,
2026-09-21 11:16:52 +02:00
assetVersion : String ? = nil ,
analytics : Analytics ? = nil
) {
2026-09-21 11:44:58 +02:00
self . viewer = viewer
2026-09-21 11:16:52 +02:00
self . analytics = analytics
self . assetVersion = assetVersion
self . listing = listing
self . locale = . init ( identifier : "en" )
2026-09-21 13:53:13 +02:00
self . otherCopies = otherCopies
self . pickups = pickups
2026-09-21 11:16:52 +02:00
self . related = related
}
}
// MARK: - Page
extension ListingPage : Page {
// MARK: Computed
var content : some HTML {
2026-09-21 11:44:58 +02:00
SiteLayout (
active : listing . kind ,
viewer : viewer
) {
2026-09-21 11:16:52 +02:00
div (. class ( "page listing" )) {
breadcrumb
div (. class ( "listing__body" )) {
div (. class ( "listing__media" )) {
2026-09-21 12:55:35 +02:00
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" )
)
}
}
}
}
2026-09-21 11:16:52 +02:00
}
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" )) {
2026-09-21 13:32:05 +02:00
" \( 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"
}
2026-09-21 11:16:52 +02:00
}
}
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" : ""
2026-09-21 13:53:13 +02:00
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 )) "
}
}
2026-09-21 11:16:52 +02:00
}
}
2026-09-21 13:32:05 +02:00
a (
. class ( "button button--sm" ),
. href (. Route . newWant ( listing : listing . slug ))
) {
Icon (. bookmark , size : 16 )
"Save"
2026-09-21 11:16:52 +02:00
}
}
2026-09-21 12:11:25 +02:00
if ! listing . isAvailable {
2026-09-21 13:53:13 +02:00
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"
}
}
2026-09-21 11:16:52 +02:00
}
2026-09-21 12:11:25 +02:00
} 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"
2026-09-21 11:16:52 +02:00
}
2026-09-21 12:11:25 +02:00
"."
2026-09-21 11:16:52 +02:00
}
2026-09-21 12:11:25 +02:00
} 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 {
2026-09-21 11:16:52 +02:00
a (
2026-09-21 12:11:25 +02:00
. class ( "button button--outline button--lg" ),
. href ( gated (. Route . offer ( listing . slug )))
2026-09-21 11:16:52 +02:00
) {
2026-09-21 12:11:25 +02:00
"Make an offer"
2026-09-21 11:16:52 +02:00
}
}
2026-09-21 12:11:25 +02:00
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"
2026-09-21 11:16:52 +02:00
}
2026-09-21 12:11:25 +02:00
" — browsing stays open to everyone."
2026-09-21 11:16:52 +02:00
}
}
}
}
/// 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 ),
2026-09-21 12:55:35 +02:00
( "Declared defects" , listing . defects . isEmpty ? nil : listing . defects . joined ( separator : "; " )),
2026-09-21 11:16:52 +02:00
( "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"
}
}
}
2026-09-21 11:44:58 +02:00
// 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 )
2026-09-21 11:16:52 +02:00
}
}