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

This commit is contained in:
javier committed 2026-09-21 12:55:35 +02:00
1 parent 95e0a455e8
commit 9403931109
42 files changed
+3407 -36

No files matched your search

@@ -53,7 +53,7 @@ struct ListingCard: HTML {
value: "true"
)
) {
Cover(listing.cover)
ListingImage(listing)
}
div {
h3(.class("type-card-title")) {
@@ -0,0 +1,44 @@
import Elementary
import Persistence
/// What a listing looks like: its first photo, or its placeholder cover while it has none.
struct ListingImage: HTML {
// MARK: Properties
/// The listing shown.
let listing: Listing
/// A modifier class positioning or sizing the image, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a listing's image.
/// - Parameters:
/// - listing: the listing shown.
/// - modifier: a modifier class positioning or sizing the image, or `nil` (the default) for none.
init(
_ listing: Listing,
modifier: String? = nil
) {
self.listing = listing
self.modifier = modifier
}
// MARK: Computed
var body: some HTML {
if let photo = listing.photos.first {
img(
.class(["photo", modifier].compactMap(\.self).joined(separator: " ")),
.src(photo),
.alt("Photo of \(listing.title), front"),
.custom(name: "loading", value: "lazy")
)
} else {
Cover(listing.cover, modifier: modifier)
}
}
}
@@ -0,0 +1,81 @@
/// The grading guide's vocabulary: the scale raw books are graded on, and the defects every listing declares.
///
/// Single-sourced: the public guide prints it, the listing form offers it, and the form's checks apply it.
enum Grading {
// MARK: Constants
/// The conditions a collected edition — ungraded — is sold in, best first.
static let conditions = ["Sealed", "Like new", "Very good", "Good", "Acceptable"]
/// The companies that grade and slab.
static let graders = ["CGC", "CBCS", "PGX"]
/// The highest grade a raw book with a colour-breaking crease can carry.
static let creaseCap = 7.0
/// The page qualities a grader states, best first.
static let pageQualities = ["White", "Off-white to white", "Off-white", "Cream"]
/// The scale, best first: each grade and its code.
static let scale: [(grade: Double, code: String)] = [
(10.0, "GM"), (9.8, "NM/M"), (9.6, "NM+"), (9.4, "NM"), (9.2, "NM-"), (9.0, "VF/NM"),
(8.5, "VF+"), (8.0, "VF"), (7.5, "VF-"), (7.0, "FN/VF"), (6.5, "FN+"), (6.0, "FN"),
(5.0, "VG/FN"), (4.0, "VG"), (3.0, "GD/VG"), (2.0, "GD"), (1.0, "FR"), (0.5, "PR"),
]
/// The grades the guide illustrates: what each allows, and what rules a book out of it.
static let references: [(grade: Double, name: String, allowed: String, excluded: String)] = [
(9.8, "Near mint / mint", "Nearly perfect. Flat, glossy, sharp corners, tight staples. Up to one tiny bindery tear.", "Any spine stress you can see without tilting the book. Any crease."),
(9.4, "Near mint", "A few very small spine ticks, a light corner bend. Full gloss.", "Colour breaks. Writing. Foxing."),
(8.0, "Very fine", "Light wear: a few spine ticks, minor corner blunting, slight gloss loss.", "Colour-breaking creases longer than 1 cm. Tears."),
(6.5, "Fine +", "Moderate wear. A colour-breaking crease or two, some spine roll, small arrival date is fine.", "Missing pieces. Detached cover."),
(4.0, "Very good", "Well read. Multiple creases, rounded corners, possible small tear or staple rust.", "Water damage across the cover. Missing pages."),
(2.0, "Good", "Complete but heavily worn. Tears, writing, cover may be loose at one staple.", "Anything missing — that's Fair or Poor."),
]
}
// MARK: - Enumerations
extension Grading {
/// A box on the defect checklist.
enum Defect: String, CaseIterable, Sendable {
case spineStress = "spine-stress"
case crease
case cornerWear = "corner-wear"
case foxing
case tears
case writing
case detached
case caseScuffs = "case-scuffs"
/// What the box means.
var definition: String {
switch self {
case .spineStress: "Small non-colour-breaking lines along the spine from opening the book."
case .crease: "A fold that has cracked the ink. Caps a raw book at 7.0 or below."
case .cornerWear: "Rounded or frayed corners."
case .foxing: "Brown spots or tide marks; usually humidity."
case .tears: "Any loss of paper. Describe size and location."
case .writing: "Arrival dates, names, distributor stamps."
case .detached: "Cover loose at one or both staples; rusted staples."
case .caseScuffs: "Slabs only: scratches or cracks on the holder, not the book."
}
}
/// The box's name, as listings declare it.
var label: String {
switch self {
case .spineStress: "Spine stress / ticks"
case .crease: "Colour-breaking crease"
case .cornerWear: "Corner wear / blunting"
case .foxing: "Foxing / staining"
case .tears: "Tears or missing pieces"
case .writing: "Writing / stamps"
case .detached: "Detached cover / staples"
case .caseScuffs: "Case scuffs / cracks"
}
}
}
}
@@ -0,0 +1,159 @@
import Elementary
import Foundation
import Infrastructure
/// The public grading guide: the scale raw books are graded on, reference grades, and the defect checklist every listing answers.
struct GradingGuidePage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the grading guide.
/// - Parameters:
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
/// - 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(
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = .init(identifier: "en")
self.viewer = viewer
}
}
// MARK: - Page
extension GradingGuidePage: Page {
// MARK: Constants
/// The path the page is served at.
static let path = String.Route.gradingGuide
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page guide")) {
div(.class("guide__head")) {
p(.class("type-eyebrow")) {
"CCN grading guide · public"
}
h1(.class("type-h1")) {
"How CCN sellers grade raw books"
}
p(.class("type-lead")) {
"Slabbed books carry the grader's number and a cert you can verify. Raw books are graded by the seller against this scale, and every defect on the checklist below must be declared. A declared defect is never grounds for a dispute; an undeclared one always is."
}
}
ol(
.class("guide__scale"),
.custom(name: "aria-label", value: "The grading scale, best first")
) {
ForEach(Grading.scale) { step in
li(.class("guide__grade")) {
strong {
step.grade.gradeLabel
}
span {
step.code
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"Reference grades"
}
ul(.class("guide__references")) {
ForEach(Grading.references) { reference in
li(.class("card guide__reference")) {
div(.class("guide__reference-grade")) {
span(.class("type-stat")) {
reference.grade.gradeLabel
}
span(.class("type-label")) {
reference.name
}
}
// The design leaves the example photos as placeholders; so does the prototype.
div(
.class("guide__photo"),
.custom(name: "aria-hidden", value: "true")
) {
"Photo: \(reference.grade.gradeLabel) example"
}
div {
h3(.class("type-eyebrow guide__allowed")) {
"Allowed"
}
p(.class("type-body-sm")) {
reference.allowed
}
}
div {
h3(.class("type-eyebrow guide__excluded")) {
"Not at this grade"
}
p(.class("type-body-sm")) {
reference.excluded
}
}
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"The defect checklist, defined"
}
p(.class("type-body-sm guide__intro")) {
"These are the eight boxes on every listing form. Tick everything that applies and say where it is; the photos should show it."
}
dl(.class("guide__defects")) {
ForEach(Grading.Defect.allCases) { defect in
div(.class("card guide__defect")) {
dt(.class("type-label")) {
defect.label
}
dd(.class("type-caption")) {
defect.definition
}
}
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.guide]
}
var summary: String? {
"The scale CCN sellers grade raw comics on, with reference grades and the defect checklist every listing declares."
}
var title: String {
"Grading guide — CCN"
}
}
@@ -62,7 +62,21 @@ extension ListingPage: Page {
breadcrumb
div(.class("listing__body")) {
div(.class("listing__media")) {
Cover(listing.cover, modifier: "cover--hero")
ListingImage(listing, modifier: "cover--hero")
if listing.photos.count > 1 {
ul(.class("listing__photos")) {
ForEach(Array(listing.photos.dropFirst())) { photo in
li {
img(
.class("photo"),
.src(photo),
.alt("Another photo of \(listing.title)"),
.custom(name: "loading", value: "lazy")
)
}
}
}
}
}
div(.class("listing__main")) {
div(.class("listing__intro")) {
@@ -300,6 +314,7 @@ private extension ListingPage {
("Printing", listing.printingDetail),
(listing.grade == nil ? "Condition" : "Grade", grade),
("Page quality", listing.pageQuality),
("Declared defects", listing.defects.isEmpty ? nil : listing.defects.joined(separator: "; ")),
("Key notes", listing.keyNotes),
("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"),
].compactMap { term, value in
@@ -0,0 +1,445 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The new-listing form: what the copy is, its condition against the grading guide, photos of it, and its price.
///
/// Every field applies to some format or grading; without scripts they all show, labelled by what they are for, and the server checks what
/// applies. With scripts, `js/shared.js` hides the ones that do not.
struct NewListingPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The values the form is filled with.
let form: ListingForm
/// What is wrong with the form, by field.
let problems: [ListingForm.Field: String]
/// The viewer's shop.
let shop: Shop
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the new-listing form.
/// - Parameters:
/// - shop: the viewer's shop.
/// - form: the values the form is filled with; an empty form by default.
/// - problems: what is wrong with the form, by field; nothing by default.
/// - 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(
shop: Shop,
form: ListingForm = .init(),
problems: [ListingForm.Field: String] = [:],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.form = form
self.locale = .init(identifier: "en")
self.problems = problems
self.shop = shop
self.viewer = viewer
}
}
// MARK: - Page
extension NewListingPage: Page {
// MARK: Constants
/// The path the page is served at, and the form posted to.
static let path = "\(String.Route.sellerDashboard)/new"
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page listing-form")) {
div(.class("listing-form__head")) {
p(.class("type-eyebrow")) {
"\(shop.name) · seller tools"
}
h1(.class("type-h1")) {
"New listing"
}
}
if !problems.isEmpty {
div(
.class("notice notice--bad"),
.custom(name: "role", value: "alert")
) {
p {
strong {
problems.count == 1 ? "One thing to fix before publishing." : "\(problems.count) things to fix before publishing."
}
form.value(.photos) == nil ? " Photos have to be picked again: a browser never keeps a chosen file." : ""
}
}
}
Elementary.form(
.class("listing-form__form"),
.action(Self.path),
.method(.post),
.custom(name: "enctype", value: "multipart/form-data")
) {
what
condition
photos
price
div(.class("listing-form__submit")) {
a(
.class("button"),
.href(.Route.sellerDashboard)
) {
"Cancel"
}
button(
.class("button button--primary button--lg"),
.type(.submit)
) {
"Publish listing"
}
}
}
}
}
}
@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 {
"New listing · \(shop.name) — CCN"
}
}
// MARK: - Sections
private extension NewListingPage {
var what: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"1. What is it"
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Format"
}
div(.class("segmented")) {
ForEach(Format.allCases) { format in
label(.class("segmented__option")) {
input(
.type(.radio),
.name(ListingForm.Field.format.rawValue),
.value(format.rawValue)
)
.attributes(.checked, when: form.has(.format, value: format.rawValue))
span {
format.singular
}
}
}
}
problem(.format)
}
div(.class("listing-form__grid")) {
text(.series, "Series or title", placeholder: "Nightward", span: 2)
text(.issue, "Issue or volume", placeholder: "1", hint: "Required for single issues")
text(.publisher, "Publisher", placeholder: "Meridian Comics", span: 2)
text(.year, "Cover year", placeholder: "2019", numeric: true)
select(.printing, "Printing", options: [("1", "1st printing"), ("2", "2nd printing"), ("3", "3rd printing"), ("0", "Later / unknown")])
select(.edition, "Edition", options: [("", "Not applicable"), ("Direct", "Direct"), ("Newsstand", "Newsstand")], scope: "single-issues")
text(.keyNotes, "Key notes", placeholder: "1st app. of the Nightward", hint: "Leave empty unless the issue is a key")
}
}
}
var condition: some HTML {
section(.class("card listing-form__section")) {
div(.class("section-head")) {
h2(.class("type-panel")) {
"2. Condition"
}
a(
.class("link-ink"),
.href(.Route.gradingGuide)
) {
"CCN grading guide"
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "single-issues")
) {
p(.class("type-eyebrow")) {
"Single issues"
}
fieldset(.class("listing-form__modes")) {
legend(.class("visually-hidden")) {
"Slabbed or raw"
}
mode(.slabbed, "Slabbed (graded)", "CGC, CBCS or PGX. Buyers see the cert number to verify.")
mode(.raw, "Raw (seller-graded)", "You grade it against the guide and declare every defect.")
}
div(.class("listing-form__grid")) {
select(.grade, "Grade", options: [("", "Pick a grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") })
select(.grader, "Grader", options: [("", "—")] + Grading.graders.map { ($0, $0) }, scope: "slabbed")
select(.pageQuality, "Page quality", options: [("", "Not stated")] + Grading.pageQualities.map { ($0, $0) }, scope: "slabbed")
text(.cert, "Cert number", placeholder: "4312087-002", scope: "slabbed")
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "collected")
) {
p(.class("type-eyebrow")) {
"Manga, trade paperbacks and hardcovers"
}
div(.class("listing-form__grid")) {
select(.condition, "Condition", options: [("", "Pick a condition")] + Grading.conditions.map { ($0, $0) })
}
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Defects"
}
p(.class("field__hint")) {
"Tick everything that applies. A buyer can't dispute a defect you declared; an undeclared one is always grounds."
}
div(.class("listing-form__checks")) {
ForEach(Grading.Defect.allCases) { defect in
label(.class("check")) {
input(
.type(.checkbox),
.name(ListingForm.Field.defects.rawValue),
.value(defect.rawValue)
)
.attributes(.checked, when: form.has(.defects, value: defect.rawValue))
defect == .caseScuffs ? "\(defect.label) (slab)" : defect.label
}
}
}
problem(.defects)
}
}
}
var photos: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"3. Photos of this copy"
}
div(.class("field")) {
label(
.class("field__label"),
.for("photos")
) {
"Up to \(PhotoStore.maxCount) photos — front first, then back, spine, and the label for slabs"
}
input(
.class("listing-form__files"),
.id("photos"),
.type(.file),
.name(ListingForm.Field.photos.rawValue),
.custom(name: "accept", value: "image/jpeg,image/png,image/webp"),
.custom(name: "multiple", value: "")
)
p(.class("field__hint")) {
"JPEG, PNG or WebP, up to 3 MB each. No stock images. Without photos the listing shows a placeholder cover."
}
problem(.photos)
}
div(.class("field")) {
label(
.class("field__label"),
.for("notes")
) {
"Seller's notes (provenance, case condition, anything a photo can't show)"
}
textarea(
.class("input"),
.id("notes"),
.name(ListingForm.Field.notes.rawValue),
.custom(name: "rows", value: "3"),
.custom(name: "maxlength", value: "1000")
) {
form.value(.notes) ?? ""
}
problem(.notes)
}
}
}
var price: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"4. Price and shipping"
}
div(.class("listing-form__grid")) {
text(.price, "Asking price (EUR)", placeholder: "240", numeric: true)
select(.offers, "Offers", options: [("1", "Accept offers · auto-decline under 80%"), ("0", "Fixed price, no offers")])
text(.shipping, "Tracked shipping to NL (EUR)", placeholder: "6", numeric: true)
}
p(.class("gate-note")) {
Icon(.info, size: 16)
span {
"Ships from \(shop.location) · \(shop.handlingDays) business days handling. Payment is settled between you and the buyer off-platform; CCN records the sale, the agreed price and shipping, and holds the message history for both sides."
}
}
}
}
}
// MARK: - Fields
private extension NewListingPage {
/// A way of grading a single issue, as a radio card.
func mode(
_ mode: Grading.Mode,
_ title: String,
_ text: String
) -> some HTML {
label(.class("listing-form__mode")) {
input(
.type(.radio),
.name(ListingForm.Field.grading.rawValue),
.value(mode.rawValue)
)
.attributes(.checked, when: form.has(.grading, value: mode.rawValue))
span {
strong {
title
}
span(.class("type-small")) {
text
}
}
}
}
/// What is wrong with a field, tied to it by its identifier; nothing when it is fine.
@HTMLBuilder
func problem(
_ field: ListingForm.Field
) -> some HTML {
if let problem = problems[field] {
p(
.class("field__error"),
.id("\(field.rawValue)-error")
) {
problem
}
}
}
/// A select, showing the form's value. A scoped one applies to one format or grading only.
func select(
_ field: ListingForm.Field,
_ title: String,
options: [(value: String, label: String)],
scope: String? = nil
) -> some HTML {
div(.class("field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
Elementary.select(
.class("select select--block"),
.id(field.rawValue),
.name(field.rawValue)
) {
ForEach(options) { option in
Elementary.option(.value(option.value)) {
option.label
}
.attributes(.selected, when: form.value(field) == option.value)
}
}
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
/// A text field, showing the form's value.
func text(
_ field: ListingForm.Field,
_ title: String,
placeholder: String,
hint: String? = nil,
numeric: Bool = false,
span: Int = 1,
scope: String? = nil
) -> some HTML {
div(.class(span == 2 ? "field field--wide" : "field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
input(
.class("input"),
.id(field.rawValue),
.type(.text),
.name(field.rawValue),
.value(form.value(field) ?? ""),
.placeholder(placeholder)
)
.attributes(.custom(name: "inputmode", value: "numeric"), when: numeric)
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
if let hint, problems[field] == nil {
p(.class("field__hint")) {
hint
}
}
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
}
@@ -86,11 +86,19 @@ extension SellerDashboardPage: Page {
summary
}
}
a(
.class("button button--outline"),
.href(shop.path)
) {
"View public shop"
div(.class("sell__actions")) {
a(
.class("button button--outline"),
.href(shop.path)
) {
"View public shop"
}
a(
.class("button button--primary"),
.href(NewListingPage.path)
) {
"New listing"
}
}
}
ul(.class("tiles")) {
@@ -5,7 +5,8 @@ import Persistence
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
///
/// The prototype stubs the passkey ceremony — see ``SessionController``.
/// A real passkey creates an account, or signs in one created with a passkey — see ``PasskeyController``. The demo accounts have none, so
/// the stubbed demo sign-in stays beside it — see ``SessionController``.
struct SignInPage {
// MARK: Properties
@@ -86,20 +87,29 @@ extension SignInPage: Page {
form(
.class("gate__form"),
.action(.Route.signIn),
.method(.post)
.method(.post),
.custom(name: "data-passkey-form", value: "")
) {
input(
.type(.hidden),
.name("return"),
.value(returnPath)
)
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
.type(.button),
.hidden,
.custom(name: "data-passkey", value: "")
) {
Icon(.key, size: 18)
"Continue with passkey"
}
p(
.class("field__error"),
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
div(.class("field")) {
label(
.class("field__label"),
@@ -128,24 +138,21 @@ extension SignInPage: Page {
error
}
}
div(.class("gate__saved")) {
Icon(.key, size: 16)
span {
strong {
AccountRepository.demoEmail
}
span(.class("gate__saved-note")) {
" · saved passkey"
}
}
}
p(.class("field__hint")) {
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
}
}
}
p(.class("notice notice--warn")) {
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
div(.class("notice notice--warn gate__demo")) {
p {
"Prototype: the demo accounts have no passkey. The demo sign-in trusts the email above — or signs in the demo buyer, \(AccountRepository.demoEmail), without one. To sell, use joost@kestrelrow.example."
}
button(
.class("button button--outline button--block"),
.type(.submit)
) {
"Demo sign-in, without a passkey"
}
}
}
p(.class("gate__recover")) {
a(.href(.Route.recover)) {
@@ -167,7 +174,7 @@ extension SignInPage: Page {
}
var scripts: [any Asset] {
[StaticFile.shared]
[StaticFile.shared, StaticFile.passkey]
}
var stylesheets: [any Asset] {
@@ -0,0 +1,256 @@
import Foundation
import Persistence
/// The new-listing form as posted: the raw values, kept to fill the form back in, and what is wrong with them.
///
/// The form is the listing's data model. Its checks apply the grading guide: a raw book is graded on the scale, a colour-breaking crease caps
/// it, and case scuffs are a slab's alone.
struct ListingForm: Sendable, Equatable {
// MARK: Properties
/// The text values posted, by field name; a field posted more than once keeps every value.
let values: [String: [String]]
// MARK: Initializers
/// Creates the form from the values posted; an empty form by default, with the most common choices made.
init(
values: [String: [String]] = [
Field.format.rawValue: [Format.singleIssues.rawValue],
Field.grading.rawValue: [Grading.Mode.slabbed.rawValue],
Field.printing.rawValue: ["1"],
Field.offers.rawValue: ["1"],
]
) {
self.values = values
}
/// Creates the form from a multipart body's text parts.
init(
_ multipart: MultipartForm
) {
var values = [String: [String]]()
for field in Field.allCases {
values[field.rawValue] = multipart.values(field.rawValue)
}
self.values = values
}
}
// MARK: - Enumerations
extension ListingForm {
/// A field of the form.
enum Field: String, CaseIterable, Sendable {
case cert
case condition
case defects
case edition
case format
case grade
case grader
case grading
case issue
case keyNotes = "key-notes"
case notes
case offers
case pageQuality = "page-quality"
case photos
case price
case printing
case publisher
case series
case shipping
case year
}
}
extension Grading {
/// How a single issue is graded.
enum Mode: String, Sendable {
/// By the seller, against the guide.
case raw
/// By a grading company, in a slab.
case slabbed
}
}
// MARK: - Extensions
extension ListingForm {
// MARK: Computed
/// The format chosen, or `nil` when none valid was.
var format: Format? {
value(.format).flatMap(Format.init)
}
/// Whether the copy is a slabbed single issue.
var isSlabbed: Bool {
format == .singleIssues && value(.grading) == Grading.Mode.slabbed.rawValue
}
// MARK: Methods
/// Whether a field holds a value.
func has(
_ field: Field,
value: String
) -> Bool {
values[field.rawValue]?.contains(value) == true
}
/// The first value of a field, or `nil` when it was left blank.
func value(
_ field: Field
) -> String? {
values[field.rawValue]?.first
}
/// Checks the form.
/// - Parameters:
/// - photos: the URL paths of the photos stored for the listing.
/// - currentYear: the year cover years are checked against.
/// - Returns: the draft the form describes, or what is wrong, by field.
func validated(
photos: [String],
currentYear: Int = Calendar(identifier: .gregorian).component(.year, from: .init())
) -> Result<ListingDraft, Problems> {
var problems = [Field: String]()
func required(
_ field: Field,
_ name: String,
max: Int = 80
) -> String {
guard let value = value(field) else {
problems[field] = "Enter \(name)."
return ""
}
if value.count > max {
problems[field] = "Keep \(name) under \(max) characters."
}
return value
}
let series = required(.series, "the series or title")
let publisher = required(.publisher, "the publisher")
let year = value(.year).flatMap(Int.init) ?? 0
if !(1_900...currentYear + 1).contains(year) {
problems[.year] = "Enter a cover year between 1900 and \(currentYear + 1)."
}
guard let format else {
problems[.format] = "Pick a format."
return .failure(.init(fields: problems))
}
let defects = (values[Field.defects.rawValue] ?? []).compactMap(Grading.Defect.init)
var grader: String?
var grade: Double?
var condition = ""
if format == .singleIssues {
if value(.issue) == nil {
problems[.issue] = "Enter the issue number."
}
let step = value(.grade)
.flatMap(Double.init)
.flatMap { chosen in Grading.scale.first { $0.grade == chosen } }
grade = step?.grade
condition = step?.code ?? ""
if step == nil {
problems[.grade] = "Pick a grade from the scale."
}
if isSlabbed {
grader = value(.grader).flatMap { Grading.graders.contains($0) ? $0 : nil }
if grader == nil {
problems[.grader] = "Pick the grading company."
}
if value(.cert) == nil {
problems[.cert] = "Enter the cert number on the label, so buyers can verify the grade."
}
} else {
if defects.contains(.caseScuffs) {
problems[.defects] = "Case scuffs are for slabs; this copy is raw."
} else if defects.contains(.crease), let grade, grade > Grading.creaseCap {
problems[.defects] = "A colour-breaking crease caps a raw book at \(Grading.creaseCap.gradeLabel). Lower the grade, or untick the crease."
}
}
} else {
condition = value(.condition).flatMap { Grading.conditions.contains($0) ? $0 : nil } ?? ""
if condition.isEmpty {
problems[.condition] = "Pick the condition."
}
}
let price = value(.price).flatMap(Int.init) ?? 0
let shipping = value(.shipping).flatMap(Int.init) ?? -1
if !(1...100_000).contains(price) {
problems[.price] = "Enter the asking price in whole euros."
}
if !(0...100).contains(shipping) {
problems[.shipping] = "Enter the shipping cost to NL in whole euros; 0 for free."
}
if (value(.notes) ?? "").count > 1_000 {
problems[.notes] = "Keep the notes under 1,000 characters."
}
guard problems.isEmpty else {
return .failure(.init(fields: problems))
}
return .success(.init(
series: series,
issue: value(.issue) ?? "",
publisher: publisher,
year: year,
format: format.rawValue,
printing: value(.printing).flatMap(Int.init).map { (1...3).contains($0) ? $0 : 0 } ?? 0,
edition: format == .singleIssues ? (value(.edition) ?? "") : "",
grader: grader,
grade: grade,
condition: condition,
certNumber: isSlabbed ? value(.cert) : nil,
pageQuality: isSlabbed ? value(.pageQuality).flatMap { Grading.pageQualities.contains($0) ? $0 : nil } : nil,
keyNotes: value(.keyNotes),
defects: defects.map(\.label),
sellerNotes: value(.notes) ?? "",
priceCents: price * 100,
shippingCents: shipping * 100,
acceptsOffers: value(.offers) == "1",
photos: photos
))
}
}
// MARK: - Structs
extension ListingForm {
/// What is wrong with a form, by field.
struct Problems: Error, Equatable, Sendable {
/// The problem with each field that has one.
var fields: [Field: String]
}
}
@@ -0,0 +1,104 @@
import MultipartKit
import NIOCore
/// The parts of a `multipart/form-data` body: what a form with file fields posts.
struct MultipartForm: Sendable {
// MARK: Properties
/// The parts, in the order posted.
let parts: [MultipartPart]
// MARK: Initializers
/// Parses a body.
/// - Parameters:
/// - body: the request's body, collected.
/// - contentType: the request's `Content-Type`, which names the boundary between the parts.
/// - Returns: `nil` when the content type is not multipart, or the body does not parse.
init?(
body: ByteBuffer,
contentType: String?
) {
guard
let contentType,
contentType.lowercased().hasPrefix("multipart/form-data"),
let boundary = contentType
.split(separator: ";")
.map({ $0.trimmingCharacters(in: .whitespaces) })
.first(where: { $0.lowercased().hasPrefix("boundary=") })?
.dropFirst("boundary=".count)
.trimmingCharacters(in: .init(charactersIn: "\""))
else {
return nil
}
let parser = MultipartParser(boundary: boundary)
var parts = [MultipartPart]()
var headers = HTTPHeaders()
var buffer = ByteBuffer()
parser.onHeader = { headers.replaceOrAdd(name: $0, value: $1) }
parser.onBody = { buffer.writeBuffer(&$0) }
parser.onPartComplete = {
parts.append(.init(headers: headers, body: buffer))
headers = [:]
buffer = .init()
}
do {
try parser.execute(body)
} catch {
return nil
}
self.parts = parts
}
}
// MARK: - Extensions
extension MultipartForm {
// MARK: Methods
/// The bodies of the file parts of a name that carry a file: a file field left empty posts a part with no file name.
func files(
_ name: String
) -> [ByteBuffer] {
parts
.filter { $0.name == name && !($0.filename ?? "").isEmpty && $0.body.readableBytes > 0 }
.map(\.body)
}
/// The first text value of a name, trimmed, or `nil` when there is none or it is blank.
func value(
_ name: String
) -> String? {
values(name).first
}
/// Every text value of a name, trimmed, the blank ones dropped.
func values(
_ name: String
) -> [String] {
parts
.filter { $0.name == name && $0.filename == nil }
.map { String(buffer: $0.body).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
private extension MultipartPart {
/// The file name the part was posted with, or `nil` for a text field.
var filename: String? {
headers
.first(name: "Content-Disposition")?
.split(separator: ";")
.map { $0.trimmingCharacters(in: .whitespaces) }
.first { $0.lowercased().hasPrefix("filename=") }
.map { String($0.dropFirst("filename=".count)).trimmingCharacters(in: .init(charactersIn: "\"")) }
}
}
@@ -54,12 +54,14 @@ extension StaticFile {
.fontAnton,
.fontArchivo,
.fontOswald,
.guide,
.icon,
.icon192,
.icon512,
.index,
.listing,
.notFound,
.passkey,
.robots,
.search,
.shared,
@@ -121,6 +123,9 @@ extension StaticFile {
/// The `font/oswald-variable.woff2` display face (weights 500–700).
static let fontOswald = Self("oswald-variable", as: .woff2)
/// The `css/guide.css` stylesheet for the grading guide.
static let guide = Self("guide", as: .css)
/// The `icon.svg` icon.
static let icon = Self("icon", as: .svg)
@@ -139,6 +144,9 @@ extension StaticFile {
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
static let notFound = Self("not-found", as: .css, .js)
/// The `js/passkey.js` script running the passkey ceremonies on the sign-in page.
static let passkey = Self("passkey", as: .js)
/// The `robots.txt` crawler directives.
static let robots = Self("robots", as: .txt)