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 == .expired, conversation.side == .buying, offer.listing.isAvailable { p(.class("type-small")) { "\(conversation.shop.name) didn't answer within 48 hours. The listing is still available at \(offer.listing.priceCents.euros)." } div(.class("answer")) { a( .class("button button--primary button--sm"), .href(.Route.offer(offer.listing.slug)) ) { "Offer again" } a( .class("button button--sm"), .href(.Route.buy(offer.listing.slug)) ) { "Buy at \(offer.listing.priceCents.euros)" } } } 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)" } } }