347 lines
13 KiB
Swift
347 lines
13 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 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(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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|