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