Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user