Implemented phase 5 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 13:32:05 +02:00
1 parent 89a7a7b8e5
commit 31d2d98d49
46 files changed
+4039 -162

No files matched your search

@@ -0,0 +1,112 @@
import Elementary
/// A labelled text field, with its hint or — tied to it for assistive technology — what is wrong with it.
struct TextField: HTML {
// MARK: Properties
let name: String
let title: String
var value: String = ""
var placeholder: String = ""
var hint: String?
var problem: String?
var isWide = false
var maxLength: Int?
// MARK: Computed
var body: some HTML {
div(.class(isWide ? "field field--wide" : "field")) {
label(
.class("field__label"),
.for(name)
) {
title
}
input(
.class("input"),
.id(name),
.type(.text),
.name(name),
.value(value),
.placeholder(placeholder)
)
.attributes(.custom(name: "maxlength", value: "\(maxLength ?? 0)"), when: maxLength != nil)
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(name)-error"),
when: problem != nil
)
FieldNote(name: name, hint: hint, problem: problem)
}
}
}
/// A labelled select, showing the chosen option.
struct SelectField: HTML {
// MARK: Properties
let name: String
let title: String
let options: [(value: String, label: String)]
var selected: String?
var problem: String?
// MARK: Computed
var body: some HTML {
div(.class("field")) {
label(
.class("field__label"),
.for(name)
) {
title
}
select(
.class("select select--block"),
.id(name),
.name(name)
) {
ForEach(options) { option in
Elementary.option(.value(option.value)) {
option.label
}
.attributes(.selected, when: option.value == selected)
}
}
FieldNote(name: name, hint: nil, problem: problem)
}
}
}
/// What is wrong with a field, under it; or, when nothing is, its hint.
struct FieldNote: HTML {
// MARK: Properties
let name: String
let hint: String?
let problem: String?
// MARK: Computed
var body: some HTML {
if let problem {
p(
.class("field__error"),
.id("\(name)-error")
) {
problem
}
} else if let hint {
p(.class("field__hint")) {
hint
}
}
}
}
@@ -30,13 +30,13 @@ struct ListingCard: HTML {
var body: some HTML {
article(.class("listing-card")) {
// Saving is a gated action: signed out, it leads to the sign-in screen.
// Saving a copy adds a want for it; signed out, the want list leads to the sign-in screen first.
a(
.class("listing-card__save"),
.href(.Route.signIn(returningTo: listing.path)),
.href(.Route.newWant(listing: listing.slug)),
.custom(
name: "aria-label",
value: "Save \(listing.title) to watchlist"
value: "Add \(listing.title) to your want list"
)
) {
Icon(.bookmark, size: 18)
@@ -45,17 +45,6 @@ struct SiteFooter: HTML {
span {
"CCN B.V. · Amsterdam · KvK [NUMBER] · BTW-id [NUMBER] · Business sellers show their KvK number on their shop page; consumers have a 14-day right of withdrawal on purchases from business sellers."
}
span(.class("site-footer__legal-links")) {
a(.href(.Route.terms)) {
"Terms"
}
a(.href(.Route.privacy)) {
"Privacy"
}
a(.href(.Route.sellerRules)) {
"Seller rules"
}
}
}
}
}
@@ -88,7 +77,6 @@ private extension SiteFooter {
("Open a shop", .Route.openShop),
("Seller dashboard", .Route.sellerDashboard),
("Grading guide", .Route.gradingGuide),
("Trade at events", .Route.events),
]
),
(
@@ -96,10 +84,8 @@ private extension SiteFooter {
links: [
("Sign in or join", .Route.signIn),
("Want list & alerts", .Route.wants),
("My collection", .Route.collection),
("Messages", .Route.messages),
("Passkeys & settings", .Route.account),
("Lost your passkey?", .Route.recover),
]
),
]
@@ -98,7 +98,7 @@ private extension SiteHeader {
}
}
/// The format links, plus the series and events links shown on tablet only.
/// The format links, plus the series link shown on tablet only.
var navigation: some HTML {
nav(
.class("site-nav"),
@@ -134,12 +134,6 @@ private extension SiteHeader {
) {
"Series"
}
a(
.class("site-nav__link site-nav__link--extra"),
.href(.Route.events)
) {
"Events"
}
}
}
@@ -193,7 +187,7 @@ private extension SiteHeader {
.bell,
label: "Want list",
count: viewer.newWantMatches,
unit: "new matches",
unit: viewer.newWantMatches == 1 ? "new match" : "new matches",
path: .Route.wants
)
counter(
@@ -203,13 +197,11 @@ private extension SiteHeader {
unit: "unread",
path: .Route.messages
)
if viewer.hasShop {
a(
.class("site-header__text-link site-header__text-link--strong from-tablet"),
.href(.Route.sellerDashboard)
) {
"My shop"
}
a(
.class("site-header__text-link site-header__text-link--strong from-tablet"),
.href(viewer.hasShop ? .Route.sellerDashboard : .Route.openShop)
) {
viewer.hasShop ? "My shop" : "Open a shop"
}
a(
.class("site-header__account"),
@@ -226,19 +218,6 @@ private extension SiteHeader {
viewer.initials
}
}
// Not in the design, whose sign-out lives on the account screen the prototype leaves out.
form(
.class("site-header__sign-out"),
.action(.Route.signOut),
.method(.post)
) {
button(
.class("site-header__text-link"),
.type(.submit)
) {
"Sign out"
}
}
}
/// An icon link with a count badge, omitted at zero.
@@ -3,14 +3,12 @@ import Foundation
extension String {
/// A namespace for the paths of the site's screens.
///
/// Screens not built yet are listed too, and answer with the not-found page until they are.
/// Single-sourced, so the navigation and the controllers never spell a path differently.
enum Route {
/// The account screen.
static let account = "/account"
/// The collection tracker.
static let collection = "/collection"
/// The events screen.
static let events = "/events"
/// The side-by-side comparison of copies.
static let compare = "/compare"
/// The public grading guide.
static let gradingGuide = "/grading-guide"
/// The browse / home screen.
@@ -19,26 +17,20 @@ extension String {
static let messages = "/messages"
/// The open-a-shop onboarding.
static let openShop = "/sell/open-a-shop"
/// The privacy statement.
static let privacy = "/privacy"
/// The passkey recovery screen.
static let recover = "/recover"
/// The search results screen.
static let search = "/search"
/// The seller dashboard.
static let sellerDashboard = "/sell"
/// The rules sellers agree to.
static let sellerRules = "/seller-rules"
/// The series index.
static let series = "/series"
/// The passkey sign-in screen.
static let signIn = "/sign-in"
/// The path a sign-out is posted to.
static let signOut = "/sign-out"
/// The terms of service.
static let terms = "/terms"
/// The want list.
static let wants = "/wants"
/// The first-sign-in screen.
static let welcome = "/welcome"
/// The path an offer is answered at.
/// - Parameter id: the offer's identifier.
@@ -117,6 +109,17 @@ extension String {
"/sales/\(number)"
}
/// The path of a series' screen, optionally on the copies of one issue.
/// - Parameters:
/// - slug: the series' URL slug.
/// - issue: the issue whose copies are listed, or `nil` (the default) for none.
static func series(
_ slug: String,
issue: Int? = nil
) -> String {
"\(series)/\(slug)\(issue.map { "?issue=\($0)" } ?? "")"
}
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
@@ -146,6 +149,25 @@ extension String {
return path
}
/// The path of the first-sign-in screen, continuing to the given path afterwards.
static func welcome(
returningTo path: String
) -> String {
"\(welcome)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")"
}
/// The path of the form adding a want, filled from a listing or with a title.
static func newWant(
listing slug: String? = nil,
title: String? = nil
) -> String {
let query = [slug.map { "listing=\($0)" }, title.map { "title=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" }]
.compactMap(\.self)
.joined(separator: "&")
return "\(wants)/new\(query.isEmpty ? "" : "?\(query)")"
}
/// The path of the sign-in screen, returning to the given path afterwards.
/// - Parameter path: the path the visitor's gated action started from.
static func signIn(
@@ -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"
}
}
@@ -0,0 +1,158 @@
import Foundation
import Persistence
/// The open-a-shop form as posted: the raw values, kept to fill the form back in, and what is wrong with them.
struct ShopForm: Sendable, Equatable {
// MARK: Properties
/// The values posted, by field name.
let values: [String: String]
// MARK: Initializers
/// Creates the form from the values posted; an empty form by default, with the most common choices made.
init(
values: [String: String] = [
Field.country.rawValue: "NL",
Field.kind.rawValue: Shop.Kind.privateSeller.rawValue,
Field.returns.rawValue: ShopForm.returns[0],
Field.handling.rawValue: "2",
]
) {
self.values = values
}
}
// MARK: - Enumerations
extension ShopForm {
/// A field of the form.
enum Field: String, CaseIterable, Sendable {
case about
case city
case country
case declaration
case handle
case handling
case kind
case kvk
case legalName = "legal-name"
case name
case returns
case vat
}
}
// MARK: - Extensions
extension ShopForm {
// MARK: Constants
/// The countries a shop ships from, by ISO code: the EU's nearest markets, and the UK.
static let countries = [("NL", "Netherlands"), ("BE", "Belgium"), ("DE", "Germany"), ("FR", "France"), ("GB", "United Kingdom")]
/// The handling times offered, in business days.
static let handlingDays = [1, 2, 5]
/// The returns policies offered.
static let returns = ["14-day returns", "14-day returns, if not as described", "Returns by agreement"]
// MARK: Methods
/// A field's value, trimmed, or `nil` when it was left blank.
func value(
_ field: Field
) -> String? {
values[field.rawValue]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.flatMap { $0.isEmpty ? nil : $0 }
}
/// Checks the form.
/// - Returns: the draft the form describes, or what is wrong, by field.
func validated() -> Result<ShopDraft, Problems> {
var problems = [Field: String]()
let name = value(.name) ?? ""
let handle = value(.handle)?.lowercased() ?? ""
let kind = value(.kind).flatMap(Shop.Kind.init)
if !(3...60).contains(name.count) {
problems[.name] = "Enter a shop name of 3 to 60 characters."
}
if handle.wholeMatch(of: /[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])/) == nil {
problems[.handle] = "Use 3 to 40 lowercase letters, digits and hyphens."
}
if value(.city) == nil {
problems[.city] = "Enter the city you ship from."
}
if !Self.countries.contains(where: { $0.0 == value(.country) }) {
problems[.country] = "Pick the country you ship from."
}
if (value(.about) ?? "").count > 600 {
problems[.about] = "Keep the description under 600 characters."
}
if kind == nil {
problems[.kind] = "Say whether you sell privately or as a business."
}
// Dutch consumer law has a business seller show who they are.
if kind == .business {
if value(.kvk)?.wholeMatch(of: /\d{8}/) == nil {
problems[.kvk] = "Enter your 8-digit KvK number."
}
if value(.legalName) == nil {
problems[.legalName] = "Enter the name your business is registered under."
}
}
if !Self.returns.contains(value(.returns) ?? "") {
problems[.returns] = "Pick a returns policy."
}
if !Self.handlingDays.contains(value(.handling).flatMap(Int.init) ?? 0) {
problems[.handling] = "Pick a handling time."
}
if value(.declaration) == nil {
problems[.declaration] = "Every seller agrees to declare defects and photograph the actual copy."
}
guard problems.isEmpty, let kind else {
return .failure(.init(fields: problems))
}
return .success(.init(
name: name,
handle: handle,
city: value(.city) ?? "",
country: value(.country) ?? "",
about: value(.about) ?? "",
kind: kind,
kvkNumber: value(.kvk),
vatID: value(.vat),
legalName: value(.legalName),
returnsNote: value(.returns) ?? "",
handlingDays: value(.handling).flatMap(Int.init) ?? 2
))
}
}
// MARK: - Structs
extension ShopForm {
/// What is wrong with a form, by field.
struct Problems: Error, Equatable, Sendable {
/// The problem with each field that has one.
var fields: [Field: String]
}
}
@@ -50,6 +50,7 @@ extension StaticFile {
/// Spelled out because Swift cannot enumerate a type's own constants: a file missing here is still served, but goes untested.
static let all: [Self] = [
.appleTouchIcon,
.collecting,
.favicon,
.fontAnton,
.fontArchivo,
@@ -111,6 +112,9 @@ extension StaticFile {
/// The `apple-touch-icon.png` icon.
static let appleTouchIcon = Self("apple-touch-icon", as: .png)
/// The `css/collecting.css` stylesheet shared by the series screens and the comparison.
static let collecting = Self("collecting", as: .css)
/// The `favicon.ico` icon.
static let favicon = Self("favicon", as: .ico)
@@ -49,15 +49,18 @@ extension Viewer {
/// - Parameters:
/// - user: the signed-in account.
/// - shopSlug: the slug of the shop the account owns, or `nil` (the default) when it owns none.
/// - newWantMatches: the number of new listings matching the account's want list; none by default.
/// - unreadMessages: the number of conversations holding something the account has not seen; none by default.
init(
_ user: User,
shopSlug: String? = nil,
newWantMatches: Int = 0,
unreadMessages: Int = 0
) {
self.init(
displayName: user.displayName,
shopSlug: shopSlug,
newWantMatches: newWantMatches,
unreadMessages: unreadMessages
)
}
@@ -0,0 +1,332 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct AccountController<Context: RequestContext> {
// MARK: Properties
/// The repository accounts and their passkeys are read and changed through.
private let accounts: AccountRepository
private let analytics: Analytics?
private let assetVersion: String?
/// The repository shops are opened through.
private let catalogue: CatalogueRepository
/// The store the signed-in account is read from.
private let sessions: SessionStore
// MARK: Initializers
/// Creates an account controller.
/// - Parameters:
/// - accounts: the repository accounts and their passkeys are read and changed through.
/// - catalogue: the repository shops are opened through.
/// - sessions: the store the signed-in account is read from.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
accounts: AccountRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.accounts = accounts
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
}
}
// MARK: - RouterController
extension AccountController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(.init(.Route.welcome), use: welcome)
routes.post(.init(.Route.welcome), use: introduce)
routes.get(.init(.Route.account), use: account)
routes.post(.init("\(String.Route.account)/name"), use: rename)
routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke)
routes.get(.init(.Route.openShop), use: shopForm)
routes.post(.init(.Route.openShop), use: openShop)
return routes
}
}
// MARK: - Account
private extension AccountController {
/// Handles a request for the account screen.
@Sendable
func account(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found)
}
return try await accountPage(user, request: request, context: context)
}
/// Renders the account screen — with a `422` when it reports a problem.
func accountPage(
_ user: User,
problem: String? = nil,
request: Request,
context: Context
) async throws -> Response {
let passkeys = try await accounts.passkeys(of: user)
let viewer = await sessions.viewer(for: request)
return HTMLResponse(
status: problem == nil ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
AccountPage(
user: user,
passkeys: passkeys,
problem: problem,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a new display name from the account screen.
@Sendable
func rename(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
guard let name = Self.displayName(form.name) else {
return try await accountPage(user, problem: Self.nameProblem, request: request, context: context)
}
await sessions.update(try await accounts.rename(user, to: name))
return .redirect(to: .Route.account, type: .normal)
}
/// Handles the revoking of one of the account's own passkeys.
@Sendable
func revoke(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context)
try await accounts.revokePasskey(form.credential, of: user)
return .redirect(to: "\(String.Route.account)#passkeys", type: .normal)
}
}
// MARK: - First sign-in
private extension AccountController {
/// Handles the display name picked on first sign-in, and leads on to what the visitor came for.
@Sendable
func introduce(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
let returnPath = String.Route.safeReturn(form.return)
guard let name = Self.displayName(form.name) else {
return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context)
}
await sessions.update(try await accounts.rename(user, to: name))
return .redirect(to: returnPath, type: .normal)
}
/// Handles a request for the first-sign-in screen.
@Sendable
func welcome(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn, type: .found)
}
return welcomePage(
name: user.displayName,
returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")),
request: request,
context: context
)
}
/// Renders the first-sign-in screen — with a `422` when it reports a problem.
func welcomePage(
name: String,
returnPath: String,
problem: String? = nil,
request: Request,
context: Context
) -> Response {
HTMLResponse(
status: problem == nil ? .ok : .unprocessableContent,
additionalHeaders: [.cacheControl: "no-store"]
) { [analytics, assetVersion] in
WelcomePage(
displayName: name,
returnPath: returnPath,
problem: problem,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Opening a shop
private extension AccountController {
/// Handles the open-a-shop form posted: opens the shop, and leads to its first listing.
@Sendable
func openShop(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal)
}
let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context)
let form = ShopForm(values: values)
switch form.validated() {
case .failure(let problems):
return await shopPage(form, problems: problems.fields, request: request, context: context)
case .success(let draft):
do {
_ = try await catalogue.openShop(draft, for: user)
} catch ShopError.handleTaken {
return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context)
} catch ShopError.alreadyOwner {
return .redirect(to: .Route.sellerDashboard, type: .normal)
}
return .redirect(to: NewListingPage.path, type: .normal)
}
}
/// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard.
@Sendable
func shopForm(
request: Request,
context: Context
) async throws -> Response {
guard await sessions.user(for: request) != nil else {
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found)
}
guard await sessions.viewer(for: request)?.hasShop != true else {
return .redirect(to: .Route.sellerDashboard, type: .found)
}
return await shopPage(.init(), request: request, context: context)
}
/// Renders the open-a-shop form — with a `422` when it reports problems.
func shopPage(
_ form: ShopForm,
problems: [ShopForm.Field: String] = [:],
request: Request,
context: Context
) async -> Response {
let viewer = await sessions.viewer(for: request)
return HTMLResponse(
status: problems.isEmpty ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
OpenShopPage(
form: form,
problems: problems,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Helpers
private extension AccountController {
// MARK: Constants
static var nameProblem: String { "Enter a display name of 2 to 40 characters." }
// MARK: Methods
/// A display name as posted, trimmed; or `nil` when it is too short or too long.
static func displayName(
_ name: String
) -> String? {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
return (2...40).contains(name.count) ? name : nil
}
}
// MARK: - Structs
/// The fields a display name form posts.
private struct NameForm: Decodable {
let name: String
let `return`: String?
}
/// The field a passkey's revoke form posts.
private struct RevokeForm: Decodable {
let credential: String
}
@@ -0,0 +1,295 @@
import Foundation
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves what a collector uses: the want list, for a signed-in account, and the public series screens and comparison.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct CollectingController<Context: RequestContext> {
// MARK: Properties
private let analytics: Analytics?
private let assetVersion: String?
/// The repository listings are read through.
private let catalogue: CatalogueRepository
/// The repository wants and series are read and written through.
private let collecting: CollectingRepository
/// The store the signed-in account is read from.
private let sessions: SessionStore
// MARK: Initializers
/// Creates a collecting controller.
/// - Parameters:
/// - collecting: the repository wants and series are read and written through.
/// - catalogue: the repository listings are read through.
/// - sessions: the store the signed-in account is read from.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
collecting: CollectingRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.collecting = collecting
self.sessions = sessions
}
}
// MARK: - RouterController
extension CollectingController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(.init(.Route.wants), use: wants)
routes.post(.init(.Route.wants), use: addWant)
routes.get(.init("\(String.Route.wants)/new"), use: newWant)
routes.post(.init("\(String.Route.wants)/:id/alerts"), use: toggleAlerts)
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
routes.get(.init(.Route.series), use: seriesIndex)
routes.get(.init("\(String.Route.series)/:slug"), use: series)
routes.get(.init(.Route.compare), use: compare)
return routes
}
}
// MARK: - Wants
private extension CollectingController {
/// Handles a want posted from the form.
@Sendable
func addWant(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(WantForm.self, from: request, context: context)
try await collecting.addWant(
title: form.title,
minGrade: form.minGrade.flatMap(Double.init),
maxPriceCents: form.maxPrice.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }.flatMap { $0 > 0 ? $0 * 100 : nil },
slabbedOnly: form.slabbed == "1",
for: user
)
return .redirect(to: .Route.wants, type: .normal)
}
/// Handles a request for the add-a-want form, filled from a listing being saved, or with a title — a gap in a series.
@Sendable
func newWant(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
var draft = WantsPage.Draft(title: request.uri.queryParameters.get("title") ?? "")
if
let slug = request.uri.queryParameters.get("listing"),
let listing = try await catalogue.listing(slug: slug)
{
draft = .init(title: listing.title, minGrade: listing.grade, maxPriceCents: listing.priceCents)
}
return try await wantsPage(user, draft: draft, request: request, context: context)
}
/// Handles the removal of one of the account's wants.
@Sendable
func removeWant(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
}
if let id = context.parameters.get("id", as: UUID.self) {
try await collecting.removeWant(id, of: user)
}
return .redirect(to: .Route.wants, type: .normal)
}
/// Handles the switching of a want's alerts.
@Sendable
func toggleAlerts(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
}
if let id = context.parameters.get("id", as: UUID.self) {
try await collecting.toggleAlerts(ofWant: id, of: user)
}
return .redirect(to: .Route.wants, type: .normal)
}
/// Handles a request for the want list. Looking at it is what makes its matches no longer new.
@Sendable
func wants(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .found)
}
return try await wantsPage(user, draft: nil, request: request, context: context)
}
/// Renders the want list with its matches still marked new, then marks them seen — so the header's bell is quiet on the next page.
func wantsPage(
_ user: User,
draft: WantsPage.Draft?,
request: Request,
context: Context
) async throws -> Response {
let wants = try await collecting.wants(of: user)
try await collecting.markWantsSeen(by: user)
let viewer = await sessions.viewer(for: request)
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
WantsPage(
wants: wants,
draft: draft,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Series
private extension CollectingController {
/// Handles a request for the comparison of the copies named in the query, up to three.
@Sendable
func compare(
request: Request,
context: Context
) async throws -> HTMLResponse {
var copies = [Listing]()
for slug in request.uri.queryParameters.getAll("copy").prefix(SeriesPage.maxCompared) {
if let copy = try await catalogue.listing(slug: slug), copy.isAvailable {
copies.append(copy)
}
}
let viewer = await sessions.viewer(for: request)
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion, copies] in
ComparePage(
copies: copies,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for a series; an unknown slug is not found.
@Sendable
func series(
request: Request,
context: Context
) async throws -> HTMLResponse {
let slug = try context.parameters.require("slug")
guard let series = try await collecting.series(slug: slug) else {
throw HTTPError(.notFound)
}
// Every copy of the series, wherever it ships from: a series screen is the whole market for it.
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
let issue = request.uri.queryParameters.get("issue", as: Int.self)
let viewer = await sessions.viewer(for: request)
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SeriesPage(
series: series,
listings: listings,
issue: issue,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for the series index.
@Sendable
func seriesIndex(
request: Request,
context: Context
) async throws -> HTMLResponse {
let listings = try await catalogue.listings()
let entries = try await collecting.allSeries().map { series in
(series, listings.filter { $0.seriesSlug == series.slug })
}
let viewer = await sessions.viewer(for: request)
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SeriesIndexPage(
entries: entries,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
}
}
// MARK: - Structs
/// The fields the add-a-want form posts.
private struct WantForm: Decodable {
let title: String
let minGrade: String?
let maxPrice: String?
let slabbed: String?
enum CodingKeys: String, CodingKey {
case title
case minGrade = "min-grade"
case maxPrice = "max-price"
case slabbed
}
}
@@ -21,6 +21,9 @@ public struct ListingFormController<Context: RequestContext> {
/// The repository listings are published through.
private let catalogue: CatalogueRepository
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void
/// The store the photos are kept in.
private let photos: PhotoStore
@@ -40,15 +43,18 @@ public struct ListingFormController<Context: RequestContext> {
/// - photos: the store the photos are kept in.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
public init(
catalogue: CatalogueRepository,
trade: TradeRepository,
sessions: SessionStore,
photos: PhotoStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
) {
self.analytics = analytics
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.photos = photos
@@ -151,6 +157,8 @@ private extension ListingFormController {
throw HTTPError(.internalServerError)
}
await onCatalogueChange()
return .redirect(to: listing.path, type: .normal)
}
@@ -8,8 +8,8 @@ import WebAuthn
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
///
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has
/// no other proof of who is asking, so that would hand the account over.
/// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's
/// own session: signed out there is no proof of who is asking, so it would hand the account over.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct PasskeyController<Context: RequestContext> {
@@ -134,7 +134,35 @@ private extension PasskeyController {
user = joined
return try await signedIn(user, returningTo: answer.return)
// A new account picks its display name before going on.
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
case .addition(let userID):
guard let owner = await sessions.user(for: request), owner.id == userID else {
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
}
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
let credential = try await manager.finishRegistration(
challenge: ceremony.challenge,
credentialCreationData: answer.credential,
confirmCredentialIDNotRegisteredYet: { [accounts] in
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
}
)
try await accounts.addPasskey(
to: owner,
credentialID: Self.base64URL(credential.id),
publicKey: credential.publicKey,
signCount: credential.signCount
)
// The bare path: from the account screen, one ending in a fragment would not reload it.
var response = Self.json(.ok, #"{"location":"\#(String.Route.account)"}"#)
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
return response
case .authentication:
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
let credentialID = Self.base64URL(answer.credential.id.asString())
@@ -167,8 +195,23 @@ private extension PasskeyController {
request: Request,
context: Context
) async throws -> Response {
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
.trimmingCharacters(in: .whitespaces) ?? ""
let start = try? await request.decode(as: Start.self, context: context)
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
// From the account screen: another passkey for the account signed in.
if start?.add == true {
guard let owner = await sessions.user(for: request) else {
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
}
let options = manager.beginRegistration(user: .init(
id: Array(owner.id.uuidString.utf8),
name: owner.email,
displayName: owner.displayName
))
return try await begin(.addition(userID: owner.id), options: options, challenge: options.challenge, mode: "create")
}
// No email: whichever passkey the browser holds for this site.
guard !typed.isEmpty else {
@@ -299,6 +342,7 @@ private extension PasskeyController {
/// What the script posts to start a ceremony.
private struct Start: Decodable {
let email: String?
let add: Bool?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
@@ -19,17 +19,14 @@ public struct RootController<Context: RequestContext> {
// MARK: Properties
/// What a page rendered per request is built from, the same as the cached ones.
private let page: @Sendable (Locale, Viewer?) -> IndexPage
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
private let sessions: SessionStore?
/// The landing pages. Whatever changes the catalogue refreshes them through it.
public let home: HomePages
/// Negotiates the language the bare route answers in.
private let negotiate: Negotiate
/// The landing page, rendered once per supported language and reused for every request.
private let responses: LocalizedHTMLCollectionResponse
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
private let sessions: SessionStore?
// MARK: Initializers
@@ -47,28 +44,14 @@ public struct RootController<Context: RequestContext> {
listings: HomeListings = .init(),
sessions: SessionStore? = nil
) {
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
.init(
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings,
viewer: $1
)
}
self.home = .init(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings
)
self.negotiate = .init(bundle: .module)
self.page = page
self.sessions = sessions
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
// and carry it too.
self.responses = .init(
bundle: .module,
variesOnAcceptLanguage: true
) {
page($0, nil)
}
}
}
@@ -156,14 +139,19 @@ private extension RootController {
context: Context
) async -> Response {
guard let viewer = await sessions?.viewer(for: request) else {
return responses.response(
for: language,
return await home.response(
in: language,
request: request
)
}
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
page(.init(identifier: language), viewer)
let page = await home.page(
in: language,
for: viewer
)
return HTMLResponse(additionalHeaders: .perViewer) {
page
}
.response(from: request, context: context)
}
@@ -194,8 +194,13 @@ private extension SessionController {
)
}
let token = await sessions.start(for: try await accounts.account(email: email))
var response = Response.redirect(to: returnPath, type: .normal)
let account = try await accounts.signIn(email: email)
let token = await sessions.start(for: account.user)
// A new account picks its display name before going on.
var response = Response.redirect(
to: account.isNew ? .Route.welcome(returningTo: returnPath) : returnPath,
type: .normal
)
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
/// The repository listings and shops are read through.
private let catalogue: CatalogueRepository
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void
/// The store the signed-in account is read from.
private let sessions: SessionStore
@@ -35,14 +38,17 @@ public struct TradeController<Context: RequestContext> {
/// - sessions: the store the signed-in account is read from.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
public init(
trade: TradeRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
) {
self.analytics = analytics
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
@@ -95,7 +101,11 @@ private extension TradeController {
}
do {
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal)
let number = try await trade.buy(listing: slug, buyer: user)
await onCatalogueChange()
return .redirect(to: .Route.sale(number), type: .normal)
} catch let error as TradeError {
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
}
@@ -409,6 +419,10 @@ private extension TradeController {
let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context)
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
if number.flatMap(\.self) != nil {
await onCatalogueChange()
}
return .redirect(
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
type: .normal
@@ -0,0 +1,107 @@
import Foundation
import Hummingbird
import Infrastructure
/// The landing page, rendered once per language and cached — and rendered again whenever the catalogue changes.
///
/// A sale takes a copy off the page and a new listing puts one on, so whatever changes the catalogue calls ``refresh(with:)``.
public actor HomePages {
// MARK: Properties
/// What a page is built from, given its locale, its listings, and its viewer.
private let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage
/// The listings the pages show in their rows.
private var listings: HomeListings
/// The pages rendered for signed-out visitors, one per language.
private var responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// Renders the landing pages.
/// - Parameters:
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the pages derive their canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
/// - listings: the listings the pages show in their rows; none by default.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage = {
.init(
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: $1,
viewer: $2
)
}
self.listings = listings
self.page = page
self.responses = Self.render(page, listings)
}
// MARK: Methods
/// Renders the pages again, around new listings.
public func refresh(
with listings: HomeListings
) {
self.listings = listings
self.responses = Self.render(page, listings)
}
}
// MARK: - Extensions
extension HomePages {
// MARK: Methods
/// The page rendered around a signed-in viewer, with the current listings.
func page(
in language: String,
for viewer: Viewer
) -> IndexPage {
page(.init(identifier: language), listings, viewer)
}
/// The cached response for a signed-out visitor.
func response(
in language: String,
request: Request
) -> Response {
responses.response(
for: language,
request: request
)
}
}
// MARK: - Helpers
private extension HomePages {
/// Renders the signed-out pages. The bare route negotiates its language, so they declare `Vary: Accept-Language`.
static func render(
_ page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage,
_ listings: HomeListings
) -> LocalizedHTMLCollectionResponse {
.init(
bundle: .module,
variesOnAcceptLanguage: true
) {
page($0, listings, nil)
}
}
}
@@ -86,6 +86,8 @@ extension PasskeyCeremonies {
/// What a ceremony does.
enum Kind: Sendable, Equatable {
/// Adds a passkey to the signed-in account of the given identifier.
case addition(userID: UUID)
/// Signs in with a passkey registered earlier.
case authentication
/// Creates the account of the given email, with its first passkey.
@@ -109,7 +111,7 @@ extension PasskeyCeremonies {
name: cookieName,
value: token,
maxAge: token.isEmpty ? 0 : Int(lifetime),
path: String.Route.signIn,
path: "\(String.Route.signIn)/passkey",
secure: isSecure,
httpOnly: true,
sameSite: .strict
@@ -10,6 +10,9 @@ public actor SessionStore {
// MARK: Properties
/// The repository a viewer's new want matches are read through, or `nil` to leave them out.
private let collecting: CollectingRepository?
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
private let trade: TradeRepository?
@@ -19,10 +22,14 @@ public actor SessionStore {
// MARK: Initializers
/// Creates an empty session store.
/// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
/// - Parameters:
/// - trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
/// - collecting: the repository a viewer's new want matches are read through, or `nil` (the default) to leave them out.
public init(
trade: TradeRepository? = nil
trade: TradeRepository? = nil,
collecting: CollectingRepository? = nil
) {
self.collecting = collecting
self.trade = trade
}
@@ -50,6 +57,15 @@ public actor SessionStore {
return token
}
/// Replaces an account in every session it holds, after it was renamed.
public func update(
_ user: User
) {
for (token, held) in users where held.id == user.id {
users[token] = user
}
}
/// The account the session with the given token belongs to, or `nil` when there is no such session.
public func user(
for token: String
@@ -114,6 +130,7 @@ extension SessionStore {
return .init(
user,
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
newWantMatches: (try? await collecting?.newMatchCount(for: user)) ?? 0,
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
)
}