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

296 lines
11 KiB
Swift

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 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.
/// - 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],
viewer: Viewer? = nil,
now: Date = .init(),
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
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
}
}
a(
.class("button button--outline"),
.href(shop.path)
) {
"View public shop"
}
}
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(String(format: "%.1f", shop.rating), "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"
}
}
}
}
}
}
}
}
@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
}
}
}
}
}