325 lines
10 KiB
Swift
325 lines
10 KiB
Swift
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
|
||
}
|
||
|
||
}
|