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

@@ -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
)
}
}