Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -0,0 +1,324 @@
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user