Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -0,0 +1,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)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user