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 } } } }