Implemented phase 1 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 11:16:52 +02:00
1 parent 6d7ab4bcba
commit ea89ea892a
37 files changed
+4173 -43

No files matched your search

@@ -0,0 +1,62 @@
import Elementary
/// A chip stating a grade, condition, or status.
struct Chip: HTML {
// MARK: Properties
/// Whether the chip leads with a check mark, for a grade a grading company certified.
let isCertified: Bool
/// The chip's text.
let label: String
/// The chip's colouring.
let tone: Tone
// MARK: Initializers
/// Creates a chip.
/// - Parameters:
/// - label: the chip's text.
/// - tone: the chip's colouring; neutral by default.
/// - isCertified: whether the chip leads with a check mark; `false` by default.
init(
_ label: String,
tone: Tone = .neutral,
isCertified: Bool = false
) {
self.isCertified = isCertified
self.label = label
self.tone = tone
}
// MARK: Computed
var body: some HTML {
span(.class(tone.className)) {
if isCertified {
Icon(.check, size: 10)
}
label
}
}
}
// MARK: - Enumerations
extension Chip {
/// A chip's colouring.
enum Tone: String, Sendable {
case bad
case good
case neutral
case warn
/// The chip's classes for the tone.
var className: String {
self == .neutral ? "chip" : "chip chip--\(rawValue)"
}
}
}
@@ -0,0 +1,60 @@
import Elementary
import Persistence
/// A placeholder cover: lettering on a toned ground, scaling with its width. Stands in until listings carry photos.
struct Cover: HTML {
// MARK: Properties
/// The cover's lettering and tone.
let cover: Listing.Cover
/// A modifier class positioning or sizing the cover, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a placeholder cover.
/// - Parameters:
/// - cover: the cover's lettering and tone.
/// - modifier: a modifier class positioning or sizing the cover, or `nil` (the default) for none.
init(
_ cover: Listing.Cover,
modifier: String? = nil
) {
self.cover = cover
self.modifier = modifier
}
// MARK: Computed
/// Decorative: whatever links to the listing names it.
var body: some HTML {
div(
.class(["cover", "cover--\(cover.tone)", modifier].compactMap(\.self).joined(separator: " ")),
.custom(
name: "aria-hidden",
value: "true"
)
) {
div(.class("cover__face")) {
div(.class("cover__title")) {
for line in cover.title.split(separator: "\n") {
span {
String(line)
}
}
}
div(.class("cover__foot")) {
span(.class("cover__imprint")) {
cover.imprint
}
span(.class(cover.number.allSatisfy(\.isNumber) ? "cover__number" : "cover__number cover__number--text")) {
cover.number
}
}
}
}
}
}
@@ -11,18 +11,24 @@ struct Icon: HTML {
/// The icon's rendered width and height, in pixels.
let size: Int
/// The stroke width, or `nil` for the glyph's own.
let weight: String?
// MARK: Initializers
/// Creates an icon.
/// - Parameters:
/// - glyph: the glyph the icon draws.
/// - size: the icon's rendered width and height, in pixels; 20 by default.
/// - weight: the stroke width, or `nil` (the default) for the glyph's own.
init(
_ glyph: Glyph,
size: Int = 20
size: Int = 20,
weight: String? = nil
) {
self.glyph = glyph
self.size = size
self.weight = weight
}
// MARK: Computed
@@ -32,7 +38,7 @@ struct Icon: HTML {
HTMLRaw(
"""
<svg class="icon" width="\(size)" height="\(size)" viewBox="0 0 24 24" fill="none" stroke="currentColor" \
stroke-width="\(glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
stroke-width="\(weight ?? glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
"""
)
}
@@ -50,10 +56,14 @@ extension Icon {
case bookmark
/// A check mark.
case check
/// A page of text.
case document
/// Filter lines.
case filter
/// A key.
case key
/// A padlock.
case lock
/// A speech bubble.
case message
/// A magnifying glass.
@@ -75,8 +85,10 @@ extension Icon.Glyph {
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
case .search: #"<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path>"#
case .shop: #"<path d="M3 9.5 12 4l9 5.5"></path><path d="M5 10v9h14v-9"></path><path d="M10 19v-5h4v5"></path>"#
@@ -0,0 +1,108 @@
import Elementary
import Persistence
/// A listing in a card grid: its cover, title, price, grade chip, and — depending on the ``Context`` — the shop selling it.
struct ListingCard: HTML {
// MARK: Properties
/// Where the card is shown.
let context: Context
/// The listing the card shows.
let listing: Listing
// MARK: Initializers
/// Creates a listing card.
/// - Parameters:
/// - listing: the listing the card shows.
/// - context: where the card is shown; a browsing row by default.
init(
_ listing: Listing,
context: Context = .browse
) {
self.context = context
self.listing = listing
}
// MARK: Computed
var body: some HTML {
article(.class("listing-card")) {
// Saving is a gated action: signed out, it leads to the sign-in screen.
a(
.class("listing-card__save"),
.href(.Route.signIn(returningTo: listing.path)),
.custom(
name: "aria-label",
value: "Save \(listing.title) to watchlist"
)
) {
Icon(.bookmark, size: 18)
}
a(
.class("listing-card__cover"),
.href(listing.path),
.custom(
name: "tabindex",
value: "-1"
),
.custom(
name: "aria-hidden",
value: "true"
)
) {
Cover(listing.cover)
}
div {
h3(.class("type-card-title")) {
a(
.class("listing-card__title"),
.href(listing.path)
) {
listing.title
}
}
p(.class("listing-card__meta")) {
listing.summary
}
}
div(.class("listing-card__row")) {
span(.class("type-price-card")) {
listing.priceCents.euros
}
Chip(
listing.chip.label,
tone: listing.chip.tone,
isCertified: listing.chip.isCertified
)
}
if context != .shop {
a(
.class("listing-card__shop"),
.href(listing.shop.path)
) {
context == .search
? "\(listing.shop.name) · \(listing.shop.country) · ship \(listing.shippingCents.euros)"
: listing.shop.name
}
}
}
}
}
// MARK: - Enumerations
extension ListingCard {
/// Where a listing card is shown.
enum Context: Sendable {
/// A browsing row: the shop is named.
case browse
/// Search results: the shop is named with its country and shipping cost.
case search
/// The shop's own screen: the shop goes unnamed.
case shop
}
}
@@ -0,0 +1,107 @@
import Elementary
/// The page links under a card grid; renders nothing for a single page.
struct Pagination: HTML {
// MARK: Properties
/// The page shown, counted from one.
let current: Int
/// The path of a given page.
let path: @Sendable (Int) -> String
/// The number of pages.
let total: Int
// MARK: Initializers
/// Creates the page links.
/// - Parameters:
/// - current: the page shown, counted from one.
/// - total: the number of pages.
/// - path: the path of a given page.
init(
current: Int,
total: Int,
path: @escaping @Sendable (Int) -> String
) {
self.current = current
self.path = path
self.total = total
}
// MARK: Computed
var body: some HTML {
if total > 1 {
nav(
.class("pagination"),
.custom(
name: "aria-label",
value: "Pagination"
)
) {
for page in 1...total {
if page == current {
a(
.class("pagination__page"),
.href(path(page)),
.custom(
name: "aria-current",
value: "page"
)
) {
"\(page)"
}
} else {
a(
.class("pagination__page"),
.href(path(page))
) {
"\(page)"
}
}
}
if current < total {
a(
.class("pagination__next"),
.href(path(current + 1))
) {
"Next"
}
}
}
}
}
}
// MARK: - Extensions
extension Pagination {
// MARK: Methods
/// Cuts the items of one page out of a list, clamping the page to the list's range.
/// - Parameters:
/// - items: the whole list.
/// - page: the page asked for, counted from one.
/// - size: the number of items on a page.
/// - Returns: the page's items, the page actually shown, and the number of pages.
static func slice<Item>(
_ items: [Item],
page: Int,
size: Int
) -> (items: [Item], current: Int, total: Int) {
let total = max((items.count + size - 1) / size, 1)
let current = min(max(page, 1), total)
return (
Array(items.dropFirst((current - 1) * size).prefix(size)),
current,
total
)
}
}
@@ -10,6 +10,9 @@ struct SiteHeader: HTML {
/// The format marked as current in the navigation, or `nil` for none.
let active: Format?
/// The text the search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out actions.
let viewer: Viewer?
@@ -18,12 +21,15 @@ struct SiteHeader: HTML {
/// Creates a site header.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil
) {
self.active = active
self.query = query
self.viewer = viewer
}
@@ -159,6 +165,7 @@ private extension SiteHeader {
.id("site-search-query"),
.type(.search),
.name("q"),
.value(query ?? ""),
.placeholder("Search title, issue, grade or shop")
)
button(
@@ -11,6 +11,9 @@ struct SiteLayout<Content: HTML>: HTML {
/// The screen's markup, rendered inside the `main` landmark.
let content: Content
/// The text the header's search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out header.
let viewer: Viewer?
@@ -19,15 +22,18 @@ struct SiteLayout<Content: HTML>: HTML {
/// Creates a site layout around a screen's content.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the header's search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header.
/// - content: the screen's markup.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil,
@HTMLBuilder content: () -> Content
) {
self.active = active
self.content = content()
self.query = query
self.viewer = viewer
}
@@ -42,6 +48,7 @@ struct SiteLayout<Content: HTML>: HTML {
}
SiteHeader(
active: active,
query: query,
viewer: viewer
)
main(.id("main")) {
@@ -0,0 +1,63 @@
import Elementary
import Persistence
/// The sort order select. It submits its form on change when scripts run; the form's own button covers the rest.
struct SortSelect: HTML {
// MARK: Properties
/// The identifier of the form the select belongs to.
let form: String
/// The order currently applied.
let selected: ListingFilter.Sort
// MARK: Computed
var body: some HTML {
div(.class("sort")) {
label(
.class("sort__label"),
.for("\(form)-sort")
) {
"Sort"
}
select(
.class("select"),
.id("\(form)-sort"),
.name(SearchParameters.Name.sort.rawValue),
.form(form),
.custom(
name: "data-autosubmit",
value: ""
)
) {
for sort in ListingFilter.Sort.allCases {
option(.value(sort.rawValue)) {
sort.label
}
.attributes(.selected, when: sort == selected)
}
}
}
}
}
// MARK: - Extensions
extension ListingFilter.Sort {
// MARK: Computed
/// The order's name in the select.
var label: String {
switch self {
case .newest: "Newly listed"
case .priceAscending: "Price: low to high"
case .priceDescending: "Price: high to low"
case .gradeDescending: "Grade: high to low"
}
}
}
@@ -26,6 +26,26 @@ extension Format {
}
}
/// The format's name for one item.
var singular: String {
switch self {
case .singleIssues: "Single issue"
case .manga: "Manga"
case .tradePaperbacks: "Trade paperback"
case .hardcovers: "Hardcover"
}
}
/// What the format covers, in a sentence.
var summary: String {
switch self {
case .singleIssues: "Raw and slabbed, all eras. Filter by grade, print and key status."
case .manga: "Tankōbon volumes, box sets and first English printings."
case .tradePaperbacks: "Collected runs and one-shots, with printing noted on every copy."
case .hardcovers: "Omnibuses, deluxe editions and sealed copies."
}
}
/// The path of the search screen filtered to the format.
var path: String {
"\(String.Route.search)?format=\(rawValue)"
@@ -0,0 +1,14 @@
import Foundation
extension Int {
// MARK: Computed
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
var euros: String {
isMultiple(of: 100)
? "€\(self / 100)"
: String(format: "€%d.%02d", self / 100, self % 100)
}
}
@@ -0,0 +1,89 @@
import Foundation
import Persistence
extension Listing {
// MARK: Computed
/// The chip stating the copy's grade or condition.
///
/// A slab reads `CGC 9.8` — good from 9.0 up, a warning below 7.0 — a raw issue `Raw · NM`, and a collected edition its condition.
var chip: (label: String, tone: Chip.Tone, isCertified: Bool) {
guard let grader, let grade else {
return (
kind == .singleIssues ? "Raw · \(condition)" : condition,
.neutral,
false
)
}
return (
"\(grader) \(grade.gradeLabel)",
grade >= 9 ? .good : grade < 7 ? .warn : .neutral,
true
)
}
/// The format the listing is published in, or `nil` when its token names none.
var kind: Format? {
.init(rawValue: format)
}
/// The path of the listing's detail screen.
var path: String {
.Route.listing(slug)
}
/// The line under a card's title: the publisher, year, and printing, led by the format for a collected edition.
var summary: String {
let lead = kind == .singleIssues ? nil : kind?.singular
return [lead, publisher, String(year), printing]
.compactMap(\.self)
.joined(separator: " · ")
}
}
extension Shop {
// MARK: Computed
/// The shop's location, e.g. `Amsterdam, NL`.
var location: String {
"\(city), \(country)"
}
/// The uppercased initials of the first two words of the shop's name that start with a letter.
var monogram: String {
name
.split(separator: " ")
.filter { $0.first?.isLetter == true }
.prefix(2)
.compactMap(\.first)
.map { String($0).uppercased() }
.joined()
}
/// The path of the shop's public screen.
var path: String {
.Route.shop(slug)
}
/// The shop's standing in a line: where it is, since when, and how it is rated.
var standing: String {
"\(location) · selling on CCN since \(sinceYear) · \(String(format: "%.1f", rating)) from \(ratedSales) rated sales"
}
}
extension Double {
// MARK: Computed
/// The grade to one decimal place, e.g. `9.8`.
var gradeLabel: String {
String(format: "%.1f", self)
}
}
@@ -1,3 +1,5 @@
import Foundation
extension String {
/// A namespace for the paths of the site's screens.
///
@@ -35,5 +37,36 @@ extension String {
static let terms = "/terms"
/// The want list.
static let wants = "/wants"
/// The path of a listing's detail screen.
/// - Parameter slug: the listing's URL slug.
static func listing(
_ slug: String
) -> String {
"/listing/\(slug)"
}
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
_ slug: String
) -> String {
"/shop/\(slug)"
}
/// The path of the sign-in screen, returning to the given path afterwards.
/// - Parameter path: the path the visitor's gated action started from.
static func signIn(
returningTo path: String
) -> String {
"\(signIn)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")"
}
}
}
// MARK: - Constants
private extension CharacterSet {
/// The characters a query value carries unescaped.
static let urlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: "&=+?#"))
}
@@ -2,6 +2,7 @@ import Elementary
import Foundation
import Infrastructure
import Localization
import Persistence
/// The website's landing page, with its text localized to a given locale.
struct IndexPage {
@@ -14,6 +15,9 @@ struct IndexPage {
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The listings the page shows in its rows.
let listings: HomeListings
/// The locale the page content is localized to.
let locale: Locale
@@ -31,14 +35,17 @@ struct IndexPage {
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
/// - listings: the listings the page shows in its rows; none by default.
init(
locale: Locale,
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = listings
self.locale = locale
self.siteOrigin = siteOrigin
self.localize = .init(bundle: .module)
@@ -82,32 +89,85 @@ extension IndexPage: Page {
// MARK: Properties
/// The hero alone, until the listings exist.
var content: some HTML {
SiteLayout {
div(.class("page")) {
div(.class("page home")) {
section(.class("hero")) {
p(.class("type-eyebrow")) {
"Single issues · Manga · Trades · Hardcovers"
}
h1(.class("type-hero")) {
localize("index.heading", locale: locale)
}
p(.class("type-lead")) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div(.class("hero__actions")) {
a(
.class("button button--primary button--lg"),
.href(Format.singleIssues.path)
) {
"Browse single issues"
div(.class("hero__text")) {
p(.class("type-eyebrow")) {
"Single issues · Manga · Trades · Hardcovers"
}
h1(.class("type-hero")) {
localize("index.heading", locale: locale)
}
p(.class("type-lead")) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div(.class("hero__actions")) {
a(
.class("button button--primary button--lg"),
.href(Format.singleIssues.path)
) {
"Browse single issues"
}
a(
.class("button button--outline button--lg"),
.href(.Route.series)
) {
"Browse by series"
}
}
}
if listings.singleIssues.count >= 3 {
div(.class("hero__covers")) {
Cover(listings.singleIssues[1].cover, modifier: "cover--left")
Cover(listings.singleIssues[0].cover, modifier: "cover--centre")
Cover(listings.singleIssues[2].cover, modifier: "cover--right")
}
}
}
nav(
.class("formats"),
.custom(name: "aria-label", value: "Browse by format")
) {
for format in Format.allCases {
a(
.class("button button--outline button--lg"),
.href(.Route.series)
.class("formats__tile"),
.href(format.path)
) {
"Browse by series"
span(.class("formats__name")) {
format.label
}
span(.class("type-caption")) {
format.summary
}
}
}
}
row(
"New in single issues",
more: "All single issues",
path: Format.singleIssues.path,
listings: listings.singleIssues
)
row(
"Manga, trades and hardcovers",
more: "All collected editions",
path: SearchParameters(items: Format.allCases.dropFirst().map {
.init(name: SearchParameters.Name.format.rawValue, value: $0.rawValue)
}).path(.Route.search),
listings: listings.collected
)
section(.class("promises")) {
for promise in Self.promises {
div(.class("promises__item")) {
Icon(promise.glyph, size: 28, weight: "1.8")
h3(.class("promises__title")) {
promise.title
}
p(.class("type-body-sm promises__text")) {
promise.text
}
}
}
}
@@ -128,3 +188,50 @@ extension IndexPage: Page {
}
}
// MARK: - Helpers
private extension IndexPage {
// MARK: Constants
/// What the marketplace promises, under the listing rows.
static let promises: [(glyph: Icon.Glyph, title: String, text: String)] = [
(.key, "Passkeys, not passwords", "Sign up and sign in with Face ID, a fingerprint or a hardware key. Nothing to phish, nothing to reuse, nothing to reset."),
(.document, "Grade stated up front", "Every listing declares slab or raw, grader and grade, printing and defects, with photos of the actual copy front and back."),
(.shop, "Shops are open to everyone", "Anyone can browse a seller's inventory without an account. Buying, offers and messages need a signed-in account on both sides."),
]
// MARK: Methods
/// A titled row of listing cards; renders nothing without listings.
@HTMLBuilder
func row(
_ title: String,
more: String,
path: String,
listings: [Listing]
) -> some HTML {
if !listings.isEmpty {
section(.class("home__row")) {
div(.class("section-head")) {
h2(.class("type-h2")) {
title
}
a(
.class("link-ink"),
.href(path)
) {
more
}
}
div(.class("card-grid")) {
for listing in listings {
ListingCard(listing)
}
}
}
}
}
}
@@ -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)
}
}
@@ -0,0 +1,352 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The search results screen: the matching listings beside the filter rail.
struct SearchPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The page of listings shown.
let listings: [Listing]
/// The number of listings matching the search, across every page.
let matchCount: Int
/// The page shown and the number of pages.
let pages: (current: Int, total: Int)
/// The search the screen answers.
let parameters: SearchParameters
/// Every publisher a listing can be filtered by.
let publishers: [String]
/// The number of shops the matching listings come from.
let shopCount: Int
// MARK: Initializers
/// Creates the search results screen for a search and the listings matching it.
/// - Parameters:
/// - parameters: the search the screen answers.
/// - matches: every listing matching the search; the screen shows the page the parameters ask for.
/// - publishers: every publisher a listing can be filtered by.
/// - 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(
parameters: SearchParameters,
matches: [Listing],
publishers: [String],
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
let slice = Pagination.slice(
matches,
page: parameters.page,
size: SearchParameters.pageSize
)
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = slice.items
self.locale = .init(identifier: "en")
self.matchCount = matches.count
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.publishers = publishers
self.shopCount = Set(matches.map(\.shop.slug)).count
}
}
// MARK: - Page
extension SearchPage: Page {
// MARK: Constants
/// The path the page is served at.
static let path = String.Route.search
/// The identifier tying the sort select to the filter form.
private static let formID = "filters"
// MARK: Computed
var content: some HTML {
SiteLayout(
active: formats.count == 1 ? formats.first : nil,
query: parameters.value(.query)
) {
div(.class("page search")) {
div(.class("search__head")) {
div(.class("search__heading")) {
p(.class("type-eyebrow")) {
formats.isEmpty ? "All formats" : formats.map(\.label).joined(separator: " · ")
}
h1(.class("type-h1")) {
heading
}
p(.class("type-body-sm search__count")) {
matchCount == 1 ? "1 listing" : "\(matchCount) listings"
shopCount == 1 ? " from 1 shop" : " from \(shopCount) shops"
}
}
SortSelect(
form: Self.formID,
selected: parameters.filter.sort
)
}
div(.class("search__body")) {
filters
div(.class("search__results")) {
if !parameters.pills.isEmpty {
ul(.class("search__pills")) {
for pill in parameters.pills {
li {
a(
.class("pill pill--active"),
.href(pill.removed.path(Self.path)),
.custom(
name: "aria-label",
value: "Remove filter: \(pill.label)"
)
) {
pill.label
span(.custom(name: "aria-hidden", value: "true")) {
" ×"
}
}
}
}
}
}
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"No listings match"
}
p(.class("type-body-sm")) {
"Try fewer filters, or let listings ship from anywhere."
}
a(
.class("button button--outline"),
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
) {
"Clear all filters"
}
}
} else {
div(.class("card-grid card-grid--search")) {
for listing in listings {
ListingCard(listing, context: .search)
}
}
}
Pagination(
current: pages.current,
total: pages.total
) { [parameters] in
parameters.setting(.page, to: "\($0)").path(Self.path)
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.search]
}
var title: String {
"\(heading) — CCN"
}
}
// MARK: - Helpers
private extension SearchPage {
// MARK: Computed
/// The filter rail: a plain `GET` form inside a disclosure, which the stylesheet keeps open on desktop.
var filters: some HTML {
details(.class("filters")) {
Elementary.summary(.class("filters__toggle button button--outline")) {
Icon(.filter, size: 15)
parameters.pills.isEmpty ? "Filters" : "Filters · \(parameters.pills.count)"
}
form(
.class("filters__form"),
.id(Self.formID),
.action(Self.path),
.method(.get)
) {
div(.class("filters__head")) {
h2(.class("type-eyebrow filters__title")) {
"Filters"
}
a(
.class("filters__clear"),
.href(Self.path)
) {
"Clear all"
}
}
if let query = parameters.value(.query) {
input(
.type(.hidden),
.name(SearchParameters.Name.query.rawValue),
.value(query)
)
}
group("Format") {
ForEach(Format.allCases) { format in
checkbox(.format, value: format.rawValue, label: format.label)
}
}
group("Grading") {
checkbox(.grading, value: SearchParameters.Grading.slabbed.rawValue, label: "Slabbed (CGC, CBCS, PGX)")
checkbox(.grading, value: SearchParameters.Grading.raw.rawValue, label: "Raw, seller-graded")
div(.class("filters__range")) {
field(.gradeMin, label: "Min", placeholder: "0.5", isLabelVisible: true)
field(.gradeMax, label: "Max", placeholder: "10", isLabelVisible: true)
}
}
group("Printing") {
checkbox(.firstPrint, value: "1", label: "1st print only")
}
group("Publisher") {
ForEach(publishers) { publisher in
checkbox(.publisher, value: publisher, label: publisher)
}
}
group("Price") {
div(.class("filters__range")) {
field(.priceMin, label: "Minimum price", placeholder: "€ min")
span(.class("type-small")) {
"to"
}
field(.priceMax, label: "Maximum price", placeholder: "€ max")
}
}
group("Ships from") {
div(.class("segmented")) {
for region in ListingFilter.Region.allCases {
label(.class("segmented__option")) {
input(
.type(.radio),
.name(SearchParameters.Name.shipsFrom.rawValue),
.value(region.rawValue)
)
.attributes(.checked, when: region == parameters.filter.shipsFrom)
span {
region == .anywhere ? "Anywhere" : region.rawValue.uppercased()
}
}
}
}
span(.class("type-small")) {
"Outside the EU, import VAT may apply."
}
}
group("Key issues") {
checkbox(.keys, value: "1", label: "Key issues only")
}
group("Seller") {
checkbox(.offers, value: "1", label: "Accepts offers")
checkbox(.verified, value: "1", label: "Verified shop")
}
button(
.class("button button--dark button--block"),
.type(.submit)
) {
"Apply filters"
}
}
}
}
/// The formats the search is narrowed to.
var formats: [Format] {
parameters
.values(.format)
.compactMap(Format.init)
}
/// The screen's heading: the search text in quotes, else the one format searched, else every listing.
var heading: String {
if let query = parameters.value(.query) {
return "“\(query)”"
}
return formats.count == 1 ? formats[0].label : "All listings"
}
// MARK: Methods
/// A checkbox, ticked when the search carries its pair.
func checkbox(
_ name: SearchParameters.Name,
value: String,
label text: String
) -> some HTML {
label(.class("check")) {
input(
.type(.checkbox),
.name(name.rawValue),
.value(value)
)
.attributes(.checked, when: parameters.has(name, value: value))
text
}
}
/// A short text field, filled with the search's value for its name.
func field(
_ name: SearchParameters.Name,
label text: String,
placeholder: String,
isLabelVisible: Bool = false
) -> some HTML {
label(.class("filters__field")) {
span(.class(isLabelVisible ? "type-small" : "visually-hidden")) {
text
}
input(
.class("input input--sm"),
.type(.text),
.name(name.rawValue),
.value(parameters.value(name) ?? ""),
.placeholder(placeholder),
.custom(name: "inputmode", value: "decimal")
)
}
}
/// A titled group of filter controls.
func group(
_ title: String,
@HTMLBuilder content: () -> some HTML
) -> some HTML {
fieldset(.class("filters__group")) {
legend(.class("type-label")) {
title
}
content()
}
}
}
@@ -0,0 +1,325 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// A seller's public shop: the one screen a signed-out visitor gets in full, with every action leading to the sign-in screen.
struct ShopPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The number of listings the shop has in each format, before the quick filters.
let counts: [Format: Int]
/// The page of listings shown.
let listings: [Listing]
/// The page shown and the number of pages.
let pages: (current: Int, total: Int)
/// The tab, quick filters, and sort order applied.
let parameters: SearchParameters
/// The shop shown.
let shop: Shop
// MARK: Initializers
/// Creates the public screen of a shop.
/// - Parameters:
/// - shop: the shop shown.
/// - inventory: every listing of the shop, counted per format on the tabs.
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
/// - parameters: the tab, quick filters, and sort order applied.
/// - 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(
shop: Shop,
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
let slice = Pagination.slice(
matches,
page: parameters.page,
size: Self.pageSize
)
self.analytics = analytics
self.assetVersion = assetVersion
self.counts = Dictionary(grouping: inventory.compactMap(\.kind), by: \.self).mapValues(\.count)
self.listings = slice.items
self.locale = .init(identifier: "en")
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.shop = shop
}
}
// MARK: - Page
extension ShopPage: Page {
// MARK: Constants
/// The identifier tying the sort select to its form.
private static let formID = "shop-sort"
/// The number of listings on a page: two rows of the desktop grid.
private static let pageSize = 10
// MARK: Computed
var content: some HTML {
SiteLayout {
div(.class("page shop")) {
section(.class("shop__head")) {
span(.class("monogram monogram--xl")) {
shop.monogram
}
div(.class("shop__intro")) {
div(.class("shop__name")) {
h1(.class("type-h1")) {
shop.name
}
if shop.isVerified {
span(.class("chip chip--good")) {
Icon(.check, size: 12)
"Verified shop"
}
}
}
p(.class("type-body-sm shop__standing")) {
"\(shop.standing) · \(shop.shipsNote)"
}
p(.class("shop__about")) {
shop.about
}
ul(.class("shop__stats")) {
for stat in stats {
li {
strong {
stat.value
}
" \(stat.label)"
}
}
}
}
div(.class("shop__actions")) {
a(
.class("button button--primary"),
.href(signIn)
) {
Icon(.message, size: 16)
"Message shop"
}
a(
.class("button button--outline"),
.href(signIn)
) {
"Follow shop"
}
span(.class("type-small shop__gate")) {
Icon(.key, size: 13)
"Both need a passkey sign-in"
}
}
}
nav(
.class("tabs"),
.custom(name: "aria-label", value: "Shop sections")
) {
tab("All", count: counts.values.reduce(0, +), format: nil)
ForEach(Format.allCases.filter { counts[$0] != nil }) { format in
tab(format.label, count: counts[format] ?? 0, format: format)
}
}
div(.class("shop__toolbar")) {
ul(.class("shop__quick")) {
for quick in QuickFilter.allCases {
li {
a(
.class("pill"),
.href(quick.toggled(in: parameters).path(shop.path))
) {
quick.label
}
.attributes(
.custom(name: "aria-pressed", value: "true"),
when: quick.isOn(in: parameters)
)
}
}
}
form(
.class("shop__sort"),
.id(Self.formID),
.action(shop.path),
.method(.get)
) {
for item in parameters.removing(.sort).items {
input(
.type(.hidden),
.name(item.name),
.value(item.value)
)
}
SortSelect(
form: Self.formID,
selected: parameters.filter.sort
)
button(
.class("button button--sm js-hidden"),
.type(.submit)
) {
"Sort"
}
}
}
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing here right now"
}
p(.class("type-body-sm")) {
"No listing in this shop matches those filters."
}
a(
.class("button button--outline"),
.href(shop.path)
) {
"See everything"
}
}
} else {
div(.class("card-grid")) {
for listing in listings {
ListingCard(listing, context: .shop)
}
}
}
Pagination(
current: pages.current,
total: pages.total
) { [parameters, shop] in
parameters.setting(.page, to: "\($0)").path(shop.path)
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.shop]
}
var summary: String? {
"\(shop.name), \(shop.location): \(shop.about)"
}
var title: String {
"\(shop.name) — CCN"
}
}
// MARK: - Enumerations
private extension ShopPage {
/// A one-tap filter above the shop's grid.
enum QuickFilter: CaseIterable {
case slabbed
case underFifty
case offers
case keys
/// The filter's name on its pill.
var label: String {
switch self {
case .slabbed: "Slabbed only"
case .underFifty: "Under €50"
case .offers: "Accepts offers"
case .keys: "Keys"
}
}
/// The parameter pair the filter stands for.
var pair: (name: SearchParameters.Name, value: String) {
switch self {
case .slabbed: (.grading, SearchParameters.Grading.slabbed.rawValue)
case .underFifty: (.priceMax, "50")
case .offers: (.offers, "1")
case .keys: (.keys, "1")
}
}
/// Whether the parameters carry the filter.
func isOn(
in parameters: SearchParameters
) -> Bool {
parameters.has(pair.name, value: pair.value)
}
/// The parameters with the filter switched.
func toggled(
in parameters: SearchParameters
) -> SearchParameters {
parameters.toggling(pair.name, value: pair.value)
}
}
}
// MARK: - Helpers
private extension ShopPage {
// MARK: Computed
/// The sign-in screen, returning to this shop.
var signIn: String {
.Route.signIn(returningTo: shop.path)
}
var stats: [(value: String, label: String)] {
[
("\(counts.values.reduce(0, +))", "listings"),
(shop.soldCount.formatted(.number.locale(.init(identifier: "en"))), "sold"),
(shop.handlingDays == 1 ? "1 day" : "\(shop.handlingDays) days", "handling"),
(shop.returnsNote, ""),
]
}
// MARK: Methods
/// A tab narrowing the grid to one format, or to all of them.
func tab(
_ label: String,
count: Int,
format: Format?
) -> some HTML {
let target = format.map { parameters.setting(.format, to: $0.rawValue) } ?? parameters.removing(.format)
return a(
.class("tabs__tab"),
.href(target.path(shop.path))
) {
"\(label) · \(count)"
}
.attributes(
.custom(name: "aria-current", value: "page"),
when: parameters.value(.format) == format?.rawValue
)
}
}
@@ -0,0 +1,321 @@
import Foundation
import Persistence
/// The query parameters of a listing search: what the filter is read from, and what the links that change it are built with.
struct SearchParameters: Sendable, Equatable {
// MARK: Properties
/// The name-value pairs, in the order given.
let items: [Item]
// MARK: Initializers
/// Creates the parameters from their pairs.
/// - Parameter items: the name-value pairs.
init(
items: [Item] = []
) {
self.items = items
}
/// Parses the parameters out of a URL's raw query, dropping the pairs left empty by an unfilled form field.
/// - Parameter query: the raw query, without the leading question mark, or `nil` for none.
init(
query: String?
) {
self.items = (query ?? "")
.split(separator: "&")
.compactMap { pair in
let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
let decoded = parts.map {
$0.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? ""
}
guard
decoded.count == 2,
!decoded[1].trimmingCharacters(in: .whitespaces).isEmpty
else {
return nil
}
return .init(name: decoded[0], value: decoded[1].trimmingCharacters(in: .whitespaces))
}
}
}
// MARK: - Structs
extension SearchParameters {
/// A query parameter.
struct Item: Sendable, Equatable {
/// The parameter's name.
let name: String
/// The parameter's value.
let value: String
}
/// An active criterion, shown as a pill that links to the search without it.
struct Pill: Sendable, Equatable {
/// The criterion in a few words.
let label: String
/// The parameters of the search without the criterion.
let removed: SearchParameters
}
}
// MARK: - Extensions
extension SearchParameters {
// MARK: Constants
/// The number of listings on a page.
static let pageSize = 12
// MARK: Computed
/// The filter the parameters describe. Listings ship from the EU unless the parameters say otherwise.
var filter: ListingFilter {
.init(
query: value(.query),
formats: values(.format),
isSlabbed: slabbed,
gradeMin: value(.gradeMin).flatMap(Double.init),
gradeMax: value(.gradeMax).flatMap(Double.init),
firstPrintOnly: has(.firstPrint),
keysOnly: has(.keys),
publishers: values(.publisher),
priceMin: value(.priceMin).flatMap(Int.init).map { $0 * 100 },
priceMax: value(.priceMax).flatMap(Int.init).map { $0 * 100 },
shipsFrom: value(.shipsFrom).flatMap(ListingFilter.Region.init) ?? .eu,
acceptsOffers: has(.offers),
verifiedShops: has(.verified),
sort: value(.sort).flatMap(ListingFilter.Sort.init) ?? .newest
)
}
/// The page asked for, counted from one.
var page: Int {
max(value(.page).flatMap(Int.init) ?? 1, 1)
}
/// The active criteria, in the order of the filter rail.
var pills: [Pill] {
var pills = [Pill]()
let filter = filter
for format in values(.format) {
pills.append(.init(
label: Format(rawValue: format)?.label ?? format,
removed: removing(.format, value: format)
))
}
if let isSlabbed = filter.isSlabbed {
pills.append(.init(
label: isSlabbed ? "Slabbed" : "Raw",
removed: removing(.grading)
))
}
if filter.gradeMin != nil || filter.gradeMax != nil {
pills.append(.init(
label: "Grade \((filter.gradeMin ?? 0.5).gradeLabel)–\((filter.gradeMax ?? 10).gradeLabel)",
removed: removing(.gradeMin).removing(.gradeMax)
))
}
if filter.firstPrintOnly {
pills.append(.init(label: "1st print only", removed: removing(.firstPrint)))
}
for publisher in filter.publishers {
pills.append(.init(label: publisher, removed: removing(.publisher, value: publisher)))
}
if filter.priceMin != nil || filter.priceMax != nil {
let label = switch (filter.priceMin, filter.priceMax) {
case (let min?, let max?): "\(min.euros)–\(max.euros)"
case (let min?, nil): "From \(min.euros)"
case (_, let max): "Up to \((max ?? 0).euros)"
}
pills.append(.init(label: label, removed: removing(.priceMin).removing(.priceMax)))
}
if filter.shipsFrom != .anywhere {
pills.append(.init(
label: "Ships from \(filter.shipsFrom.rawValue.uppercased())",
removed: setting(.shipsFrom, to: ListingFilter.Region.anywhere.rawValue)
))
}
if filter.keysOnly {
pills.append(.init(label: "Key issues", removed: removing(.keys)))
}
if filter.acceptsOffers {
pills.append(.init(label: "Accepts offers", removed: removing(.offers)))
}
if filter.verifiedShops {
pills.append(.init(label: "Verified shops", removed: removing(.verified)))
}
return pills
}
/// The parameters as a URL query with its leading question mark, or nothing when there are none.
var queryString: String {
guard !items.isEmpty else {
return ""
}
return "?" + items
.map { "\(Self.escape($0.name))=\(Self.escape($0.value))" }
.joined(separator: "&")
}
// MARK: Methods
/// The filter the parameters describe within one shop, wherever it ships from.
/// - Parameter slug: the shop's URL slug.
func filter(
inShop slug: String
) -> ListingFilter {
var filter = filter
filter.shipsFrom = .anywhere
filter.shop = slug
return filter
}
/// Whether any parameter has the name.
func has(
_ name: Name
) -> Bool {
value(name) != nil
}
/// Whether a parameter has the name and value.
func has(
_ name: Name,
value: String
) -> Bool {
values(name).contains(value)
}
/// The given path followed by the parameters.
func path(
_ path: String
) -> String {
path + queryString
}
/// The parameters without those of the name — or, given a value, without that one pair — and back on the first page.
func removing(
_ name: Name,
value: String? = nil
) -> Self {
.init(items: items.filter {
$0.name != Name.page.rawValue && !($0.name == name.rawValue && (value == nil || $0.value == value))
})
}
/// The parameters with the name set to the one value. Setting anything but the page returns to the first page.
func setting(
_ name: Name,
to value: String
) -> Self {
let kept = name == .page
? items.filter { $0.name != name.rawValue }
: removing(name).items
return .init(items: kept + [.init(name: name.rawValue, value: value)])
}
/// The parameters with the pair removed when present, and set when absent.
func toggling(
_ name: Name,
value: String
) -> Self {
has(name, value: value)
? removing(name, value: value)
: setting(name, to: value)
}
/// The first value of the name, or `nil` when no parameter has it.
func value(
_ name: Name
) -> String? {
values(name).first
}
/// Every value of the name.
func values(
_ name: Name
) -> [String] {
items
.filter { $0.name == name.rawValue }
.map(\.value)
}
}
// MARK: - Enumerations
extension SearchParameters {
/// The names of the parameters a search reads.
enum Name: String, Sendable {
case firstPrint = "first-print"
case format
case gradeMax = "grade-max"
case gradeMin = "grade-min"
case grading
case keys
case offers
case page
case priceMax = "price-max"
case priceMin = "price-min"
case publisher
case query = "q"
case shipsFrom = "ships-from"
case sort
case verified
}
/// The values of the `grading` parameter.
enum Grading: String, Sendable {
case raw
case slabbed
}
}
// MARK: - Helpers
private extension SearchParameters {
// MARK: Computed
/// `true` for slabbed only, `false` for raw only, `nil` when the parameters name both or neither.
var slabbed: Bool? {
let gradings = Set(values(.grading).compactMap(Grading.init))
return gradings.count == 1
? gradings.contains(.slabbed)
: nil
}
// MARK: Methods
/// Percent-escapes a name or value for a URL query.
static func escape(
_ string: String
) -> String {
string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(.init(charactersIn: "&=+?#"))) ?? ""
}
}
@@ -58,9 +58,12 @@ extension StaticFile {
.icon192,
.icon512,
.index,
.listing,
.notFound,
.robots,
.search,
.shared,
.shop,
.site,
.sitemap,
]
@@ -128,15 +131,24 @@ extension StaticFile {
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
static let index = Self("index", as: .css, .js)
/// The `css/listing.css` stylesheet for the listing detail page.
static let listing = Self("listing", as: .css)
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
static let notFound = Self("not-found", as: .css, .js)
/// The `robots.txt` crawler directives.
static let robots = Self("robots", as: .txt)
/// The `css/search.css` stylesheet for the search results page.
static let search = Self("search", as: .css)
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
static let shared = Self("shared", as: .css, .js)
/// The `css/shop.css` stylesheet for the shop page.
static let shop = Self("shop", as: .css)
/// The `site.webmanifest` web application manifest.
static let site = Self("site", as: .webmanifest)
@@ -0,0 +1,172 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the public catalogue: the search results, a listing's detail, and a seller's shop.
///
/// Unlike the landing page, these screens depend on the request, so each is rendered per request rather than cached.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct CatalogueController<Context: RequestContext> {
// MARK: Properties
/// The analytics tracker the pages embed, or `nil` to omit it.
private let analytics: Analytics?
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
private let assetVersion: String?
/// The repository the shops and listings are read through.
private let repository: CatalogueRepository
// MARK: Initializers
/// Creates a catalogue controller.
/// - Parameters:
/// - repository: the repository the shops and listings are read through.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
repository: CatalogueRepository,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.repository = repository
}
}
// MARK: - RouterController
extension CatalogueController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(
.Catalogue.search,
use: search
)
routes.get(
.Catalogue.listing,
use: listing
)
routes.get(
.Catalogue.shop,
use: shop
)
return routes
}
}
// MARK: - Helpers
private extension CatalogueController {
// MARK: Constants
/// The number of other listings shown under a listing: one line of the desktop grid.
static var relatedCount: Int { 5 }
// MARK: Methods
/// Handles a request for a listing's detail; an unknown slug is not found.
@Sendable
func listing(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let listing = try await repository.listing(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let related = try await repository
.listings(matching: .init(shop: listing.shop.slug))
.filter { $0.slug != listing.slug }
.prefix(Self.relatedCount)
return .init { [analytics, assetVersion] in
ListingPage(
listing: listing,
related: Array(related),
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for the search results, filtered through the query parameters.
@Sendable
func search(
request: Request,
context: Context
) async throws -> HTMLResponse {
let parameters = SearchParameters(query: request.uri.query)
let matches = try await repository.listings(matching: parameters.filter)
let publishers = try await repository.publishers()
return .init { [analytics, assetVersion] in
SearchPage(
parameters: parameters,
matches: matches,
publishers: publishers,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for a seller's shop; an unknown slug is not found.
@Sendable
func shop(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let shop = try await repository.shop(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let parameters = SearchParameters(query: request.uri.query)
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
return .init { [analytics, assetVersion] in
ShopPage(
shop: shop,
inventory: inventory,
matches: matches,
parameters: parameters,
assetVersion: assetVersion,
analytics: analytics
)
}
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``CatalogueController`` route paths.
enum Catalogue {
/// The path of a listing's detail.
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
/// The path of the search results.
static let search: RouterPath = .init(.Route.search)
/// The path of a seller's shop.
static let shop: RouterPath = .init(.Route.shop(":\(String.slug)"))
}
}
private extension String {
/// The name of the route parameter carrying a listing's or a shop's slug.
static let slug = "slug"
}
@@ -31,10 +31,12 @@ public struct RootController<Context: RequestContext> {
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
/// - listings: the listings the landing page shows in its rows; none by default.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
self.negotiate = .init(bundle: .module)
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
@@ -47,7 +49,8 @@ public struct RootController<Context: RequestContext> {
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
analytics: analytics,
listings: listings
)
}
}
@@ -0,0 +1,38 @@
import Persistence
/// The listings the landing page shows, fetched once at startup: the page is rendered once and cached.
public struct HomeListings: Sendable {
// MARK: Constants
/// The number of listings in a row: one line of the desktop grid.
public static let rowSize = 5
// MARK: Properties
/// The newest manga, trade paperbacks, and hardcovers.
public let collected: [Listing]
/// The newest single issues.
public let singleIssues: [Listing]
// MARK: Initializers
/// Picks the landing page's rows out of a list of listings.
/// - Parameter listings: the listings to pick from, newest first; none by default.
public init(
from listings: [Listing] = []
) {
self.collected = Array(
listings
.filter { $0.kind != .singleIssues }
.prefix(Self.rowSize)
)
self.singleIssues = Array(
listings
.filter { $0.kind == .singleIssues }
.prefix(Self.rowSize)
)
}
}