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
@@ -11,8 +11,8 @@ import WebsiteLibrary
|
||||
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
|
||||
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
|
||||
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
|
||||
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a PostgreSQL backend is migrated out of
|
||||
/// band (so a shared database is never migrated on boot).
|
||||
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated here, before the landing page reads its listings, while a
|
||||
/// PostgreSQL backend is migrated out of band (so a shared database is never migrated on boot).
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
/// - Returns: the configured application, ready to run as a service.
|
||||
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
|
||||
@@ -46,6 +46,15 @@ func application(
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated — and seeded — before the landing page
|
||||
// reads its listings. The PostgreSQL backend is left untouched: a shared database is migrated out of band to avoid
|
||||
// multi-instance races.
|
||||
if case .inMemory = driver {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
|
||||
let repository = CatalogueRepository(fluent: fluent)
|
||||
|
||||
var app = Application(
|
||||
router: router(
|
||||
staticFilesPath: reader.staticFilesPath,
|
||||
@@ -60,7 +69,12 @@ func application(
|
||||
// building both against an empty host.
|
||||
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent)
|
||||
probe: Probe(fluent: fluent),
|
||||
repository: repository,
|
||||
listings: await homeListings(
|
||||
from: repository,
|
||||
logger: logger
|
||||
)
|
||||
),
|
||||
configuration: ApplicationConfiguration(
|
||||
reader: reader.scoped(to: "http")
|
||||
@@ -70,14 +84,6 @@ func application(
|
||||
|
||||
app.addServices(fluent)
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
|
||||
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
|
||||
if case .inMemory = driver {
|
||||
app.beforeServerStarts {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -121,6 +127,26 @@ func migration(
|
||||
/// The request context type the application serves its routes with.
|
||||
private typealias AppRequestContext = WebsiteRequestContext
|
||||
|
||||
/// Reads the listings the landing page shows, once: the page is rendered at startup and cached.
|
||||
///
|
||||
/// A catalogue that cannot be read — a PostgreSQL backend not migrated yet — leaves the page without its rows rather than failing the launch.
|
||||
/// - Parameters:
|
||||
/// - repository: the repository the listings are read through.
|
||||
/// - logger: the logger the failure is reported to.
|
||||
/// - Returns: the landing page's listings, or none when the catalogue cannot be read.
|
||||
private func homeListings(
|
||||
from repository: CatalogueRepository,
|
||||
logger: Logger
|
||||
) async -> HomeListings {
|
||||
do {
|
||||
return .init(from: try await repository.listings())
|
||||
} catch {
|
||||
logger.warning("Catalogue is unreadable; the landing page will show no listings: \(error)")
|
||||
|
||||
return .init()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the application's logger.
|
||||
/// - Parameters:
|
||||
/// - serverName: the label applied to the logger.
|
||||
@@ -156,6 +182,8 @@ private func logger(
|
||||
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
|
||||
/// - listings: the listings the landing page shows in its rows.
|
||||
/// - Returns: the configured router.
|
||||
private func router(
|
||||
staticFilesPath: String,
|
||||
@@ -168,7 +196,9 @@ private func router(
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
siteOrigin: String?,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
probe: Probe,
|
||||
repository: CatalogueRepository,
|
||||
listings: HomeListings
|
||||
) -> Router<AppRequestContext> {
|
||||
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
|
||||
// the page's status and headers instead of a 404.
|
||||
@@ -204,6 +234,12 @@ private func router(
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings
|
||||
)
|
||||
CatalogueController<AppRequestContext>(
|
||||
repository: repository,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user