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 upcoming events the shop trades at. let pickups: [Event] /// 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. /// - pickups: the upcoming events the shop trades at; none by default. /// - 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, pickups: [Event] = [], 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.pickups = pickups 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)" if let event = pickups.first { " · trading at " a(.href(.Route.events)) { "\(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))" } } } 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")) { counts.isEmpty ? "\(shop.name) is restocking" : "Nothing here right now" } p(.class("type-body-sm")) { counts.isEmpty ? "Nothing listed right now\(pickups.first.map { " — the shop is trading at \($0.name), \(Date.span(from: $0.startsOn, to: $0.endsOn))" } ?? "")." : "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 ) } }