446 lines
16 KiB
Swift
446 lines
16 KiB
Swift
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.oneDecimal, "\($0.grade.oneDecimal) · \($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)
|
|
}
|
|
|
|
}
|