331 lines
11 KiB
Swift
331 lines
11 KiB
Swift
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
|
|
)
|
|
}
|
|
|
|
}
|