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

325 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}