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