487 lines
16 KiB
Swift
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)"
|
|
}
|
|
}
|
|
|
|
}
|