Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -0,0 +1,437 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// A sale record, as one of its parties sees it: what was agreed, the five-step handshake, and the step that is theirs to take.
|
||||
struct SalePage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The moment stamps are read relative to.
|
||||
let now: Date
|
||||
|
||||
/// The party the viewer is to the sale.
|
||||
let role: Sale.Role
|
||||
|
||||
/// The sale shown.
|
||||
let sale: Sale
|
||||
|
||||
/// The signed-in viewer.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the sale record's screen.
|
||||
/// - Parameters:
|
||||
/// - sale: the sale shown.
|
||||
/// - role: the party the viewer is to the sale.
|
||||
/// - 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(
|
||||
sale: Sale,
|
||||
role: Sale.Role,
|
||||
viewer: Viewer? = nil,
|
||||
now: Date = .init(),
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = .init(identifier: "en")
|
||||
self.now = now
|
||||
self.role = role
|
||||
self.sale = sale
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension SalePage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page sale")) {
|
||||
nav(
|
||||
.class("breadcrumb"),
|
||||
.custom(name: "aria-label", value: "Breadcrumb")
|
||||
) {
|
||||
a(.href(role == .buyer ? .Route.messages : .Route.sellerDashboard)) {
|
||||
role == .buyer ? "Messages" : "Seller dashboard"
|
||||
}
|
||||
span(.custom(name: "aria-hidden", value: "true")) {
|
||||
"/"
|
||||
}
|
||||
span(
|
||||
.class("breadcrumb__current"),
|
||||
.custom(name: "aria-current", value: "page")
|
||||
) {
|
||||
sale.reference
|
||||
}
|
||||
}
|
||||
div(.class("sale__head")) {
|
||||
div(.class("sale__heading")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Sale record · \(sale.reference) · you are the \(role == .buyer ? "buyer" : "seller")"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"\(sale.listing.title), \(sale.listing.chip.label)"
|
||||
}
|
||||
p(.class("type-body-sm sale__summary")) {
|
||||
"Agreed \(sale.agreedAt.stamp(relativeTo: now)) with \(counterparty) · \(sale.priceCents.euros) + \(sale.shippingCents.euros) tracked shipping · paid off-platform"
|
||||
}
|
||||
}
|
||||
Chip(sale.status.label, tone: sale.status.tone)
|
||||
}
|
||||
div(.class("card sale__handshake")) {
|
||||
ol(.class("stepper")) {
|
||||
for step in Sale.Step.allCases {
|
||||
li(.class("stepper__step stepper__step--\(state(of: step))")) {
|
||||
span(.class("stepper__dot")) {
|
||||
if step <= sale.completed {
|
||||
Icon(.check, size: 14)
|
||||
} else {
|
||||
"\(step.rawValue)"
|
||||
}
|
||||
}
|
||||
span(.class("stepper__name")) {
|
||||
step.label
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
caption(of: step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("sale__next")) {
|
||||
p(.class("sale__rule")) {
|
||||
"Each step is stamped by the person who did it. Ratings unlock only after "
|
||||
strong {
|
||||
"Received"
|
||||
}
|
||||
", so every rating on CCN maps to a finished sale."
|
||||
}
|
||||
div(.class("sale__actions")) {
|
||||
action
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(.Route.conversation(sale.conversationID))
|
||||
) {
|
||||
Icon(.message, size: 15)
|
||||
"Message \(role == .buyer ? "seller" : "buyer")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("sale__body")) {
|
||||
div(.class("sale__column")) {
|
||||
section(.class("card sale__card")) {
|
||||
h2(.class("type-eyebrow sale__title")) {
|
||||
role == .buyer ? "What you agreed to" : "What was agreed"
|
||||
}
|
||||
div(.class("sale__agreed")) {
|
||||
Cover(sale.listing.cover, modifier: "cover--agreed")
|
||||
dl(.class("details")) {
|
||||
for detail in details {
|
||||
dt {
|
||||
detail.term
|
||||
}
|
||||
dd {
|
||||
detail.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("gate-note")) {
|
||||
Icon(.info, size: 16)
|
||||
span {
|
||||
"The declared condition above is frozen at the moment of agreement. If the copy arrives with a defect not listed here, that's the basis for a problem report."
|
||||
}
|
||||
}
|
||||
}
|
||||
section(.class("card sale__card")) {
|
||||
h2(.class("type-eyebrow sale__title")) {
|
||||
"Payment · off-platform"
|
||||
}
|
||||
p(.class("type-body-sm sale__text")) {
|
||||
payment
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("sale__column")) {
|
||||
section(.class("card sale__card")) {
|
||||
h2(.class("type-eyebrow sale__title")) {
|
||||
role == .buyer ? "Seller" : "Buyer"
|
||||
}
|
||||
if role == .buyer {
|
||||
div(.class("sale__party")) {
|
||||
span(.class("monogram")) {
|
||||
sale.listing.shop.monogram
|
||||
}
|
||||
div {
|
||||
p(.class("sale__party-name")) {
|
||||
sale.listing.shop.name
|
||||
}
|
||||
p(.class("type-small")) {
|
||||
sale.listing.shop.standing
|
||||
}
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(sale.listing.shop.path)
|
||||
) {
|
||||
"Visit shop"
|
||||
}
|
||||
} else {
|
||||
div(.class("sale__party")) {
|
||||
span(.class("monogram")) {
|
||||
Viewer(sale.buyer).initials
|
||||
}
|
||||
p(.class("sale__party-name")) {
|
||||
sale.buyer.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sale.buyerRating != nil || sale.sellerRating != nil {
|
||||
section(.class("card sale__card")) {
|
||||
h2(.class("type-eyebrow sale__title")) {
|
||||
"Ratings"
|
||||
}
|
||||
dl(.class("details")) {
|
||||
dt {
|
||||
"Buyer rated seller"
|
||||
}
|
||||
dd {
|
||||
Self.stars(sale.buyerRating)
|
||||
}
|
||||
dt {
|
||||
"Seller rated buyer"
|
||||
}
|
||||
dd {
|
||||
Self.stars(sale.sellerRating)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
"Sale record \(sale.reference) — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SalePage {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The step that is the viewer's to take now, as a form; or what is waited on.
|
||||
@HTMLBuilder
|
||||
var action: some HTML {
|
||||
if sale.allows(.markPaid(note: ""), by: role) {
|
||||
step("paid", button: "Mark as paid") {
|
||||
noteField("How you paid", placeholder: "Bank transfer (iDEAL)")
|
||||
}
|
||||
} else if sale.allows(.markSent(note: ""), by: role) {
|
||||
step("sent", button: "Mark as sent") {
|
||||
noteField("Carrier and tracking", placeholder: "DHL · 3SABCD1234567")
|
||||
}
|
||||
} else if sale.allows(.markReceived, by: role) {
|
||||
step("received", button: "Mark as received") {}
|
||||
} else if sale.allows(.rate(stars: 5), by: role) {
|
||||
step("rated", button: "Leave rating") {
|
||||
fieldset(.class("rating")) {
|
||||
legend(.class("visually-hidden")) {
|
||||
"Your rating, out of five"
|
||||
}
|
||||
for stars in (1...5).reversed() {
|
||||
label(.class("rating__option")) {
|
||||
input(
|
||||
.type(.radio),
|
||||
.name("stars"),
|
||||
.value("\(stars)"),
|
||||
.required
|
||||
)
|
||||
.attributes(.checked, when: stars == 5)
|
||||
span {
|
||||
"\(stars) ★"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p(.class("sale__waiting")) {
|
||||
waiting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The other party's name.
|
||||
var counterparty: String {
|
||||
role == .buyer ? sale.listing.shop.name : sale.buyer.displayName
|
||||
}
|
||||
|
||||
/// What was agreed, leaving out what the declaration does not state.
|
||||
var details: [(term: String, value: String)] {
|
||||
let price = sale.priceCents == sale.askedCents
|
||||
? "\(sale.priceCents.euros) · bought at the asking price"
|
||||
: "\(sale.priceCents.euros) · offer accepted (asked \(sale.askedCents.euros))"
|
||||
|
||||
return [
|
||||
("Grade", sale.declared.grade),
|
||||
("Page quality", sale.declared.pageQuality),
|
||||
("Printing", sale.declared.printing),
|
||||
("Declared by seller", sale.declared.notes),
|
||||
("Price", price),
|
||||
("Shipping", "\(sale.shippingCents.euros) · tracked to NL · \(sale.listing.shop.handlingDays) business days handling"),
|
||||
].compactMap { term, value in
|
||||
value.map { (term, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the payment stands, in a sentence or two.
|
||||
var payment: String {
|
||||
let parties = role == .buyer ? "You and the seller settle" : "You and the buyer settle"
|
||||
|
||||
guard let paidAt = sale.paidAt else {
|
||||
return "\(parties) payment directly — CCN handles no money. The buyer marks the sale paid here once the transfer is made, so the seller can dispatch."
|
||||
}
|
||||
|
||||
let note = sale.paymentNote.map { " by \($0)" } ?? ""
|
||||
|
||||
return "\(parties) payment directly. The buyer marked this as paid\(note) \(paidAt.stamp(relativeTo: now)). CCN keeps the agreed amounts and the stamps as the record of the sale."
|
||||
}
|
||||
|
||||
/// What is waited on when the next step is not the viewer's.
|
||||
var waiting: String {
|
||||
switch sale.next {
|
||||
case .paid: "Waiting for the buyer to pay and mark the sale paid."
|
||||
case .sent: "Waiting for the seller to dispatch."
|
||||
case .received: "Waiting for the buyer to confirm the copy arrived."
|
||||
case .rated: "Thanks for rating. Waiting for the other side's rating."
|
||||
default: "The handshake is complete."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The line under a step: its stamp once taken, else who takes it.
|
||||
func caption(
|
||||
of step: Sale.Step
|
||||
) -> String {
|
||||
switch step {
|
||||
case .agreed:
|
||||
return sale.agreedAt.stamp(relativeTo: now)
|
||||
case .paid:
|
||||
return sale.paidAt.map { "Buyer marked paid · \($0.stamp(relativeTo: now))" } ?? "Buyer confirms"
|
||||
case .sent:
|
||||
return sale.sentAt.map { "\(sale.shippingNote ?? "Seller marked sent") · \($0.stamp(relativeTo: now))" } ?? "Seller confirms"
|
||||
case .received:
|
||||
return sale.receivedAt.map { "Buyer confirmed · \($0.stamp(relativeTo: now))" } ?? "Buyer confirms"
|
||||
case .rated:
|
||||
let count = [sale.buyerRating, sale.sellerRating].compactMap(\.self).count
|
||||
|
||||
return count == 0 ? "Both sides" : "\(count) of 2 rated"
|
||||
}
|
||||
}
|
||||
|
||||
/// A short text field for a step's note.
|
||||
func noteField(
|
||||
_ title: String,
|
||||
placeholder: String
|
||||
) -> some HTML {
|
||||
label(.class("sale__note")) {
|
||||
span(.class("visually-hidden")) {
|
||||
title
|
||||
}
|
||||
input(
|
||||
.class("input"),
|
||||
.type(.text),
|
||||
.name("note"),
|
||||
.placeholder(placeholder),
|
||||
.custom(name: "maxlength", value: "200")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rating as stars, or that it is still to come.
|
||||
static func stars(
|
||||
_ rating: Int?
|
||||
) -> String {
|
||||
rating.map { String(repeating: "★", count: $0) + String(repeating: "☆", count: 5 - $0) } ?? "Not yet"
|
||||
}
|
||||
|
||||
/// Whether a step is done, the one waited on, or still to come.
|
||||
func state(
|
||||
of step: Sale.Step
|
||||
) -> String {
|
||||
if step <= sale.completed {
|
||||
return "done"
|
||||
}
|
||||
|
||||
return step == sale.next ? "current" : "pending"
|
||||
}
|
||||
|
||||
/// The form taking a step of the handshake.
|
||||
func step(
|
||||
_ name: String,
|
||||
button title: String,
|
||||
@HTMLBuilder fields: () -> some HTML
|
||||
) -> some HTML {
|
||||
form(
|
||||
.class("sale__step"),
|
||||
.action(.Route.sale(sale.number)),
|
||||
.method(.post)
|
||||
) {
|
||||
input(
|
||||
.type(.hidden),
|
||||
.name("step"),
|
||||
.value(name)
|
||||
)
|
||||
fields()
|
||||
button(
|
||||
.class("button button--primary"),
|
||||
.type(.submit)
|
||||
) {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user