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
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user