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
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user