Implemented phase 3 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:11:25 +02:00
1 parent b1ceed7790
commit 0dc3fe6bea
29 files changed
+4089 -66

No files matched your search

@@ -0,0 +1,75 @@
import Elementary
import Persistence
/// The seller's answer to an open offer: accept or decline, each a form.
struct AnswerOffer: HTML {
// MARK: Properties
/// The offer answered.
let offer: Offer
/// The path to return to afterwards; accepting leads to the sale record instead.
let returnPath: String
// MARK: Computed
var body: some HTML {
form(
.class("answer"),
.action(.Route.answer(offer: offer.id)),
.method(.post)
) {
input(
.type(.hidden),
.name("return"),
.value(returnPath)
)
button(
.class("button button--primary button--sm"),
.type(.submit),
.name("answer"),
.custom(name: "value", value: "accept")
) {
"Accept"
}
button(
.class("button button--sm"),
.type(.submit),
.name("answer"),
.custom(name: "value", value: "decline")
) {
"Decline"
}
}
}
}
// MARK: - Extensions
extension Offer.Status {
// MARK: Computed
/// The status in a word.
var label: String {
switch self {
case .open: "Open"
case .accepted: "Accepted"
case .declined: "Declined"
case .expired: "Expired"
}
}
/// The chip colouring for the status.
var tone: Chip.Tone {
switch self {
case .open: .warn
case .accepted: .good
case .declined: .bad
case .expired: .neutral
}
}
}
@@ -0,0 +1,68 @@
import Elementary
import Persistence
/// The five steps of a sale's handshake, as dots: done, current, or still to come.
struct Handshake: HTML {
// MARK: Properties
/// The last step taken.
let completed: Sale.Step
// MARK: Computed
var body: some HTML {
ol(
.class("handshake"),
.custom(name: "aria-label", value: "Handshake: \(completed.label) done")
) {
for step in Sale.Step.allCases {
li(.class(step <= completed ? "handshake__step handshake__step--done" : "handshake__step")) {
step.label
}
}
}
}
}
// MARK: - Extensions
extension Sale.Step {
// MARK: Computed
/// The step's name.
var label: String {
switch self {
case .agreed: "Agreed"
case .paid: "Paid"
case .sent: "Sent"
case .received: "Received"
case .rated: "Rated"
}
}
}
extension Sale {
// MARK: Computed
/// The record's number as shown, e.g. `CCN-48213`.
var reference: String {
"CCN-\(number)"
}
/// Where the sale stands, in a few words, and whether that is done.
var status: (label: String, tone: Chip.Tone) {
switch next {
case .paid: ("Awaiting payment", .warn)
case .sent: ("Awaiting dispatch", .warn)
case .received: ("On its way", .warn)
case .rated: ("Awaiting ratings", .neutral)
default: ("Complete", .good)
}
}
}
@@ -62,6 +62,8 @@ extension Icon {
case document
/// Filter lines.
case filter
/// A circled "i".
case info
/// A key.
case key
/// A padlock.
@@ -90,6 +92,7 @@ extension Icon.Glyph {
case .close: #"<path d="M6 6l12 12M18 6 6 18"></path>"#
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
case .info: #"<circle cx="12" cy="12" r="9"></circle><path d="M12 8v5M12 16h.01"></path>"#
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
@@ -203,11 +203,13 @@ private extension SiteHeader {
unit: "unread",
path: .Route.messages
)
a(
.class("site-header__text-link site-header__text-link--strong from-desktop"),
.href(.Route.sellerDashboard)
) {
"My shop"
if viewer.hasShop {
a(
.class("site-header__text-link site-header__text-link--strong from-tablet"),
.href(.Route.sellerDashboard)
) {
"My shop"
}
}
a(
.class("site-header__account"),
@@ -0,0 +1,73 @@
import Foundation
extension Date {
// MARK: Methods
/// How long until the date, in a few words: `47 h left`, `2 days left`, `under an hour left`.
/// - Parameter now: the moment counted from.
func countdown(
from now: Date
) -> String {
let hours = Int(timeIntervalSince(now) / 3_600)
switch hours {
case ..<1: return "under an hour left"
case ..<48: return "\(hours) h left"
default: return "\(hours / 24) days left"
}
}
/// The date as a stamp on a record: `today, 09:14`, `yesterday, 21:38`, `20 Sep, 14:05`, or with the year once it differs.
///
/// Read on Amsterdam's clock: the marketplace is NL-first.
/// - Parameter now: the moment "today" is judged from.
func stamp(
relativeTo now: Date
) -> String {
let time = formatted(Self.style.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))
if Self.calendar.isDate(self, inSameDayAs: now) {
return "today, \(time)"
}
if
let yesterday = Self.calendar.date(byAdding: .day, value: -1, to: now),
Self.calendar.isDate(self, inSameDayAs: yesterday)
{
return "yesterday, \(time)"
}
let isSameYear = Self.calendar.isDate(self, equalTo: now, toGranularity: .year)
let day = formatted(isSameYear ? Self.style.day().month(.abbreviated) : Self.style.day().month(.abbreviated).year())
return "\(day), \(time)"
}
}
// MARK: - Helpers
private extension Date {
// MARK: Constants
/// The calendar days are told apart by, on Amsterdam's clock.
static let calendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
return calendar
}()
/// The base format style: British English, on Amsterdam's clock.
static let style = Date.FormatStyle(
locale: .init(identifier: "en_GB"),
calendar: calendar,
timeZone: timeZone
)
static let timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
}
@@ -40,6 +40,14 @@ extension String {
/// The want list.
static let wants = "/wants"
/// The path an offer is answered at.
/// - Parameter id: the offer's identifier.
static func answer(
offer id: UUID
) -> String {
"\(sellerDashboard)/offers/\(id.uuidString.lowercased())"
}
/// The path buying a listing outright starts at.
/// - Parameter slug: the listing's URL slug.
static func buy(
@@ -48,6 +56,14 @@ extension String {
"\(listing(slug))/buy"
}
/// The path of a conversation.
/// - Parameter id: the conversation's identifier.
static func conversation(
_ id: UUID
) -> String {
"\(messages)/\(id.uuidString.lowercased())"
}
/// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
/// - Parameters:
/// - path: the action's own path.
@@ -93,6 +109,14 @@ extension String {
"\(listing(slug))/offer"
}
/// The path of a sale record.
/// - Parameter number: the record's number.
static func sale(
_ number: Int
) -> String {
"/sales/\(number)"
}
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
@@ -222,42 +222,62 @@ private extension ListingPage {
Unavailable("Save", modifier: "button--sm")
}
}
div(.class("buy-box__actions")) {
a(
.class("button button--primary button--lg"),
.href(gated(.Route.buy(listing.slug)))
) {
"Buy now"
if !listing.isAvailable {
p(.class("notice")) {
"This copy is sold. A sale was agreed on CCN."
}
if listing.acceptsOffers {
a(
.class("button button--outline button--lg"),
.href(gated(.Route.offer(listing.slug)))
) {
"Make an offer"
} else if viewer?.shopSlug == listing.shop.slug {
p(.class("notice")) {
"This is your listing. Offers on it arrive in your "
a(.href(.Route.sellerDashboard)) {
"seller dashboard"
}
"."
}
} else {
actions
}
}
}
/// The gated actions: buy, offer, message — and, signed out, why they lead to the sign-in.
@HTMLBuilder
var actions: some HTML {
div(.class("buy-box__actions")) {
a(
.class("button button--primary button--lg"),
.href(gated(.Route.buy(listing.slug)))
) {
"Buy now"
}
if listing.acceptsOffers {
a(
.class("button button--bare button--lg"),
.href(gated(.Route.message(listing: listing.slug)))
.class("button button--outline button--lg"),
.href(gated(.Route.offer(listing.slug)))
) {
Icon(.message, size: 16)
"Message seller"
"Make an offer"
}
}
if viewer == nil {
p(.class("gate-note")) {
Icon(.lock, size: 16)
span {
"Buying, offers, saving and messages need a signed-in account. "
a(
.class("gate-note__link"),
.href(.Route.signIn(returningTo: listing.path))
) {
"Sign in or join with a passkey"
}
" — browsing stays open to everyone."
a(
.class("button button--bare button--lg"),
.href(gated(.Route.message(listing: listing.slug)))
) {
Icon(.message, size: 16)
"Message seller"
}
}
if viewer == nil {
p(.class("gate-note")) {
Icon(.lock, size: 16)
span {
"Buying, offers, saving and messages need a signed-in account. "
a(
.class("gate-note__link"),
.href(.Route.signIn(returningTo: listing.path))
) {
"Sign in or join with a passkey"
}
" — browsing stays open to everyone."
}
}
}
@@ -0,0 +1,486 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The messages: the viewer's conversations in a rail, and the open one beside it — what was said, the offers made, and the sale agreed.
///
/// Below desktop the two take turns: the rail alone at the messages root, the conversation alone once one is open.
struct MessagesPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The side of the viewer's conversations the rail lists.
let box: Conversation.Side
/// Every conversation the viewer is a party to, the latest spoken in first.
let conversations: [Conversation]
/// The moment stamps are read relative to.
let now: Date
/// The conversation open beside the rail, or `nil` for none.
let open: Conversation?
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the messages screen.
/// - Parameters:
/// - conversations: every conversation the viewer is a party to, the latest spoken in first.
/// - open: the conversation open beside the rail, or `nil` (the default) for none.
/// - box: the side the rail lists, or `nil` (the default) for the open conversation's — buying, without one.
/// - 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(
conversations: [Conversation],
open: Conversation? = nil,
box: Conversation.Side? = nil,
viewer: Viewer? = nil,
now: Date = .init(),
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.box = box ?? open?.side ?? (conversations.contains { $0.side == .buying } || conversations.isEmpty ? .buying : .selling)
self.conversations = conversations
self.locale = .init(identifier: "en")
self.now = now
self.open = open
self.viewer = viewer
}
}
// MARK: - Page
extension MessagesPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class(open == nil ? "page messages" : "page messages messages--open")) {
rail
if let open {
thread(open)
} else {
div(.class("messages__thread messages__thread--none")) {
p(.class("type-body-sm")) {
conversations.isEmpty
? "No conversations yet. Message a seller from a listing, or make an offer."
: "Pick a conversation."
}
}
}
}
}
}
@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 {
"\(open.map { "\(name(in: $0)) · " } ?? "")Messages — CCN"
}
}
// MARK: - Rail
private extension MessagesPage {
var rail: some HTML {
aside(.class("messages__rail")) {
div(.class("messages__rail-head")) {
h1(.class("type-panel")) {
"Messages"
}
span(.class("type-small")) {
"\(conversations.count { $0.unreadCount > 0 }) unread"
}
}
div(.class("messages__boxes")) {
boxLink(.buying, title: "Buying")
boxLink(.selling, title: "Selling")
}
ul(.class("messages__list")) {
ForEach(conversations.filter { $0.side == box }) { conversation in
li {
summary(of: conversation)
}
}
}
}
}
/// A tab switching the rail between the viewer's sides, counting what is unread on it.
func boxLink(
_ side: Conversation.Side,
title: String
) -> some HTML {
let unread = conversations.count { $0.side == side && $0.unreadCount > 0 }
return a(
.class("pill"),
.href("\(String.Route.messages)?box=\(side.rawValue)")
) {
unread == 0 ? title : "\(title) · \(unread)"
}
.attributes(
.custom(name: "aria-current", value: "true"),
when: side == box
)
}
/// A conversation in the rail: who it is with, what about, and the latest said.
func summary(
of conversation: Conversation
) -> some HTML {
a(
.class("messages__item"),
.href(.Route.conversation(conversation.id))
) {
span(.class("monogram")) {
monogram(in: conversation)
}
span(.class("messages__item-text")) {
span(.class("messages__item-top")) {
strong {
name(in: conversation)
}
span(.class("type-small")) {
conversation.latest?.createdAt.stamp(relativeTo: now) ?? ""
}
}
span(.class("messages__item-about")) {
conversation.listing.map { "\($0.title) · \($0.chip.label) · \($0.priceCents.euros)" } ?? "About the shop"
}
span(.class("messages__item-preview")) {
preview(of: conversation)
}
}
if conversation.unreadCount > 0 {
span(.class("messages__unread")) {
"\(conversation.unreadCount)"
}
}
}
.attributes(
.custom(name: "aria-current", value: "page"),
when: conversation.id == open?.id
)
}
}
// MARK: - Thread
private extension MessagesPage {
/// The open conversation: the pinned listing, the entries, and the composer.
func thread(
_ conversation: Conversation
) -> some HTML {
section(.class("messages__thread")) {
a(
.class("messages__back until-desktop"),
.href("\(String.Route.messages)?box=\(conversation.side.rawValue)")
) {
"← All messages"
}
pinned(conversation)
ol(.class("messages__entries")) {
ForEach(Array(conversation.entries.enumerated())) { index, entry in
li(.class("entry entry--\(entry.kind.rawValue) \(isOwn(entry, in: conversation) ? "entry--own" : "entry--theirs")")) {
self.entry(entry, in: conversation, opensSale: index == conversation.entries.firstIndex { $0.saleNumber != nil })
}
}
}
if conversation.side == .buying {
replies(["Can you combine shipping?", "Pickup at Dutch Comic Con instead?"], in: conversation)
} else {
replies(["Thanks — I'll dispatch within two business days.", "Happy to send more photos."], in: conversation)
}
form(
.class("composer"),
.action(.Route.conversation(conversation.id)),
.method(.post)
) {
label(
.class("visually-hidden"),
.for("body")
) {
"Message"
}
textarea(
.class("input composer__input"),
.id("body"),
.name("body"),
.required,
.custom(name: "rows", value: "2"),
.custom(name: "maxlength", value: "2000"),
.placeholder("Write to \(name(in: conversation))…")
) {}
button(
.class("button button--primary"),
.type(.submit)
) {
"Send"
}
}
p(.class("type-small messages__safety")) {
"Keep the conversation on CCN — it's the record if anything goes wrong. Never share bank details in the open; use the sale record's payment note."
}
}
}
/// One entry: a bubble, an offer card, or an event line — the event that opened the sale carrying the way to its record.
@HTMLBuilder
func entry(
_ entry: Conversation.Entry,
in conversation: Conversation,
opensSale: Bool
) -> some HTML {
let who = isOwn(entry, in: conversation) ? "You" : name(in: conversation)
switch entry.kind {
case .text:
p(.class("entry__bubble")) {
entry.body
}
span(.class("type-small")) {
"\(who) · \(entry.createdAt.stamp(relativeTo: now))"
}
case .offer:
if let offer = entry.offer {
offerCard(offer, note: entry.body, in: conversation)
}
span(.class("type-small")) {
"\(who) · \(entry.createdAt.stamp(relativeTo: now))"
}
case .event:
p(.class("entry__event")) {
"\(who) \(entry.body) · \(entry.createdAt.stamp(relativeTo: now))"
}
if opensSale, let number = entry.saleNumber {
div(.class("notice notice--good entry__sale")) {
p {
strong {
"Sale record CCN-\(number) created"
}
}
p {
"Payment is settled between you off-platform. The buyer marks it paid on the sale record so the seller can dispatch."
}
a(
.class("button button--dark button--sm"),
.href(.Route.sale(number))
) {
"Open sale record"
}
}
}
}
}
/// An offer in the conversation. The seller answers an open one right here.
func offerCard(
_ offer: Offer,
note: String,
in conversation: Conversation
) -> some HTML {
div(.class("card offer-card")) {
div(.class("offer-card__head")) {
span(.class("type-eyebrow")) {
conversation.side == .buying ? "Your offer" : "Their offer"
}
Chip(offer.status.label, tone: offer.status.tone)
}
p(.class("type-stat")) {
offer.amountCents.euros
}
p(.class("type-small")) {
"asked \(offer.listing.priceCents.euros) · + \(offer.listing.shippingCents.euros) shipping to NL"
offer.status == .open ? " · \(offer.expiresAt.countdown(from: now))" : ""
}
if !note.isEmpty {
p(.class("offer-card__note")) {
"“\(note)”"
}
}
if offer.status == .open, conversation.side == .selling {
AnswerOffer(
offer: offer,
returnPath: .Route.conversation(conversation.id)
)
}
}
}
/// The listing pinned over the conversation, with where things stand.
func pinned(
_ conversation: Conversation
) -> some HTML {
header(.class("messages__pinned")) {
if let listing = conversation.listing {
Cover(listing.cover, modifier: "cover--thumb")
} else {
span(.class("monogram monogram--lg")) {
conversation.shop.monogram
}
}
div(.class("messages__pinned-text")) {
div(.class("messages__pinned-top")) {
if let listing = conversation.listing {
a(
.class("messages__pinned-title"),
.href(listing.path)
) {
listing.title
}
Chip(listing.chip.label, tone: listing.chip.tone, isCertified: listing.chip.isCertified)
} else {
a(
.class("messages__pinned-title"),
.href(conversation.shop.path)
) {
conversation.shop.name
}
}
if let number = conversation.saleNumber {
a(
.class("chip chip--good"),
.href(.Route.sale(number))
) {
"Agreed · CCN-\(number)"
}
} else if let offer = conversation.openOffer {
Chip("Offer open · \(offer.amountCents.euros)", tone: .warn)
} else if conversation.listing?.isAvailable == false {
Chip("Sold")
}
}
p(.class("type-small")) {
conversation.side == .buying
? "\(conversation.shop.name) · \(conversation.shop.standing)"
: "\(conversation.buyer.displayName) · buyer"
}
}
if
conversation.side == .buying,
conversation.openOffer == nil,
let listing = conversation.listing,
listing.isAvailable,
listing.acceptsOffers
{
a(
.class("button button--outline button--sm"),
.href(.Route.offer(listing.slug))
) {
"Make an offer"
}
}
}
}
/// One-tap replies: each a form posting its text.
func replies(
_ texts: [String],
in conversation: Conversation
) -> some HTML {
ul(.class("messages__replies")) {
ForEach(texts) { text in
li {
form(
.action(.Route.conversation(conversation.id)),
.method(.post)
) {
input(
.type(.hidden),
.name("body"),
.value(text)
)
button(
.class("pill"),
.type(.submit)
) {
text
}
}
}
}
}
}
}
// MARK: - Helpers
private extension MessagesPage {
/// Whether the viewer made the entry.
func isOwn(
_ entry: Conversation.Entry,
in conversation: Conversation
) -> Bool {
(entry.sender.id == conversation.buyer.id) == (conversation.side == .buying)
}
/// The other party's monogram.
func monogram(
in conversation: Conversation
) -> String {
conversation.side == .buying
? conversation.shop.monogram
: Viewer(conversation.buyer).initials
}
/// The other party's name.
func name(
in conversation: Conversation
) -> String {
conversation.side == .buying
? conversation.shop.name
: conversation.buyer.displayName
}
/// The latest entry in a line.
func preview(
of conversation: Conversation
) -> String {
guard let latest = conversation.latest else {
return "Nothing said yet."
}
switch latest.kind {
case .text: return latest.body
case .offer: return "Offer of \(latest.offer?.amountCents.euros ?? "")"
case .event: return "\(isOwn(latest, in: conversation) ? "You" : name(in: conversation)) \(latest.body)"
}
}
}
@@ -0,0 +1,324 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The offer sheet: an amount, a note, and what sending it commits the buyer to. In its buy mode, the confirmation of a purchase at the asking price.
struct OfferPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The amount the field is filled with, in euro cents.
let amountCents: Int
/// What went wrong with the last attempt, or `nil` when nothing did.
let error: String?
/// The listing offered on.
let listing: Listing
/// Whether the sheet makes an offer or buys outright.
let mode: Mode
/// The note the field is filled with.
let note: String
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the offer sheet for a listing.
/// - Parameters:
/// - listing: the listing offered on.
/// - mode: whether the sheet makes an offer or buys outright.
/// - amountCents: the amount the field is filled with, or `nil` (the default) for a tenth under the asking price.
/// - note: the note the field is filled with; empty by default.
/// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did.
/// - viewer: the signed-in viewer.
/// - 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(
listing: Listing,
mode: Mode,
amountCents: Int? = nil,
note: String = "",
error: String? = nil,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.amountCents = amountCents ?? Self.pick(10, under: listing.priceCents)
self.analytics = analytics
self.assetVersion = assetVersion
self.error = error
self.listing = listing
self.locale = .init(identifier: "en")
self.mode = mode
self.note = note
self.viewer = viewer
}
}
// MARK: - Enumerations
extension OfferPage {
/// What the sheet does.
enum Mode: Sendable {
/// Buys at the asking price.
case buy
/// Makes an offer under it.
case offer
}
}
// MARK: - Page
extension OfferPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(
active: listing.kind,
viewer: viewer
) {
div(.class("page sheet")) {
div(.class("card sheet__card")) {
div(.class("sheet__head")) {
Cover(listing.cover, modifier: "cover--thumb")
div {
h1(.class("sheet__title")) {
mode == .buy ? "Buy now" : "Make an offer"
}
p(.class("type-small")) {
"\(listing.title) · \(listing.chip.label) · asking \(listing.priceCents.euros) · \(listing.shop.name)"
}
}
}
if let error {
p(
.class("notice notice--bad"),
.custom(name: "role", value: "alert")
) {
error
}
}
if mode == .buy {
purchase
} else {
offer
}
a(
.class("sheet__cancel"),
.href(listing.path)
) {
"Cancel"
}
}
}
}
}
@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 {
"\(mode == .buy ? "Buy" : "Make an offer on") \(listing.title) — CCN"
}
}
// MARK: - Helpers
private extension OfferPage {
// MARK: Constants
/// The shares under the asking price offered as one-tap picks, in percent.
static let picks = [5, 10, 15, 20]
// MARK: Computed
var offer: some HTML {
form(
.class("sheet__form"),
.action(.Route.offer(listing.slug)),
.method(.post)
) {
div(.class("field")) {
label(
.class("field__label"),
.for("amount")
) {
"Your offer"
}
div(.class("amount")) {
span(
.class("amount__sign"),
.custom(name: "aria-hidden", value: "true")
) {
"€"
}
input(
.class("amount__input"),
.id("amount"),
.type(.text),
.name("amount"),
.value("\(amountCents / 100)"),
.required,
.custom(name: "inputmode", value: "decimal"),
.custom(name: "aria-describedby", value: "amount-hint")
)
}
// Links, not buttons: a pick reloads the sheet with its amount, so it works without scripts.
// A pick the seller would auto-decline is left out.
ul(.class("amount__picks")) {
for pick in Self.picks where Self.pick(pick, under: listing.priceCents) >= Offer.floor(forAsking: listing.priceCents) {
li {
a(
.class("pill"),
.href("\(String.Route.offer(listing.slug))?amount=\(Self.pick(pick, under: listing.priceCents) / 100)")
) {
"\(Self.pick(pick, under: listing.priceCents).euros) · −\(pick)%"
}
.attributes(
.custom(name: "aria-current", value: "true"),
when: Self.pick(pick, under: listing.priceCents) == amountCents
)
}
}
}
p(
.class("field__hint"),
.id("amount-hint")
) {
"Whole euros, up to the asking price. The seller auto-declines under "
strong {
Offer.floor(forAsking: listing.priceCents).euros
}
"."
}
}
div(.class("field")) {
label(
.class("field__label"),
.for("note")
) {
"Message "
span(.class("field__optional")) {
"(optional)"
}
}
textarea(
.class("input"),
.id("note"),
.name("note"),
.custom(name: "rows", value: "2"),
.custom(name: "maxlength", value: "500"),
.placeholder("Anything the seller should know — bundle, pickup, timing")
) {
note
}
}
div(.class("sheet__terms")) {
p {
strong {
"Total if accepted:"
}
" your offer + \(listing.shippingCents.euros) shipping to NL."
}
p {
"The offer is binding for "
strong {
"48 hours"
}
". If accepted, a sale record opens and you pay the seller directly. You can have one open offer per listing."
}
}
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
) {
"Send offer"
}
}
}
var purchase: some HTML {
form(
.class("sheet__form"),
.action(.Route.buy(listing.slug)),
.method(.post)
) {
dl(.class("sheet__sum")) {
dt {
"Asking price"
}
dd {
listing.priceCents.euros
}
dt {
"Tracked shipping to NL"
}
dd {
listing.shippingCents.euros
}
dt(.class("sheet__total")) {
"Total"
}
dd(.class("sheet__total")) {
(listing.priceCents + listing.shippingCents).euros
}
}
div(.class("sheet__terms")) {
p {
"Agreeing opens a "
strong {
"sale record"
}
" and takes the copy off sale. The declared condition is frozen at that moment."
}
p {
"You pay \(listing.shop.name) directly — CCN handles no money. Mark the sale paid on the record so the seller can dispatch."
}
}
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
) {
"Agree to buy for \((listing.priceCents + listing.shippingCents).euros)"
}
}
}
// MARK: Methods
/// The amount a share under an asking price, rounded to a whole euro, in euro cents.
static func pick(
_ percent: Int,
under askingCents: Int
) -> Int {
Int((Double(askingCents) * Double(100 - percent) / 10_000).rounded()) * 100
}
}
@@ -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
}
}
}
}
@@ -0,0 +1,295 @@
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
}
}
}
}
}
@@ -145,7 +145,7 @@ extension SignInPage: Page {
}
}
p(.class("notice notice--warn")) {
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it."
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
}
p(.class("gate__recover")) {
a(.href(.Route.recover)) {
@@ -67,6 +67,7 @@ extension StaticFile {
.signIn,
.site,
.sitemap,
.trade,
]
}
@@ -158,4 +159,7 @@ extension StaticFile {
/// The `sitemap.xml` crawler sitemap.
static let sitemap = Self("sitemap", as: .xml)
/// The `css/trade.css` stylesheet shared by the offer sheet, the messages, the sale record, and the seller dashboard.
static let trade = Self("trade", as: .css)
}
@@ -8,6 +8,9 @@ struct Viewer: Sendable {
/// The viewer's name.
let displayName: String
/// The slug of the shop the viewer owns, or `nil` when they own none.
let shopSlug: String?
/// The number of new listings matching the viewer's want list.
let newWantMatches: Int
@@ -19,14 +22,17 @@ struct Viewer: Sendable {
/// Creates a viewer.
/// - Parameters:
/// - displayName: the viewer's name.
/// - shopSlug: the slug of the shop the viewer owns, or `nil` (the default) when they own none.
/// - newWantMatches: the number of new listings matching the viewer's want list; none by default.
/// - unreadMessages: the number of messages the viewer has not read yet; none by default.
init(
displayName: String,
shopSlug: String? = nil,
newWantMatches: Int = 0,
unreadMessages: Int = 0
) {
self.displayName = displayName
self.shopSlug = shopSlug
self.newWantMatches = newWantMatches
self.unreadMessages = unreadMessages
}
@@ -40,15 +46,29 @@ extension Viewer {
// MARK: Initializers
/// Creates the viewer an account signs in as.
/// - Parameter user: the signed-in account.
/// - Parameters:
/// - user: the signed-in account.
/// - shopSlug: the slug of the shop the account owns, or `nil` (the default) when it owns none.
/// - unreadMessages: the number of conversations holding something the account has not seen; none by default.
init(
_ user: User
_ user: User,
shopSlug: String? = nil,
unreadMessages: Int = 0
) {
self.init(displayName: user.displayName)
self.init(
displayName: user.displayName,
shopSlug: shopSlug,
unreadMessages: unreadMessages
)
}
// MARK: Computed
/// Whether the viewer owns a shop.
var hasShop: Bool {
shopSlug != nil
}
/// The first word of the display name, shown beside the avatar.
var firstName: String {
displayName
@@ -0,0 +1,564 @@
import Foundation
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the path to a sale: the offer sheet, the messages, the sale records, and the seller dashboard.
///
/// Every route is for a signed-in account; signed out, a `GET` leads to the sign-in screen and back, and a `POST` to the sign-in screen alone.
/// The rules themselves — who may offer, answer, or take a step — live in `Persistence`'s `TradeRepository`.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct TradeController<Context: RequestContext> {
// MARK: Properties
private let analytics: Analytics?
private let assetVersion: String?
/// The repository listings and shops are read through.
private let catalogue: CatalogueRepository
/// The store the signed-in account is read from.
private let sessions: SessionStore
/// The repository conversations, offers, and sales are read and written through.
private let trade: TradeRepository
// MARK: Initializers
/// Creates a trade controller.
/// - Parameters:
/// - trade: the repository conversations, offers, and sales are read and written through.
/// - catalogue: the repository listings and shops are read through.
/// - sessions: the store the signed-in account is read from.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
trade: TradeRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
self.trade = trade
}
}
// MARK: - RouterController
extension TradeController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(.Trade.offer, use: showSheet(.offer))
routes.post(.Trade.offer, use: makeOffer)
routes.get(.Trade.buy, use: showSheet(.buy))
routes.post(.Trade.buy, use: buy)
routes.get(.Trade.messages, use: messages)
routes.get(.Trade.newConversation, use: startConversation)
routes.get(.Trade.conversation, use: conversation)
routes.post(.Trade.conversation, use: post)
routes.get(.Trade.sale, use: sale)
routes.post(.Trade.sale, use: takeStep)
routes.get(.Trade.dashboard, use: dashboard)
routes.post(.Trade.answer, use: answer)
return routes
}
}
// MARK: - Offers
private extension TradeController {
/// Handles buying a listing at its asking price: opens the sale record and leads to it.
@Sendable
func buy(
request: Request,
context: Context
) async throws -> Response {
let slug = try context.parameters.require(.slug)
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
}
do {
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal)
} catch let error as TradeError {
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
}
}
/// Handles an offer: makes it, and leads to the conversation it was made in. One that breaks a rule gets the sheet back, saying why.
@Sendable
func makeOffer(
request: Request,
context: Context
) async throws -> Response {
let slug = try context.parameters.require(.slug)
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(OfferForm.self, from: request, context: context)
let amountCents = Self.cents(form.amount)
do {
let id = try await trade.makeOffer(
onListing: slug,
buyer: user,
amountCents: amountCents ?? 0,
note: form.note ?? ""
)
return .redirect(to: .Route.conversation(id), type: .normal)
} catch let error as TradeError {
return try await sheet(
.offer,
slug: slug,
amountCents: amountCents,
note: form.note ?? "",
error: error,
viewer: viewer,
request: request,
context: context
)
}
}
/// Builds the handler showing the sheet in one of its modes. A listing that cannot be offered on, or bought, leads back to itself.
func showSheet(
_ mode: OfferPage.Mode
) -> @Sendable (Request, Context) async throws -> Response {
{ request, context in
let slug = try context.parameters.require(.slug)
guard let (_, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
return try await sheet(
mode,
slug: slug,
amountCents: request.uri.queryParameters.get("amount").flatMap(Self.cents),
viewer: viewer,
request: request,
context: context
)
}
}
/// Renders the sheet — with a `422` when it reports an error — or leads back to a listing the sheet is not for.
func sheet(
_ mode: OfferPage.Mode,
slug: String,
amountCents: Int? = nil,
note: String = "",
error: TradeError? = nil,
viewer: Viewer,
request: Request,
context: Context
) async throws -> Response {
guard let listing = try await catalogue.listing(slug: slug) else {
throw HTTPError(.notFound)
}
guard
listing.isAvailable,
viewer.shopSlug != listing.shop.slug,
mode == .buy || listing.acceptsOffers
else {
return .redirect(to: listing.path, type: .found)
}
return HTMLResponse(
status: error == nil ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
OfferPage(
listing: listing,
mode: mode,
amountCents: amountCents,
note: note,
error: error?.explanation,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Messages
private extension TradeController {
/// Handles a request for a conversation, marking it read.
@Sendable
func conversation(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard
let id = context.parameters.get(.id, as: UUID.self),
let open = try await trade.conversation(id: id, for: user)
else {
throw HTTPError(.notFound)
}
return try await messagesPage(open: open, user: user, request: request, context: context)
}
/// Handles a request for the messages, with no conversation open.
@Sendable
func messages(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
return try await messagesPage(open: nil, user: user, request: request, context: context)
}
/// Renders the messages. The viewer is read after the open conversation was marked read, so the header's count is current.
func messagesPage(
open: Conversation?,
user: User,
request: Request,
context: Context
) async throws -> Response {
let conversations = try await trade.conversations(for: user)
let viewer = await sessions.viewer(for: request)
let box = request.uri.queryParameters.get("box").flatMap(Conversation.Side.init)
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
MessagesPage(
conversations: conversations,
open: open,
box: box,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a message posted to a conversation.
@Sendable
func post(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let id = context.parameters.get(.id, as: UUID.self) else {
throw HTTPError(.notFound)
}
let form = try await URLEncodedFormDecoder().decode(MessageForm.self, from: request, context: context)
do {
try await trade.post(form.body, to: id, from: user)
} catch is TradeError {
throw HTTPError(.notFound)
}
return .redirect(to: .Route.conversation(id), type: .normal)
}
/// Handles the start of a conversation about a listing, or with a shop at large: finds or starts it, and leads to it.
@Sendable
func startConversation(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
let listing = request.uri.queryParameters.get("listing")
let shop = request.uri.queryParameters.get("shop")
do {
if let listing {
return .redirect(to: .Route.conversation(try await trade.conversation(aboutListing: listing, buyer: user)), type: .found)
}
if let shop {
return .redirect(to: .Route.conversation(try await trade.conversation(withShop: shop, buyer: user)), type: .found)
}
} catch TradeError.ownListing {
return .redirect(to: .Route.sellerDashboard, type: .found)
} catch is TradeError {
throw HTTPError(.notFound)
}
return .redirect(to: .Route.messages, type: .found)
}
}
// MARK: - Sales
private extension TradeController {
/// Handles a request for a sale record; one the account is no party to is not found.
@Sendable
func sale(
request: Request,
context: Context
) async throws -> Response {
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard
let number = context.parameters.get(.number, as: Int.self),
let sale = try await trade.sale(number: number, for: user),
let role = sale.role(of: user.id)
else {
throw HTTPError(.notFound)
}
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SalePage(
sale: sale,
role: role,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a step of a sale's handshake. One that is not the account's to take now changes nothing, and leads back to the record.
@Sendable
func takeStep(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let number = context.parameters.get(.number, as: Int.self) else {
throw HTTPError(.notFound)
}
let form = try await URLEncodedFormDecoder().decode(StepForm.self, from: request, context: context)
let action: Sale.Action? = switch form.step {
case "paid": .markPaid(note: form.note ?? "")
case "sent": .markSent(note: form.note ?? "")
case "received": .markReceived
case "rated": .rate(stars: form.stars ?? 0)
default: nil
}
if let action {
_ = try? await trade.perform(action, onSale: number, by: user)
}
return .redirect(to: .Route.sale(number), type: .normal)
}
}
// MARK: - Selling
private extension TradeController {
/// Handles a seller's answer to an offer: accepting leads to the sale record opened, declining back to where the answer was given.
@Sendable
func answer(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let id = context.parameters.get(.id, as: UUID.self) else {
throw HTTPError(.notFound)
}
let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context)
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
return .redirect(
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
type: .normal
)
}
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
@Sendable
func dashboard(
request: Request,
context: Context
) async throws -> Response {
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard let shop = try await trade.shop(ownedBy: user) else {
return .redirect(to: .Route.openShop, type: .found)
}
let offers = try await trade.openOffers(for: user)
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SellerDashboardPage(
shop: shop,
offers: offers,
sales: sales,
listings: listings,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Helpers
private extension TradeController {
/// The signed-in account a request comes from, and the viewer the pages show of it; or `nil` signed out.
func account(
of request: Request
) async -> (User, Viewer)? {
guard
let user = await sessions.user(for: request),
let viewer = await sessions.viewer(for: request)
else {
return nil
}
return (user, viewer)
}
/// An amount typed in euros — `215`, `€215`, `215,50` — in euro cents, or `nil` when it is not one.
static func cents(
_ amount: String
) -> Int? {
let cleaned = amount
.replacingOccurrences(of: "€", with: "")
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard
let euros = Double(cleaned),
euros.isFinite,
euros >= 0,
euros < 1_000_000
else {
return nil
}
return Int((euros * 100).rounded())
}
}
// MARK: - Structs
/// The fields the offer sheet posts.
private struct OfferForm: Decodable {
let amount: String
let note: String?
}
/// The field the composer posts.
private struct MessageForm: Decodable {
let body: String
}
/// The fields a step of the handshake posts.
private struct StepForm: Decodable {
let step: String
let note: String?
let stars: Int?
}
/// The fields an answer to an offer posts.
private struct AnswerForm: Decodable {
let answer: String
let `return`: String?
}
// MARK: - Extensions
extension TradeError {
// MARK: Computed
/// Why the step was turned down, as the person taking it reads it.
var explanation: String {
switch self {
case .invalidAmount: "Enter an amount above zero, up to the asking price. To pay the asking price, use Buy now."
case .listingUnavailable: "This copy is no longer for sale."
case .notAllowed: "That step isn't yours to take right now."
case .offerAlreadyOpen: "You already have an open offer on this listing. Wait for the seller's answer, or for it to expire."
case .offerTooLow(let floorCents): "The seller auto-declines offers under \(floorCents.euros)."
case .offersNotAccepted: "This seller doesn't take offers on this copy."
case .ownListing: "This is your own listing."
case .shopUnattended: "This shop can't take orders right now."
}
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``TradeController`` route paths.
enum Trade {
static let answer: RouterPath = .init("\(String.Route.sellerDashboard)/offers/:\(String.id)")
static let buy: RouterPath = .init(.Route.buy(":\(String.slug)"))
static let conversation: RouterPath = .init("\(String.Route.messages)/:\(String.id)")
static let dashboard: RouterPath = .init(.Route.sellerDashboard)
static let messages: RouterPath = .init(.Route.messages)
static let newConversation: RouterPath = .init("\(String.Route.messages)/new")
static let offer: RouterPath = .init(.Route.offer(":\(String.slug)"))
static let sale: RouterPath = .init(.Route.sale(0).replacingOccurrences(of: "0", with: ":\(String.number)"))
}
}
private extension String {
/// The name of the route parameter carrying a conversation's or an offer's identifier.
static let id = "id"
/// The name of the route parameter carrying a sale's number.
static let number = "number"
/// The name of the route parameter carrying a listing's slug.
static let slug = "slug"
}
@@ -10,13 +10,21 @@ public actor SessionStore {
// MARK: Properties
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
private let trade: TradeRepository?
/// The accounts signed in, by session token.
private var users: [String: User] = [:]
// MARK: Initializers
/// Creates an empty session store.
public init() {}
/// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
public init(
trade: TradeRepository? = nil
) {
self.trade = trade
}
// MARK: Methods
@@ -88,13 +96,26 @@ extension SessionStore {
request.cookies[cookieName]?.value
}
/// The signed-in viewer the request comes from, or `nil` when it carries no live session.
/// The signed-in account the request comes from, or `nil` when it carries no live session.
func user(
for request: Request
) -> User? {
Self.token(in: request).flatMap(user)
}
/// The signed-in viewer the request comes from, with what the header shows of them, or `nil` when it carries no live session.
func viewer(
for request: Request
) -> Viewer? {
Self.token(in: request)
.flatMap(user)
.map(Viewer.init)
) async -> Viewer? {
guard let user = user(for: request) else {
return nil
}
return .init(
user,
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
)
}
}