Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,236 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// The account: the display name, the passkeys that sign it in, and the way out.
|
||||
struct AccountPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The moment stamps are read relative to.
|
||||
let now: Date
|
||||
|
||||
/// The account's passkeys, the oldest first, with when each was created.
|
||||
let passkeys: [(passkey: Passkey, createdAt: Date)]
|
||||
|
||||
/// What is wrong with the display name posted, or `nil` when nothing is.
|
||||
let problem: String?
|
||||
|
||||
/// The account shown.
|
||||
let user: User
|
||||
|
||||
/// The signed-in viewer.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the account screen.
|
||||
/// - Parameters:
|
||||
/// - user: the account shown.
|
||||
/// - passkeys: the account's passkeys, the oldest first, with when each was created.
|
||||
/// - problem: what is wrong with the display name posted, or `nil` (the default) when nothing is.
|
||||
/// - viewer: the signed-in viewer.
|
||||
/// - now: the moment stamps are read relative to; the present by default.
|
||||
/// - 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(
|
||||
user: User,
|
||||
passkeys: [(passkey: Passkey, createdAt: Date)],
|
||||
problem: String? = nil,
|
||||
viewer: Viewer? = nil,
|
||||
now: Date = .init(),
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = .init(identifier: "en")
|
||||
self.now = now
|
||||
self.passkeys = passkeys
|
||||
self.problem = problem
|
||||
self.user = user
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension AccountPage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page account")) {
|
||||
aside(.class("account__rail")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Account"
|
||||
}
|
||||
h1(.class("type-panel")) {
|
||||
user.displayName
|
||||
}
|
||||
nav(
|
||||
.class("account__nav"),
|
||||
.custom(name: "aria-label", value: "Account sections")
|
||||
) {
|
||||
a(.href("#profile")) {
|
||||
"Profile"
|
||||
}
|
||||
a(.href("#passkeys")) {
|
||||
"Passkeys"
|
||||
}
|
||||
a(.href(.Route.wants)) {
|
||||
"Want list"
|
||||
}
|
||||
a(.href(.Route.messages)) {
|
||||
"Messages"
|
||||
}
|
||||
a(.href(viewer?.hasShop == true ? .Route.sellerDashboard : .Route.openShop)) {
|
||||
viewer?.hasShop == true ? "My shop" : "Open a shop"
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("account__main")) {
|
||||
section(
|
||||
.class("card account__section"),
|
||||
.id("profile")
|
||||
) {
|
||||
h2(.class("type-panel")) {
|
||||
"Profile"
|
||||
}
|
||||
form(
|
||||
.class("account__form"),
|
||||
.action("\(String.Route.account)/name"),
|
||||
.method(.post)
|
||||
) {
|
||||
TextField(
|
||||
name: "name",
|
||||
title: "Display name",
|
||||
value: user.displayName,
|
||||
hint: "How sellers and buyers see you. Signed in as \(user.email).",
|
||||
problem: problem,
|
||||
maxLength: 40
|
||||
)
|
||||
button(
|
||||
.class("button button--dark"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
section(
|
||||
.class("card account__section"),
|
||||
.id("passkeys"),
|
||||
.custom(name: "data-passkey-form", value: "")
|
||||
) {
|
||||
div(.class("section-head")) {
|
||||
h2(.class("type-panel")) {
|
||||
"Passkeys"
|
||||
}
|
||||
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
|
||||
button(
|
||||
.class("button button--primary button--sm"),
|
||||
.type(.button),
|
||||
.hidden,
|
||||
.custom(name: "data-passkey", value: "add")
|
||||
) {
|
||||
Icon(.key, size: 15)
|
||||
"Add a passkey"
|
||||
}
|
||||
}
|
||||
p(.class("type-body-sm account__text")) {
|
||||
"Any of these signs you in. Keep at least two on different devices so a lost phone never locks you out. Revoking one takes effect immediately."
|
||||
}
|
||||
p(
|
||||
.class("field__error"),
|
||||
.custom(name: "role", value: "alert"),
|
||||
.custom(name: "data-passkey-status", value: "")
|
||||
) {}
|
||||
if passkeys.isEmpty {
|
||||
p(.class("notice")) {
|
||||
"No passkey yet: this account signs in through the prototype's demo sign-in."
|
||||
}
|
||||
} else {
|
||||
ul(.class("rows")) {
|
||||
ForEach(Array(passkeys.enumerated())) { index, entry in
|
||||
li(.class("rows__row rows__row--passkey")) {
|
||||
Icon(.key)
|
||||
span {
|
||||
strong {
|
||||
"Passkey \(index + 1)"
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
" · created \(entry.createdAt.stamp(relativeTo: now))"
|
||||
}
|
||||
}
|
||||
form(
|
||||
.action("\(String.Route.account)/passkeys/revoke"),
|
||||
.method(.post)
|
||||
) {
|
||||
input(
|
||||
.type(.hidden),
|
||||
.name("credential"),
|
||||
.value(entry.passkey.credentialID)
|
||||
)
|
||||
button(
|
||||
.class("button button--sm"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Revoke"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
section(.class("card account__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"Sign out"
|
||||
}
|
||||
form(
|
||||
.action(.Route.signOut),
|
||||
.method(.post)
|
||||
) {
|
||||
button(
|
||||
.class("button button--outline"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Sign out of this device"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
meta(
|
||||
.name("robots"),
|
||||
.content("noindex")
|
||||
)
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.passkey]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.trade]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Account — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Copies side by side: grade, page quality, declared defects, and what each costs delivered to the Netherlands.
|
||||
struct ComparePage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The copies compared, cheapest delivered first.
|
||||
let copies: [Listing]
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the comparison.
|
||||
/// - Parameters:
|
||||
/// - copies: the copies compared, in any order.
|
||||
/// - 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(
|
||||
copies: [Listing],
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.copies = copies.sorted { Self.delivered($0) < Self.delivered($1) }
|
||||
self.locale = .init(identifier: "en")
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension ComparePage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page compare")) {
|
||||
div(.class("compare__head")) {
|
||||
div(.class("series__intro")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Compare · \(copies.first?.title ?? "copies") · \(copies.count) \(copies.count == 1 ? "copy" : "copies")"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"Which copy?"
|
||||
}
|
||||
p(.class("type-body-sm compare__lead")) {
|
||||
"Grade, page quality, declared defects and total cost delivered to the Netherlands, side by side."
|
||||
}
|
||||
}
|
||||
if let first = copies.first {
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(.Route.series(first.seriesSlug, issue: first.issueNumber))
|
||||
) {
|
||||
"Back to the series"
|
||||
}
|
||||
}
|
||||
}
|
||||
if copies.isEmpty {
|
||||
div(.class("empty")) {
|
||||
h2(.class("type-panel")) {
|
||||
"Nothing to compare"
|
||||
}
|
||||
p(.class("type-body-sm")) {
|
||||
"Tick up to \(SeriesPage.maxCompared) copies of an issue on its series page."
|
||||
}
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(.Route.series)
|
||||
) {
|
||||
"Browse series"
|
||||
}
|
||||
}
|
||||
}
|
||||
ul(.class("compare__copies")) {
|
||||
ForEach(copies) { copy in
|
||||
li(.class("card compare__copy")) {
|
||||
a(
|
||||
.class("compare__cover"),
|
||||
.href(copy.path),
|
||||
.custom(name: "aria-label", value: "\(copy.title), \(copy.chip.label), from \(copy.shop.name)")
|
||||
) {
|
||||
ListingImage(copy)
|
||||
}
|
||||
div(.class("compare__price")) {
|
||||
span(.class("type-stat")) {
|
||||
Self.delivered(copy).euros
|
||||
}
|
||||
Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified)
|
||||
}
|
||||
dl(.class("compare__facts")) {
|
||||
ForEach(Self.facts(of: copy)) { fact in
|
||||
dt {
|
||||
fact.term
|
||||
}
|
||||
dd {
|
||||
fact.value
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("compare__actions")) {
|
||||
a(
|
||||
.class("button button--primary button--sm"),
|
||||
.href(.Route.gated(.Route.buy(copy.slug), from: copy.path, viewer: viewer))
|
||||
) {
|
||||
"Buy now"
|
||||
}
|
||||
a(
|
||||
.class("button button--outline button--sm"),
|
||||
.href(copy.path)
|
||||
) {
|
||||
"View listing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("gate-note")) {
|
||||
Icon(.info, size: 16)
|
||||
span {
|
||||
"The delivered price adds each seller's tracked shipping to the Netherlands. Sales from outside the EU may attract import VAT at delivery."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
meta(
|
||||
.name("robots"),
|
||||
.content("noindex")
|
||||
)
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.collecting]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Compare copies — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ComparePage {
|
||||
|
||||
/// What a copy costs delivered to the Netherlands, in euro cents.
|
||||
static func delivered(
|
||||
_ copy: Listing
|
||||
) -> Int {
|
||||
copy.priceCents + copy.shippingCents
|
||||
}
|
||||
|
||||
/// What sets a copy apart, row by row.
|
||||
static func facts(
|
||||
of copy: Listing
|
||||
) -> [(term: String, value: String)] {
|
||||
let customs = copy.shop.shipsFromEU ? "" : " · customs may apply"
|
||||
|
||||
return [
|
||||
("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")),
|
||||
("Page quality", copy.pageQuality ?? "Not stated"),
|
||||
("Printing", copy.printingDetail),
|
||||
("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")),
|
||||
("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"),
|
||||
("Shop", "\(copy.shop.name) · \(copy.shop.location)"),
|
||||
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(String(format: "%.1f", copy.shop.rating)) · \(copy.shop.returnsNote)"),
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
@@ -95,7 +95,13 @@ extension ListingPage: Page {
|
||||
listing.title
|
||||
}
|
||||
p(.class("listing__byline")) {
|
||||
"\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased())"
|
||||
"\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased()) · "
|
||||
a(
|
||||
.class("listing__series"),
|
||||
.href(.Route.series(listing.seriesSlug, issue: listing.issueNumber) + (listing.issueNumber == nil ? "" : "#copies"))
|
||||
) {
|
||||
listing.issueNumber == nil ? "\(listing.seriesName) series" : "\(listing.seriesName) series · compare copies"
|
||||
}
|
||||
}
|
||||
}
|
||||
buyBox
|
||||
@@ -224,16 +230,12 @@ private extension ListingPage {
|
||||
listing.acceptsOffers ? " · accepts offers" : ""
|
||||
}
|
||||
}
|
||||
if viewer == nil {
|
||||
a(
|
||||
.class("button button--sm"),
|
||||
.href(.Route.signIn(returningTo: listing.path))
|
||||
) {
|
||||
Icon(.bookmark, size: 16)
|
||||
"Save"
|
||||
}
|
||||
} else {
|
||||
Unavailable("Save", modifier: "button--sm")
|
||||
a(
|
||||
.class("button button--sm"),
|
||||
.href(.Route.newWant(listing: listing.slug))
|
||||
) {
|
||||
Icon(.bookmark, size: 16)
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
if !listing.isAvailable {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// The open-a-shop form: the shop's public identity, how its owner sells, and its policies. One screen; the first listing follows.
|
||||
struct OpenShopPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The values the form is filled with.
|
||||
let form: ShopForm
|
||||
|
||||
/// What is wrong with the form, by field.
|
||||
let problems: [ShopForm.Field: String]
|
||||
|
||||
/// The signed-in viewer.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the open-a-shop form.
|
||||
/// - Parameters:
|
||||
/// - 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(
|
||||
form: ShopForm = .init(),
|
||||
problems: [ShopForm.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.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension OpenShopPage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page listing-form")) {
|
||||
div(.class("listing-form__head")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Open a shop · one screen, about five minutes"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"Set up your shop"
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
"A shop is a public page plus your policies. You can list the first book right after."
|
||||
}
|
||||
}
|
||||
if !problems.isEmpty {
|
||||
p(
|
||||
.class("notice notice--bad"),
|
||||
.custom(name: "role", value: "alert")
|
||||
) {
|
||||
strong {
|
||||
problems.count == 1 ? "One thing to fix before opening." : "\(problems.count) things to fix before opening."
|
||||
}
|
||||
}
|
||||
}
|
||||
Elementary.form(
|
||||
.class("listing-form__form"),
|
||||
.action(.Route.openShop),
|
||||
.method(.post)
|
||||
) {
|
||||
section(.class("card listing-form__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"1. Shop"
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
field(.name, "Shop name", placeholder: "Kestrel Row Comics", isWide: true)
|
||||
field(.handle, "Handle", placeholder: "kestrel-row", hint: "Your page: /shop/your-handle")
|
||||
field(.city, "Ships from", placeholder: "Amsterdam", hint: "Sets the ships-from filter", isWide: true)
|
||||
SelectField(
|
||||
name: ShopForm.Field.country.rawValue,
|
||||
title: "Country",
|
||||
options: ShopForm.countries.map { ($0.0, $0.1) },
|
||||
selected: form.value(.country),
|
||||
problem: problems[.country]
|
||||
)
|
||||
}
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for("about")
|
||||
) {
|
||||
"About the shop (shown at the top of your page)"
|
||||
}
|
||||
textarea(
|
||||
.class("input"),
|
||||
.id("about"),
|
||||
.name(ShopForm.Field.about.rawValue),
|
||||
.custom(name: "rows", value: "3"),
|
||||
.custom(name: "maxlength", value: "600")
|
||||
) {
|
||||
form.value(.about) ?? ""
|
||||
}
|
||||
FieldNote(name: "about", hint: nil, problem: problems[.about])
|
||||
}
|
||||
}
|
||||
section(.class("card listing-form__section")) {
|
||||
div(.class("section-head")) {
|
||||
h2(.class("type-panel")) {
|
||||
"2. Private or business seller"
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
"Required by Dutch consumer law for business sellers"
|
||||
}
|
||||
}
|
||||
fieldset(.class("listing-form__modes")) {
|
||||
legend(.class("visually-hidden")) {
|
||||
"Seller type"
|
||||
}
|
||||
kind(.privateSeller, "Private collector", "Selling your own books now and then. No KvK needed. Buyers get no statutory right of withdrawal.")
|
||||
kind(.business, "Business (eenmanszaak, VOF, BV…)", "KvK shown on your shop page; consumers get the 14-day right of withdrawal by law.")
|
||||
}
|
||||
FieldNote(name: "kind", hint: nil, problem: problems[.kind])
|
||||
div(.class("listing-form__grid")) {
|
||||
field(.kvk, "KvK number", placeholder: "12345678", hint: "Business sellers only")
|
||||
field(.vat, "BTW-id", placeholder: "NL123456789B01", hint: "Optional")
|
||||
field(.legalName, "Legal name", placeholder: "As registered")
|
||||
}
|
||||
}
|
||||
section(.class("card listing-form__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"3. Policies"
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
SelectField(
|
||||
name: ShopForm.Field.returns.rawValue,
|
||||
title: "Returns",
|
||||
options: ShopForm.returns.map { ($0, $0) },
|
||||
selected: form.value(.returns),
|
||||
problem: problems[.returns]
|
||||
)
|
||||
SelectField(
|
||||
name: ShopForm.Field.handling.rawValue,
|
||||
title: "Handling time",
|
||||
options: ShopForm.handlingDays.map { ("\($0)", $0 == 1 ? "1 business day" : "\($0) business days") },
|
||||
selected: form.value(.handling),
|
||||
problem: problems[.handling]
|
||||
)
|
||||
}
|
||||
label(.class("check check--block")) {
|
||||
input(
|
||||
.type(.checkbox),
|
||||
.name(ShopForm.Field.declaration.rawValue),
|
||||
.value("1")
|
||||
)
|
||||
.attributes(.checked, when: form.value(.declaration) != nil)
|
||||
span {
|
||||
"I'll declare every defect on the checklist and photograph the actual copy. Undeclared defects are grounds for a problem report against my shop. "
|
||||
a(.href(.Route.gradingGuide)) {
|
||||
"Grading guide"
|
||||
}
|
||||
}
|
||||
}
|
||||
FieldNote(name: "declaration", hint: nil, problem: problems[.declaration])
|
||||
p(.class("gate-note")) {
|
||||
Icon(.info, size: 16)
|
||||
span {
|
||||
"Your shop is public from the moment you open it — anyone can browse it without an account. Shipping is priced per listing. A new shop starts unverified and unrated; ratings come only from finished sales."
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("listing-form__submit")) {
|
||||
a(
|
||||
.class("button"),
|
||||
.href(.Route.home)
|
||||
) {
|
||||
"Cancel"
|
||||
}
|
||||
button(
|
||||
.class("button button--primary button--lg"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Open shop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
"Open a shop — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension OpenShopPage {
|
||||
|
||||
/// A text field of the form, showing its value and its problem.
|
||||
func field(
|
||||
_ field: ShopForm.Field,
|
||||
_ title: String,
|
||||
placeholder: String,
|
||||
hint: String? = nil,
|
||||
isWide: Bool = false
|
||||
) -> TextField {
|
||||
.init(
|
||||
name: field.rawValue,
|
||||
title: title,
|
||||
value: form.value(field) ?? "",
|
||||
placeholder: placeholder,
|
||||
hint: hint,
|
||||
problem: problems[field],
|
||||
isWide: isWide
|
||||
)
|
||||
}
|
||||
|
||||
/// A way of selling, as a radio card.
|
||||
func kind(
|
||||
_ kind: Shop.Kind,
|
||||
_ title: String,
|
||||
_ text: String
|
||||
) -> some HTML {
|
||||
label(.class("listing-form__mode")) {
|
||||
input(
|
||||
.type(.radio),
|
||||
.name(ShopForm.Field.kind.rawValue),
|
||||
.value(kind.rawValue)
|
||||
)
|
||||
.attributes(.checked, when: form.value(.kind) == kind.rawValue)
|
||||
span {
|
||||
strong {
|
||||
title
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Every series in the catalogue, with how many copies of each are listed.
|
||||
struct SeriesIndexPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The series, by name, each with the listings of it still for sale.
|
||||
let entries: [(series: Series, listings: [Listing])]
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the series index.
|
||||
/// - Parameters:
|
||||
/// - entries: the series, by name, each with the listings of it still for sale.
|
||||
/// - 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(
|
||||
entries: [(series: Series, listings: [Listing])],
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.entries = entries
|
||||
self.locale = .init(identifier: "en")
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension SeriesIndexPage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page series")) {
|
||||
div(.class("series__intro")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Browse by series"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"Series"
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
"Every issue of a run, what is listed of each, and the gaps — one tap from an alert."
|
||||
}
|
||||
}
|
||||
ul(.class("series__index")) {
|
||||
ForEach(entries) { entry in
|
||||
li {
|
||||
a(
|
||||
.class("card series__entry"),
|
||||
.href(.Route.series(entry.series.slug))
|
||||
) {
|
||||
if let cover = entry.listings.first {
|
||||
ListingImage(cover, modifier: "cover--agreed")
|
||||
}
|
||||
span(.class("series__entry-text")) {
|
||||
span(.class("series__entry-name")) {
|
||||
entry.series.name
|
||||
}
|
||||
span(.class("type-caption")) {
|
||||
"\(entry.series.publisher) · \(entry.series.years) · \(entry.series.issueCount) issues"
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
"\(entry.listings.count) \(entry.listings.count == 1 ? "copy" : "copies") listed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.collecting]
|
||||
}
|
||||
|
||||
var summary: String? {
|
||||
"Browse comics by series on CCN: every issue of a run, what is listed of each, and the gaps."
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Series — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// A series: its issues with what is listed of each — and what is not, one tap from an alert — the copies of one issue, and its collected editions.
|
||||
struct SeriesPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The issue whose copies are listed, or `nil` for none.
|
||||
let issue: Int?
|
||||
|
||||
/// Every listing of the series still for sale.
|
||||
let listings: [Listing]
|
||||
|
||||
/// The series shown.
|
||||
let series: Series
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a series' screen.
|
||||
/// - Parameters:
|
||||
/// - series: the series shown.
|
||||
/// - listings: every listing of the series still for sale.
|
||||
/// - issue: the issue whose copies are listed, or `nil` (the default) for none.
|
||||
/// - 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(
|
||||
series: Series,
|
||||
listings: [Listing],
|
||||
issue: Int? = nil,
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.issue = issue
|
||||
self.listings = listings
|
||||
self.locale = .init(identifier: "en")
|
||||
self.series = series
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension SeriesPage: Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The most copies compared side by side.
|
||||
static let maxCompared = 3
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page series")) {
|
||||
nav(
|
||||
.class("breadcrumb"),
|
||||
.custom(name: "aria-label", value: "Breadcrumb")
|
||||
) {
|
||||
a(.href(.Route.home)) {
|
||||
"Home"
|
||||
}
|
||||
span(.custom(name: "aria-hidden", value: "true")) {
|
||||
"/"
|
||||
}
|
||||
a(.href(.Route.series)) {
|
||||
"Series"
|
||||
}
|
||||
span(.custom(name: "aria-hidden", value: "true")) {
|
||||
"/"
|
||||
}
|
||||
span(
|
||||
.class("breadcrumb__current"),
|
||||
.custom(name: "aria-current", value: "page")
|
||||
) {
|
||||
series.name
|
||||
}
|
||||
}
|
||||
section(.class("series__head")) {
|
||||
if let cover = listings.first(where: { $0.issueNumber != nil }) ?? listings.first {
|
||||
ListingImage(cover, modifier: "cover--series")
|
||||
}
|
||||
div(.class("series__intro")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Series · \(series.publisher) · \(series.years)\(series.issueCount > 0 ? " · \(series.issueCount) issues" : "")"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
series.name
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now."
|
||||
}
|
||||
ul(.class("series__keys")) {
|
||||
ForEach(keys) { key in
|
||||
li(.class("chip chip--warn")) {
|
||||
"#\(key.issue) · \(key.notes)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !issues.isEmpty {
|
||||
section(.class("series__section")) {
|
||||
div(.class("section-head")) {
|
||||
h2(.class("type-h2")) {
|
||||
series.issueCount > 0 ? "All \(series.issueCount) issues" : "Issues listed"
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
"Cheapest listed copy per issue"
|
||||
}
|
||||
}
|
||||
ol(.class("series__issues")) {
|
||||
ForEach(issues) { entry in
|
||||
li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) {
|
||||
span(.class("series__number")) {
|
||||
"#\(entry.number)"
|
||||
}
|
||||
if let cheapest = entry.copies.min(by: { $0.priceCents < $1.priceCents }) {
|
||||
a(.href(.Route.series(series.slug, issue: entry.number) + "#copies")) {
|
||||
"\(entry.copies.count) listed · from \(cheapest.priceCents.euros)"
|
||||
}
|
||||
.attributes(.custom(name: "aria-current", value: "true"), when: entry.number == issue)
|
||||
} else {
|
||||
a(.href(.Route.gated(
|
||||
.Route.newWant(title: "\(series.name) #\(entry.number)"),
|
||||
from: .Route.series(series.slug),
|
||||
viewer: viewer
|
||||
))) {
|
||||
"None listed · alert me"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let issue, !copies.isEmpty {
|
||||
section(
|
||||
.class("series__section"),
|
||||
.id("copies")
|
||||
) {
|
||||
h2(.class("type-h2")) {
|
||||
"Copies of #\(issue) — \(copies.count) listed"
|
||||
}
|
||||
// A plain `GET` form: the copies ticked become the comparison's query.
|
||||
form(
|
||||
.class("series__copies"),
|
||||
.action(.Route.compare),
|
||||
.method(.get)
|
||||
) {
|
||||
div(.class("table-scroll")) {
|
||||
table(.class("table")) {
|
||||
thead {
|
||||
tr {
|
||||
th {
|
||||
span(.class("visually-hidden")) {
|
||||
"Compare"
|
||||
}
|
||||
}
|
||||
ForEach(["Grade", "Printing", "Pages", "Defects", "Price", "Ship to NL", "Shop", ""]) { heading in
|
||||
th {
|
||||
heading
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
ForEach(copies) { copy in
|
||||
tr {
|
||||
td {
|
||||
input(
|
||||
.type(.checkbox),
|
||||
.name("copy"),
|
||||
.value(copy.slug),
|
||||
.custom(name: "aria-label", value: "Compare the \(copy.chip.label) from \(copy.shop.name)")
|
||||
)
|
||||
}
|
||||
td {
|
||||
Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified)
|
||||
}
|
||||
td {
|
||||
copy.printing
|
||||
}
|
||||
td {
|
||||
copy.pageQuality ?? "—"
|
||||
}
|
||||
td {
|
||||
copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")
|
||||
}
|
||||
td {
|
||||
strong {
|
||||
copy.priceCents.euros
|
||||
}
|
||||
}
|
||||
td {
|
||||
"\(copy.shippingCents.euros) · \(copy.shop.country)"
|
||||
}
|
||||
td {
|
||||
a(.href(copy.shop.path)) {
|
||||
copy.shop.name
|
||||
}
|
||||
}
|
||||
td {
|
||||
a(
|
||||
.class("link-ink"),
|
||||
.href(copy.path)
|
||||
) {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button(
|
||||
.class("button button--dark"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Compare the copies ticked (up to \(Self.maxCompared))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collected.isEmpty {
|
||||
section(.class("series__section")) {
|
||||
h2(.class("type-h2")) {
|
||||
"Collected editions"
|
||||
}
|
||||
div(.class("card-grid")) {
|
||||
ForEach(collected) { listing in
|
||||
ListingCard(listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.collecting]
|
||||
}
|
||||
|
||||
var summary: String? {
|
||||
"\(series.name), \(series.publisher), \(series.years): every issue, the copies listed of each, and the gaps."
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"\(series.name) — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SeriesPage {
|
||||
|
||||
/// The series' manga, trade paperbacks, and hardcovers.
|
||||
var collected: [Listing] {
|
||||
listings.filter { $0.issueNumber == nil }
|
||||
}
|
||||
|
||||
/// The copies of the issue picked, cheapest delivered first.
|
||||
var copies: [Listing] {
|
||||
listings
|
||||
.filter { $0.issueNumber == issue }
|
||||
.sorted { $0.priceCents + $0.shippingCents < $1.priceCents + $1.shippingCents }
|
||||
}
|
||||
|
||||
/// Every issue of the run with the copies listed of it; for a series without a known run, the issues listed alone.
|
||||
var issues: [(number: Int, copies: [Listing])] {
|
||||
let listed = Dictionary(grouping: listings.filter { $0.issueNumber != nil }) { $0.issueNumber ?? 0 }
|
||||
let numbers = series.issueCount > 0 ? Array(1...series.issueCount) : listed.keys.sorted()
|
||||
|
||||
return numbers.map { ($0, listed[$0] ?? []) }
|
||||
}
|
||||
|
||||
/// The key issues, as the listings of them say.
|
||||
var keys: [(issue: Int, notes: String)] {
|
||||
var seen = Set<Int>()
|
||||
|
||||
return listings
|
||||
.compactMap { listing in
|
||||
guard let issue = listing.issueNumber, let notes = listing.keyNotes, seen.insert(issue).inserted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (issue, notes)
|
||||
}
|
||||
.sorted { $0.issue < $1.issue }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -154,11 +154,6 @@ extension SignInPage: Page {
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("gate__recover")) {
|
||||
a(.href(.Route.recover)) {
|
||||
"Lost access to your passkeys?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// The want list: what the viewer is looking for, the listings that meet each want, and the form adding one.
|
||||
struct WantsPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The values the add-a-want form is filled with, or `nil` to show the list alone.
|
||||
let draft: Draft?
|
||||
|
||||
/// The signed-in viewer.
|
||||
let viewer: Viewer?
|
||||
|
||||
/// The viewer's wants, the newest first.
|
||||
let wants: [Want]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the want list.
|
||||
/// - Parameters:
|
||||
/// - wants: the viewer's wants, the newest first.
|
||||
/// - draft: the values the add-a-want form is filled with, or `nil` (the default) to show the list alone.
|
||||
/// - 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(
|
||||
wants: [Want],
|
||||
draft: Draft? = nil,
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.draft = draft
|
||||
self.locale = .init(identifier: "en")
|
||||
self.viewer = viewer
|
||||
self.wants = wants
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
extension WantsPage {
|
||||
/// What the add-a-want form is filled with: a listing saved, a gap in a series, or nothing yet.
|
||||
struct Draft: Sendable, Equatable {
|
||||
var title = ""
|
||||
var minGrade: Double?
|
||||
var maxPriceCents: Int?
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension WantsPage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page wants")) {
|
||||
div(.class("section-head")) {
|
||||
h1(.class("type-h1")) {
|
||||
"Wants"
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
"\(wants.count) \(wants.count == 1 ? "want" : "wants") · \(wants.reduce(0) { $0 + $1.newMatchCount }) new matches"
|
||||
}
|
||||
}
|
||||
if let draft {
|
||||
add(draft)
|
||||
}
|
||||
if wants.isEmpty, draft == nil {
|
||||
div(.class("empty")) {
|
||||
h2(.class("type-panel")) {
|
||||
"Nothing on your list yet"
|
||||
}
|
||||
p(.class("type-body-sm")) {
|
||||
"Add the issue you're missing, and CCN checks it against every listing published."
|
||||
}
|
||||
}
|
||||
}
|
||||
ul(.class("wants__list")) {
|
||||
ForEach(wants) { want in
|
||||
li(.class("card wants__want")) {
|
||||
div(.class("wants__top")) {
|
||||
a(
|
||||
.class("wants__title"),
|
||||
.href(Self.search(for: want))
|
||||
) {
|
||||
want.title
|
||||
}
|
||||
if want.newMatchCount > 0 {
|
||||
span(.class("wants__new")) {
|
||||
"\(want.newMatchCount) new"
|
||||
}
|
||||
} else {
|
||||
span(.class("type-small")) {
|
||||
want.matches.isEmpty ? "no matches yet" : "\(want.matches.count) listed"
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("type-small")) {
|
||||
Self.limits(of: want)
|
||||
}
|
||||
if let cheapest = want.matches.min(by: { $0.priceCents < $1.priceCents }) {
|
||||
a(
|
||||
.class("wants__match"),
|
||||
.href(cheapest.path)
|
||||
) {
|
||||
"From \(cheapest.priceCents.euros) · \(cheapest.chip.label) · \(cheapest.shop.name)"
|
||||
}
|
||||
}
|
||||
div(.class("wants__actions")) {
|
||||
form(
|
||||
.action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/alerts"),
|
||||
.method(.post)
|
||||
) {
|
||||
button(
|
||||
.class("switch"),
|
||||
.type(.submit),
|
||||
.custom(name: "role", value: "switch"),
|
||||
.custom(name: "aria-checked", value: want.alerts ? "true" : "false")
|
||||
) {
|
||||
span(.class("switch__track")) {}
|
||||
"Alert"
|
||||
}
|
||||
}
|
||||
form(
|
||||
.action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/remove"),
|
||||
.method(.post)
|
||||
) {
|
||||
button(
|
||||
.class("button button--bare button--sm"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if draft == nil {
|
||||
a(
|
||||
.class("wants__add"),
|
||||
.href(.Route.newWant())
|
||||
) {
|
||||
"+ Add a want"
|
||||
}
|
||||
}
|
||||
p(.class("type-small wants__note")) {
|
||||
"Matches are checked every time a listing is published, among copies shipping from the EU. The bell in the header counts the new ones."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
"Want list — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension WantsPage {
|
||||
|
||||
/// The form adding a want.
|
||||
func add(
|
||||
_ draft: Draft
|
||||
) -> some HTML {
|
||||
form(
|
||||
.class("card wants__form"),
|
||||
.action(.Route.wants),
|
||||
.method(.post)
|
||||
) {
|
||||
h2(.class("type-panel")) {
|
||||
"Add a want"
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
TextField(
|
||||
name: "title",
|
||||
title: "Series and issue",
|
||||
value: draft.title,
|
||||
placeholder: "Nightward #7",
|
||||
hint: "Every word has to appear in a listing's title",
|
||||
isWide: true,
|
||||
maxLength: 120
|
||||
)
|
||||
SelectField(
|
||||
name: "min-grade",
|
||||
title: "Lowest grade",
|
||||
options: [("", "Any grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") },
|
||||
selected: draft.minGrade?.gradeLabel ?? ""
|
||||
)
|
||||
TextField(
|
||||
name: "max-price",
|
||||
title: "Most you'd pay (EUR)",
|
||||
value: draft.maxPriceCents.map { "\($0 / 100)" } ?? "",
|
||||
placeholder: "40"
|
||||
)
|
||||
label(.class("check")) {
|
||||
input(
|
||||
.type(.checkbox),
|
||||
.name("slabbed"),
|
||||
.value("1")
|
||||
)
|
||||
"Slabbed copies only"
|
||||
}
|
||||
}
|
||||
div(.class("listing-form__submit")) {
|
||||
a(
|
||||
.class("button"),
|
||||
.href(.Route.wants)
|
||||
) {
|
||||
"Cancel"
|
||||
}
|
||||
button(
|
||||
.class("button button--primary"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Add to want list"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A want's limits in a line, e.g. `grade ≥ 8.0 · max €40 · ships from EU only`.
|
||||
static func limits(
|
||||
of want: Want
|
||||
) -> String {
|
||||
[
|
||||
want.slabbedOnly ? "slabbed only" : nil,
|
||||
want.minGrade.map { "grade ≥ \($0.gradeLabel)" },
|
||||
want.maxPriceCents.map { "max \($0.euros)" },
|
||||
"ships from EU only",
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// The search showing a want's matches.
|
||||
static func search(
|
||||
for want: Want
|
||||
) -> String {
|
||||
var parameters = SearchParameters().setting(.query, to: want.title.replacingOccurrences(of: "·", with: " "))
|
||||
|
||||
if let minGrade = want.minGrade {
|
||||
parameters = parameters.setting(.gradeMin, to: minGrade.gradeLabel)
|
||||
}
|
||||
|
||||
if let maxPriceCents = want.maxPriceCents {
|
||||
parameters = parameters.setting(.priceMax, to: "\(maxPriceCents / 100)")
|
||||
}
|
||||
|
||||
if want.slabbedOnly {
|
||||
parameters = parameters.setting(.grading, to: SearchParameters.Grading.slabbed.rawValue)
|
||||
}
|
||||
|
||||
return parameters.path(.Route.search)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
|
||||
/// The first sign-in: shown once, to a new account, for the one thing sign-up never asked — a display name. Drawn bare, like the sign-in.
|
||||
struct WelcomePage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The name the field is filled with.
|
||||
let displayName: String
|
||||
|
||||
/// What is wrong with the name, or `nil` when nothing is.
|
||||
let problem: String?
|
||||
|
||||
/// The path the visitor continues to.
|
||||
let returnPath: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the first-sign-in screen.
|
||||
/// - Parameters:
|
||||
/// - displayName: the name the field is filled with.
|
||||
/// - returnPath: the path the visitor continues to.
|
||||
/// - problem: what is wrong with the name, or `nil` (the default) when nothing is.
|
||||
/// - 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(
|
||||
displayName: String,
|
||||
returnPath: String,
|
||||
problem: String? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.displayName = displayName
|
||||
self.locale = .init(identifier: "en")
|
||||
self.problem = problem
|
||||
self.returnPath = returnPath
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension WelcomePage: Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
div(.class("gate")) {
|
||||
header(.class("gate__bar")) {
|
||||
span(.class("gate__wordmark")) {
|
||||
"CCN"
|
||||
}
|
||||
}
|
||||
main(.class("gate__main")) {
|
||||
div(.class("gate__intro")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Shown once, on first sign-in"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"You're in."
|
||||
}
|
||||
p(.class("gate__lead")) {
|
||||
"One thing before sellers and buyers see you."
|
||||
}
|
||||
}
|
||||
form(
|
||||
.class("gate__form"),
|
||||
.action(.Route.welcome),
|
||||
.method(.post)
|
||||
) {
|
||||
input(
|
||||
.type(.hidden),
|
||||
.name("return"),
|
||||
.value(returnPath)
|
||||
)
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for("name")
|
||||
) {
|
||||
"Display name"
|
||||
}
|
||||
input(
|
||||
.class("input input--lg"),
|
||||
.id("name"),
|
||||
.type(.text),
|
||||
.name("name"),
|
||||
.value(displayName),
|
||||
.required,
|
||||
.placeholder("How sellers and buyers will see you"),
|
||||
.custom(name: "autocomplete", value: "nickname"),
|
||||
.custom(name: "maxlength", value: "40")
|
||||
)
|
||||
FieldNote(
|
||||
name: "name",
|
||||
hint: "You can open a shop later from your account — no need to decide now.",
|
||||
problem: problem
|
||||
)
|
||||
}
|
||||
div(.class("gate__nudge")) {
|
||||
Icon(.key, size: 20)
|
||||
div {
|
||||
p(.class("gate__nudge-title")) {
|
||||
"Keep a second way in"
|
||||
}
|
||||
p {
|
||||
"A passkey lives on the device that made it. Add a backup from another device on your account page, so a lost phone doesn't lock you out of open orders."
|
||||
}
|
||||
}
|
||||
}
|
||||
button(
|
||||
.class("button button--primary button--xl button--block"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Continue"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
meta(
|
||||
.name("robots"),
|
||||
.content("noindex")
|
||||
)
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.signIn]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Welcome — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user