Files
ccn/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift
T
javierandClaude Opus 5.5 911424de24 Project updates from Template
This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing:

- Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305)
- Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df)
- Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9)
- Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769)
- Enabled the Analytics trait in the Website service. (f6a6bbd)
- Reworked page routing in the Website service. (d1cb2c1)
- Gated the structured data features in the Infrastructure package behind a trait. (f0a8791)
- Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3)
- Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b)

The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead.

The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:55:43 +01:00

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(shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))), "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
}
}
}
}
}