Files
ccn/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift
T

330 lines
11 KiB
Swift
Raw Normal View History

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
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// 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.
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
/// - 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,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.viewer = viewer
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(viewer: viewer) {
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(.Route.gated(.Route.message(shop: shop.slug), from: shop.path, viewer: viewer))
) {
Icon(.message, size: 16)
"Message shop"
}
if viewer == nil {
a(
.class("button button--outline"),
.href(.Route.signIn(returningTo: shop.path))
) {
"Follow shop"
}
span(.class("type-small shop__gate")) {
Icon(.key, size: 13)
"Both need a passkey sign-in"
}
} else {
Unavailable("Follow shop", modifier: "button--outline")
}
}
}
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
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
)
}
}