2026-09-21 12:55:35 +02:00
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" )) {
2026-10-25 12:41:55 +01:00
select (. grade , "Grade" , options : [( "" , "Pick a grade" )] + Grading . scale . map { ( $0 . grade . oneDecimal , " \( $0 . grade . oneDecimal ) · \( $0 . code ) " ) })
2026-09-21 12:55:35 +02:00
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 )
}
}