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