Implemented phase 1 for the POC prototype in the Website service.
This commit is contained in:
1 parent
6d7ab4bcba
commit
ea89ea892a
37 files changed
+4173
-43
No files matched your search
@@ -0,0 +1,310 @@
|
||||
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]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the detail screen of a listing.
|
||||
/// - Parameters:
|
||||
/// - listing: the listing shown.
|
||||
/// - related: other listings from the same shop.
|
||||
/// - 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],
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
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) {
|
||||
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" : ""
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--sm"),
|
||||
.href(signIn)
|
||||
) {
|
||||
Icon(.bookmark, size: 16)
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
div(.class("buy-box__actions")) {
|
||||
a(
|
||||
.class("button button--primary button--lg"),
|
||||
.href(signIn)
|
||||
) {
|
||||
"Buy now"
|
||||
}
|
||||
if listing.acceptsOffers {
|
||||
a(
|
||||
.class("button button--outline button--lg"),
|
||||
.href(signIn)
|
||||
) {
|
||||
"Make an offer"
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--bare button--lg"),
|
||||
.href(signIn)
|
||||
) {
|
||||
Icon(.message, size: 16)
|
||||
"Message seller"
|
||||
}
|
||||
}
|
||||
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(signIn)
|
||||
) {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-in screen, returning to this listing.
|
||||
var signIn: String {
|
||||
.Route.signIn(returningTo: listing.path)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user