Files
ccn/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift
T

487 lines
16 KiB
Swift

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