Files
ccn/Services/Website/Sources/Library/Internal/Pages/NewListingPage.swift
T

445 lines
16 KiB
Swift
Raw Normal View History

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)
}
}