Implemented phase 3 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:11:25 +02:00
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
}
}