import Elementary import Foundation import Infrastructure import Persistence /// The seller dashboard: the offers waiting for an answer, the sales and their handshakes, and what is listed. struct SellerDashboardPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// The upcoming events, each with whether the shop trades there. let events: [(event: Event, isTrading: Bool)] /// The shop's listings still for sale, newest first. let listings: [Listing] /// The moment stamps are read relative to. let now: Date /// The offers waiting for the seller's answer, the soonest to expire first. let offers: [Offer] /// The shop's sales, the latest agreed first. let sales: [Sale] /// The viewer's shop. let shop: Shop /// The signed-in viewer. let viewer: Viewer? // MARK: Initializers /// Creates the seller dashboard of a shop. /// - Parameters: /// - shop: the viewer's shop. /// - offers: the offers waiting for the seller's answer, the soonest to expire first. /// - sales: the shop's sales, the latest agreed first. /// - listings: the shop's listings still for sale, newest first. /// - events: the upcoming events; none by default. /// - viewer: the signed-in viewer. /// - now: the moment stamps are read relative to; the present by default. /// - 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, offers: [Offer], sales: [Sale], listings: [Listing], events: [Event] = [], viewer: Viewer? = nil, now: Date = .init(), assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.events = events.map { event in (event, event.shops.contains { $0.slug == shop.slug }) } self.listings = listings self.locale = .init(identifier: "en") self.now = now self.offers = offers self.sales = sales self.shop = shop self.viewer = viewer } } // MARK: - Page extension SellerDashboardPage: Page { // MARK: Computed var content: some HTML { SiteLayout(viewer: viewer) { div(.class("page sell")) { div(.class("sell__head")) { div(.class("sell__heading")) { p(.class("type-eyebrow")) { "\(shop.name) · seller dashboard" } h1(.class("type-h1")) { "Hello, \(viewer?.firstName ?? shop.name)" } p(.class("type-body-sm sell__summary")) { summary } } div(.class("sell__actions")) { a( .class("button button--outline"), .href(shop.path) ) { "View public shop" } a( .class("button button--primary"), .href(NewListingPage.path) ) { "New listing" } } } ul(.class("tiles")) { tile("\(listings.count)", "Active listings", nil) tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" }) tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil) tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch") tile(shop.rating.oneDecimal, "Rating", "\(shop.ratedSales) rated sales") } section(.class("card sell__panel")) { div(.class("section-head")) { h2(.class("type-panel")) { "Offers" } span(.class("type-small")) { "Offers under 80% of asking are auto-declined" } } if offers.isEmpty { p(.class("sell__none")) { "No offers waiting." } } else { ul(.class("rows")) { ForEach(offers) { offer in li(.class("rows__row rows__row--offer")) { span { a( .class("rows__title"), .href(.Route.conversation(offer.conversationID)) ) { offer.listing.title } span(.class("type-small")) { " · \(offer.listing.chip.label)" } } span(.class("type-small")) { "asks \(offer.listing.priceCents.euros)" } strong(.class("type-price-card")) { offer.amountCents.euros } span(.class("type-small")) { offer.expiresAt.countdown(from: now) } span(.class("type-small")) { "from \(offer.buyer.displayName)" } AnswerOffer( offer: offer, returnPath: .Route.sellerDashboard ) } } } } } section(.class("card sell__panel")) { h2(.class("type-panel")) { "Recent sales" } if sales.isEmpty { p(.class("sell__none")) { "No sales yet. Accepting an offer opens the first sale record." } } else { ul(.class("rows")) { ForEach(sales) { sale in li { a( .class("rows__row rows__row--sale"), .href(.Route.sale(sale.number)) ) { strong { sale.reference } span { "\(sale.listing.title) · \(sale.listing.chip.label)" } span { sale.priceCents.euros } span(.class("type-small")) { sale.buyer.displayName } Handshake(completed: sale.completed) span(.class("type-small")) { sale.agreedAt.stamp(relativeTo: now) } } } } } } } section(.class("card sell__panel")) { div(.class("section-head")) { h2(.class("type-panel")) { "Active listings" } a( .class("link-ink"), .href(shop.path) ) { "All \(listings.count)" } } ul(.class("rows")) { ForEach(listings) { listing in li(.class("rows__row rows__row--listing")) { span { a( .class("rows__title"), .href(listing.path) ) { listing.title } span(.class("type-small")) { " · \(listing.chip.label)" } } span { listing.priceCents.euros } span(.class("type-small")) { listing.acceptsOffers ? "accepts offers" : "fixed price" } } } } } if !events.isEmpty { form( .class("card sell__panel"), .action("\(String.Route.sellerDashboard)/events"), .method(.post) ) { div(.class("section-head")) { h2(.class("type-panel")) { "Events you'll trade at" } span(.class("type-small")) { "Buyers can agree online and collect at the con" } } div(.class("listing-form__checks")) { ForEach(events) { entry in label(.class("check")) { input( .type(.checkbox), .name("events"), .value(entry.event.slug) ) .attributes(.checked, when: entry.isTrading) "\(entry.event.name) · \(Date.span(from: entry.event.startsOn, to: entry.event.endsOn))" } } } div(.class("listing-form__submit")) { button( .class("button button--dark button--sm"), .type(.submit) ) { "Save events" } } } } } } } @HTMLBuilder var metadata: some HTML { meta( .name("robots"), .content("noindex") ) siteMetadata } var scripts: [any Asset] { [StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.trade] } var title: String { "Seller dashboard · \(shop.name) — CCN" } } // MARK: - Helpers private extension SellerDashboardPage { // MARK: Computed /// What needs the seller's attention, in a line. var summary: String { let dispatch = sales.count { $0.next == .sent } return [ offers.isEmpty ? "No offers waiting" : "\(offers.count) \(offers.count == 1 ? "offer" : "offers") waiting", dispatch == 0 ? nil : "\(dispatch) \(dispatch == 1 ? "sale" : "sales") to dispatch", (viewer?.unreadMessages ?? 0) == 0 ? nil : "\(viewer?.unreadMessages ?? 0) unread", ] .compactMap(\.self) .joined(separator: " · ") } // MARK: Methods /// A figure, what it counts, and an optional note under it. func tile( _ figure: String, _ label: String, _ note: String? ) -> some HTML { li(.class("card tiles__tile")) { span(.class("type-stat")) { figure } span(.class("type-label")) { label } if let note { span(.class("type-small")) { note } } } } }