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

This commit is contained in:
javier committed 2026-09-21 13:53:13 +02:00
1 parent 256d22df40
commit f2f445827b
40 files changed
+1906 -37

No files matched your search

@@ -0,0 +1,22 @@
import Elementary
/// What a browser without passkeys is told. Shown by default; `js/passkey.js` hides it once it finds WebAuthn.
struct PasskeyUnsupported: HTML {
// MARK: Computed
var body: some HTML {
div(
.class("notice"),
.custom(name: "data-passkey-unsupported", value: "")
) {
p {
strong {
"This browser can't use passkeys."
}
" Open this page in Safari, Chrome, Edge or Firefox on a device with a screen lock — with scripts allowed."
}
}
}
}
@@ -77,6 +77,7 @@ private extension SiteFooter {
("Open a shop", .Route.openShop),
("Seller dashboard", .Route.sellerDashboard),
("Grading guide", .Route.gradingGuide),
("Trade at events", .Route.events),
]
),
(
@@ -84,8 +85,10 @@ 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 link shown on tablet only.
/// The format links, plus the series and events links shown on tablet only.
var navigation: some HTML {
nav(
.class("site-nav"),
@@ -134,6 +134,12 @@ private extension SiteHeader {
) {
"Series"
}
a(
.class("site-nav__link site-nav__link--extra"),
.href(.Route.events)
) {
"Events"
}
}
}
@@ -18,6 +18,29 @@ extension Date {
}
}
/// The days an event runs over, e.g. `Sat 26 – Sun 27 Sep`.
static func span(
from start: Date,
to end: Date
) -> String {
let first = start.formatted(style.weekday(.abbreviated).day())
let last = end.formatted(style.weekday(.abbreviated).day().month(.abbreviated))
return calendar.isDate(start, inSameDayAs: end) ? last : "\(first) – \(last)"
}
/// Whether the date falls within the week from the given moment: an event "this weekend".
func isWithinAWeek(
of now: Date
) -> Bool {
(0..<7 * 86_400).contains(timeIntervalSince(now)) || Self.calendar.isDate(self, inSameDayAs: now)
}
/// The date as a day on a record, e.g. `20 Sep`.
var dayStamp: String {
formatted(Self.style.day().month(.abbreviated))
}
/// The date as a stamp on a record: `today, 09:14`, `yesterday, 21:38`, `20 Sep, 14:05`, or with the year once it differs.
///
/// Read on Amsterdam's clock: the marketplace is NL-first.
@@ -7,8 +7,12 @@ extension String {
enum Route {
/// The account screen.
static let account = "/account"
/// The collection tracker.
static let collection = "/collection"
/// The side-by-side comparison of copies.
static let compare = "/compare"
/// The events where shops trade in person.
static let events = "/events"
/// The public grading guide.
static let gradingGuide = "/grading-guide"
/// The browse / home screen.
@@ -17,6 +21,8 @@ extension String {
static let messages = "/messages"
/// The open-a-shop onboarding.
static let openShop = "/sell/open-a-shop"
/// The passkey recovery screen.
static let recover = "/recover"
/// The search results screen.
static let search = "/search"
/// The seller dashboard.
@@ -101,6 +107,21 @@ extension String {
"\(listing(slug))/offer"
}
/// The path of the search narrowed to the copies that can be picked up at an event.
/// - Parameter slug: the event's URL slug.
static func pickup(
at slug: String
) -> String {
"\(search)?pickup=\(slug)&ships-from=anywhere"
}
/// The path a one-time recovery link leads to.
static func recover(
token: String
) -> String {
"\(recover)/\(token)"
}
/// The path of a sale record.
/// - Parameter number: the record's number.
static func sale(
@@ -88,6 +88,9 @@ extension AccountPage: Page {
a(.href(.Route.wants)) {
"Want list"
}
a(.href(.Route.collection)) {
"Collection"
}
a(.href(.Route.messages)) {
"Messages"
}
@@ -0,0 +1,271 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The collection tracker: what the viewer owns of each series, what is missing, and which of the gaps are listed right now.
struct CollectionPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// Every series in the catalogue, for the add-books form.
let catalogue: [Series]
/// The series the viewer owns issues of, each with the issues owned and the numbers of those listed for sale.
let entries: [Entry]
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the collection tracker.
/// - Parameters:
/// - catalogue: every series in the catalogue.
/// - owned: the issues the viewer owns, by series slug.
/// - listings: every listing for sale, to tell which gaps are listed.
/// - 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(
catalogue: [Series],
owned: [String: Set<Int>],
listings: [Listing],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.entries = catalogue.compactMap { series in
guard let issues = owned[series.slug], !issues.isEmpty else {
return nil
}
let ofSeries = listings.filter { $0.seriesSlug == series.slug }
return .init(
series: series,
owned: issues,
listed: Set(ofSeries.compactMap(\.issueNumber)),
cover: ofSeries.first?.cover
)
}
self.locale = .init(identifier: "en")
self.viewer = viewer
}
}
// MARK: - Structs
extension CollectionPage {
/// A series in the collection.
struct Entry: Sendable {
let series: Series
let owned: Set<Int>
let listed: Set<Int>
let cover: Listing.Cover?
/// The issues of the run not owned yet.
var missing: Set<Int> {
Set(1...max(series.issueCount, 1)).subtracting(owned)
}
/// The share of the run owned, to the nearest twentieth — what the progress bar's classes come in.
var progress: Int {
Int((Double(owned.count) / Double(max(series.issueCount, 1)) * 20).rounded()) * 5
}
}
}
// MARK: - Page
extension CollectionPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page wants")) {
div(.class("section-head")) {
h1(.class("type-h1")) {
"Collection"
}
span(.class("type-small")) {
"\(total(\.owned)) \(total(\.owned) == 1 ? "book" : "books") · \(entries.count) series"
}
}
if entries.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"Your collection is empty"
}
p(.class("type-body-sm")) {
"Add what you own and every series page shows the gaps — and which of them are listed right now."
}
}
} else {
ul(.class("tiles tiles--three")) {
tile(total(\.owned), "owned")
tile(total(\.missing), "missing")
tile(entries.reduce(0) { $0 + $1.missing.intersection($1.listed).count }, "listed now")
}
ul(.class("wants__list")) {
ForEach(entries) { entry in
li {
a(
.class("card collection__series"),
.href(.Route.series(entry.series.slug))
) {
if let cover = entry.cover {
Cover(cover, modifier: "cover--thumb")
}
span(.class("collection__text")) {
span(.class("collection__top")) {
strong {
entry.series.name
}
strong {
"\(entry.owned.count)/\(entry.series.issueCount)"
}
}
span(.class("type-small")) {
"\(entry.series.publisher) · \(entry.series.years)"
}
span(.class("progress progress--\(entry.progress)")) {}
if entry.missing.isEmpty {
span(.class("collection__complete")) {
"Complete"
}
} else {
span(.class("type-small")) {
"\(entry.missing.count) missing · \(entry.missing.intersection(entry.listed).count) listed now"
}
}
}
}
}
}
}
}
form(
.class("card wants__form"),
.action(.Route.collection),
.method(.post)
) {
h2(.class("type-panel")) {
"Add books"
}
div(.class("listing-form__grid")) {
SelectField(
name: "series",
title: "Series",
options: catalogue.map { ($0.slug, "\($0.name) · \($0.issueCount) issues") }
)
TextField(
name: "issues",
title: "Issues you own",
placeholder: "1-6, 9, 12",
hint: "Numbers and ranges, separated by commas",
isWide: true,
maxLength: 120
)
}
div(.class("listing-form__submit")) {
button(
.class("button button--primary"),
.type(.submit)
) {
"Add to collection"
}
}
}
p(.class("type-small wants__note")) {
"On a series page, every issue has its own switch — and the gaps are one tap from an alert."
}
}
}
}
@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 {
"Collection — CCN"
}
}
// MARK: - Helpers
private extension CollectionPage {
/// A figure and what it counts.
func tile(
_ figure: Int,
_ label: String
) -> some HTML {
li(.class("card tiles__tile")) {
span(.class("type-stat")) {
"\(figure)"
}
span(.class("type-small")) {
label
}
}
}
/// The size of a set, summed over the series.
func total(
_ issues: KeyPath<Entry, Set<Int>>
) -> Int {
entries.reduce(0) { $0 + $1[keyPath: issues].count }
}
}
// MARK: - Extensions
extension CollectionPage {
// MARK: Methods
/// The issue numbers a collector typed — `1-6, 9, 12` — capped so a range cannot run away.
static func issues(
in text: String
) -> [Int] {
text
.split { $0 == "," || $0 == " " || $0 == ";" }
.flatMap { part -> [Int] in
let bounds = part.split(separator: "-").compactMap { Int($0) }
switch bounds.count {
case 1: return [bounds[0]]
case 2 where bounds[0] <= bounds[1] && bounds[1] - bounds[0] < 500: return Array(bounds[0]...bounds[1])
default: return []
}
}
.filter { (1...999).contains($0) }
}
}
@@ -0,0 +1,137 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The events where shops trade in person: agree on the site, skip shipping, collect the copy at the con.
struct EventsPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The events still to come, soonest first, each with the number of listings that can be picked up there.
let events: [(event: Event, listingCount: Int)]
/// The moment "this weekend" is judged from.
let now: Date
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the events screen.
/// - Parameters:
/// - events: the events still to come, soonest first.
/// - listings: every listing for sale, counted per event by the shops trading there.
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
/// - now: the moment "this weekend" is judged from; 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(
events: [Event],
listings: [Listing],
viewer: Viewer? = nil,
now: Date = .init(),
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.events = events.map { event in
let shops = Set(event.shops.map(\.slug))
return (event, listings.count { shops.contains($0.shop.slug) })
}
self.locale = .init(identifier: "en")
self.now = now
self.viewer = viewer
}
}
// MARK: - Page
extension EventsPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page wants")) {
div(.class("events__intro")) {
h1(.class("type-h1")) {
"Pick up at a con"
}
p(.class("type-lead")) {
"Sellers mark which events they'll trade at. Agree on CCN, skip shipping, collect it in person."
}
}
if events.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"No events coming up"
}
p(.class("type-body-sm")) {
"Every copy still ships, tracked."
}
}
}
ul(.class("wants__list")) {
ForEach(events) { entry in
li {
a(
.class("card events__event"),
.href(.Route.pickup(at: entry.event.slug))
) {
span(.class("wants__top")) {
span(.class("events__name")) {
strong {
entry.event.name
}
span(.class("type-small")) {
Date.span(from: entry.event.startsOn, to: entry.event.endsOn)
}
}
if entry.event.startsOn.isWithinAWeek(of: now) {
Chip("This weekend", tone: .warn)
}
}
span(.class("type-small")) {
entry.event.venue
}
span(.class("events__chips")) {
Chip("\(entry.event.shops.count) \(entry.event.shops.count == 1 ? "shop" : "shops") trading")
Chip("\(entry.listingCount) \(entry.listingCount == 1 ? "listing" : "listings") for pickup")
}
}
}
}
}
p(.class("type-small wants__note")) {
"Selling? Pick the events you'll trade at on your seller dashboard."
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.trade]
}
var summary: String? {
"Comic cons where CCN shops trade in person: agree on a copy online, collect it at the event."
}
var title: String {
"Events — CCN"
}
}
@@ -15,6 +15,12 @@ struct ListingPage {
/// The listing shown.
let listing: Listing
/// The number of other copies of the same title for sale: in this shop, and across the site.
let otherCopies: (inShop: Int, everywhere: Int)
/// The upcoming events the shop trades at, where the copy can be picked up.
let pickups: [Event]
/// Other listings from the same shop.
let related: [Listing]
@@ -27,12 +33,16 @@ struct ListingPage {
/// - Parameters:
/// - listing: the listing shown.
/// - related: other listings from the same shop.
/// - pickups: the upcoming events the shop trades at; none by default.
/// - otherCopies: the number of other copies of the same title for sale, in this shop and across the site; none by default.
/// - 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(
listing: Listing,
related: [Listing],
pickups: [Event] = [],
otherCopies: (inShop: Int, everywhere: Int) = (0, 0),
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -42,6 +52,8 @@ struct ListingPage {
self.assetVersion = assetVersion
self.listing = listing
self.locale = .init(identifier: "en")
self.otherCopies = otherCopies
self.pickups = pickups
self.related = related
}
@@ -228,6 +240,15 @@ private extension ListingPage {
span(.class("type-body-sm buy-box__terms")) {
"+ \(listing.shippingCents.euros) tracked shipping to NL"
listing.acceptsOffers ? " · accepts offers" : ""
if let event = pickups.first {
" · "
a(
.class("listing__series"),
.href(.Route.events)
) {
"or pick up at \(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))"
}
}
}
}
a(
@@ -239,8 +260,28 @@ private extension ListingPage {
}
}
if !listing.isAvailable {
p(.class("notice")) {
"This copy is sold. A sale was agreed on CCN."
div(.class("notice listing__sold")) {
p {
strong {
"This copy sold\(listing.soldAt.map { " on \($0.dayStamp)" } ?? "")."
}
" \(listing.shop.name) has \(otherCopies.inShop) more \(otherCopies.inShop == 1 ? "copy" : "copies") of \(listing.title), and \(otherCopies.everywhere) \(otherCopies.everywhere == 1 ? "is" : "are") listed across CCN."
}
div(.class("empty__actions")) {
a(
.class("button button--primary button--sm"),
.href(SearchParameters().setting(.query, to: listing.title).setting(.shipsFrom, to: "anywhere").path(.Route.search))
) {
"Other copies"
}
a(
.class("button button--outline button--sm"),
.href(.Route.newWant(title: listing.title))
) {
Icon(.bell, size: 15)
"Alert me"
}
}
}
} else if viewer?.shopSlug == listing.shop.slug {
p(.class("notice")) {
@@ -332,6 +332,25 @@ private extension MessagesPage {
"“\(note)”"
}
}
if offer.status == .expired, conversation.side == .buying, offer.listing.isAvailable {
p(.class("type-small")) {
"\(conversation.shop.name) didn't answer within 48 hours. The listing is still available at \(offer.listing.priceCents.euros)."
}
div(.class("answer")) {
a(
.class("button button--primary button--sm"),
.href(.Route.offer(offer.listing.slug))
) {
"Offer again"
}
a(
.class("button button--sm"),
.href(.Route.buy(offer.listing.slug))
) {
"Buy at \(offer.listing.priceCents.euros)"
}
}
}
if offer.status == .open, conversation.side == .selling {
AnswerOffer(
offer: offer,
@@ -0,0 +1,253 @@
import Elementary
import Foundation
import Infrastructure
/// The recovery of an account whose every passkey is lost: ask for a one-time link, then — from the link — set up a new passkey.
///
/// Drawn bare, like the sign-in. The prototype sends no mail, so it shows the link it would have sent.
struct RecoverPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// What the screen shows.
let step: Step
// MARK: Initializers
/// Creates the recovery screen.
/// - Parameters:
/// - step: what the screen shows.
/// - 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(
step: Step,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = .init(identifier: "en")
self.step = step
}
}
// MARK: - Enumerations
extension RecoverPage {
/// A step of the recovery.
enum Step: Sendable, Equatable {
/// Asking for the account's email, with what was wrong with the last one given.
case ask(problem: String?)
/// The link is on its way — here, on the screen, when the email has an account.
case sent(link: String?)
/// The link was followed: a new passkey can be set up. Carries the link's token.
case renew(token: String)
/// The link was used, ran out, or never existed.
case expired
}
}
// MARK: - Page
extension RecoverPage: Page {
// MARK: Computed
var content: some HTML {
div(.class("gate")) {
header(.class("gate__bar")) {
a(
.class("gate__close"),
.href(.Route.signIn),
.custom(name: "aria-label", value: "Back to sign in")
) {
Icon(.close)
}
span(.class("gate__wordmark")) {
"CCN"
}
}
main(.class("gate__main")) {
switch step {
case .ask(let problem):
ask(problem)
case .sent(let link):
sent(link)
case .renew(let token):
renew(token)
case .expired:
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"That link has run out"
}
p(.class("gate__lead")) {
"A recovery link works once, for an hour. Ask for a new one."
}
}
a(
.class("button button--primary button--xl button--block"),
.href(.Route.recover)
) {
"Send a new link"
}
}
}
}
}
@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.signIn]
}
var title: String {
"Recover your account — CCN"
}
}
// MARK: - Steps
private extension RecoverPage {
@HTMLBuilder
func ask(
_ problem: String?
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Recover your account"
}
p(.class("gate__lead")) {
"We'll email you a one-time link to set up a new passkey. Your old passkeys are removed when you do."
}
}
form(
.class("gate__form"),
.action(.Route.recover),
.method(.post)
) {
div(.class("field")) {
label(
.class("field__label"),
.for("email")
) {
"Account email"
}
input(
.class("input input--lg"),
.id("email"),
.type(.email),
.name("email"),
.required,
.placeholder("you@example.com"),
.custom(name: "autocomplete", value: "email")
)
FieldNote(name: "email", hint: nil, problem: problem)
}
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
) {
"Send recovery link"
}
}
p(.class("notice")) {
"Still have a passkey on another device? "
a(.href(.Route.signIn)) {
"Go back"
}
" and press Continue — your device will offer to use it."
}
}
@HTMLBuilder
func renew(
_ token: String
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Set up a new passkey"
}
p(.class("gate__lead")) {
"This replaces every passkey on the account, and signs you in."
}
}
div(
.class("gate__form"),
.custom(name: "data-passkey-form", value: "")
) {
input(
.type(.hidden),
.name("recover"),
.value(token)
)
button(
.class("button button--primary button--xl button--block"),
.type(.button),
.hidden,
.custom(name: "data-passkey", value: "recover")
) {
Icon(.key, size: 18)
"Create a new passkey"
}
p(
.class("field__error"),
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
PasskeyUnsupported()
}
}
@HTMLBuilder
func sent(
_ link: String?
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Check your email"
}
p(.class("gate__lead")) {
"If that email has an account, a one-time link is on its way. It works once, for an hour."
}
}
div(.class("notice notice--warn gate__demo")) {
p {
"Prototype: CCN sends no mail yet, so the link appears here instead — which also means anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built."
}
if let link {
a(
.class("button button--outline button--block"),
.href(link)
) {
"Open the recovery link"
}
} else {
p {
strong {
"No account has that email, so no link was made."
}
}
}
}
}
}
@@ -137,16 +137,27 @@ extension SearchPage: Page {
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"No listings match"
parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match"
}
p(.class("type-body-sm")) {
"Try fewer filters, or let listings ship from anywhere."
"Nothing listed meets that right now. Clear the filters — listings outside the EU are left out by default — or ask to be told when a copy appears."
}
a(
.class("button button--outline"),
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
) {
"Clear all filters"
div(.class("empty__actions")) {
if let query = parameters.value(.query) {
a(
.class("button button--primary"),
.href(.Route.newWant(title: query))
) {
Icon(.bell, size: 16)
"Alert me"
}
}
a(
.class("button button--outline"),
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
) {
"Clear all filters"
}
}
}
} else {
@@ -268,6 +279,7 @@ private extension SearchPage {
span(.class("type-small")) {
"Outside the EU, import VAT may apply."
}
checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event")
}
group("Key issues") {
checkbox(.keys, value: "1", label: "Key issues only")
@@ -12,6 +12,9 @@ struct SellerDashboardPage {
let assetVersion: String?
let locale: Locale
/// The upcoming events, each with whether the shop trades there.
let events: [(event: Event, isTrading: Bool)]
/// The shop's listings still for sale, newest first.
let listings: [Listing]
@@ -38,6 +41,7 @@ struct SellerDashboardPage {
/// - offers: the offers waiting for the seller's answer, the soonest to expire first.
/// - sales: the shop's sales, the latest agreed first.
/// - listings: the shop's listings still for sale, newest first.
/// - events: the upcoming events; none by default.
/// - 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.
@@ -47,6 +51,7 @@ struct SellerDashboardPage {
offers: [Offer],
sales: [Sale],
listings: [Listing],
events: [Event] = [],
viewer: Viewer? = nil,
now: Date = .init(),
assetVersion: String? = nil,
@@ -54,6 +59,7 @@ struct SellerDashboardPage {
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.events = events.map { event in (event, event.shops.contains { $0.slug == shop.slug }) }
self.listings = listings
self.locale = .init(identifier: "en")
self.now = now
@@ -231,6 +237,43 @@ extension SellerDashboardPage: Page {
}
}
}
if !events.isEmpty {
form(
.class("card sell__panel"),
.action("\(String.Route.sellerDashboard)/events"),
.method(.post)
) {
div(.class("section-head")) {
h2(.class("type-panel")) {
"Events you'll trade at"
}
span(.class("type-small")) {
"Buyers can agree online and collect at the con"
}
}
div(.class("listing-form__checks")) {
ForEach(events) { entry in
label(.class("check")) {
input(
.type(.checkbox),
.name("events"),
.value(entry.event.slug)
)
.attributes(.checked, when: entry.isTrading)
"\(entry.event.name) · \(Date.span(from: entry.event.startsOn, to: entry.event.endsOn))"
}
}
}
div(.class("listing-form__submit")) {
button(
.class("button button--dark button--sm"),
.type(.submit)
) {
"Save events"
}
}
}
}
}
}
}
@@ -18,6 +18,9 @@ struct SeriesPage {
/// Every listing of the series still for sale.
let listings: [Listing]
/// The issues the viewer owns of the series, or `nil` signed out.
let owned: Set<Int>?
/// The series shown.
let series: Series
@@ -31,6 +34,7 @@ struct SeriesPage {
/// - 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.
/// - owned: the issues the viewer owns of the series, or `nil` (the default) signed out.
/// - 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.
@@ -38,6 +42,7 @@ struct SeriesPage {
series: Series,
listings: [Listing],
issue: Int? = nil,
owned: Set<Int>? = nil,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -47,6 +52,7 @@ struct SeriesPage {
self.issue = issue
self.listings = listings
self.locale = .init(identifier: "en")
self.owned = owned
self.series = series
self.viewer = viewer
}
@@ -104,6 +110,19 @@ extension SeriesPage: Page {
p(.class("type-lead")) {
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now."
}
if let owned, series.issueCount > 0 {
div(.class("card series__collection")) {
p(.class("type-eyebrow")) {
"Your collection"
}
p(.class("type-panel")) {
"\(owned.count) of \(series.issueCount) owned"
}
p(.class("type-small")) {
"\(gaps.count) missing · \(gaps.filter { !$0.copies.isEmpty }.count) of them listed now"
}
}
}
ul(.class("series__keys")) {
ForEach(keys) { key in
li(.class("chip chip--warn")) {
@@ -125,7 +144,7 @@ extension SeriesPage: Page {
}
ol(.class("series__issues")) {
ForEach(issues) { entry in
li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) {
li(.class(issueClass(entry))) {
span(.class("series__number")) {
"#\(entry.number)"
}
@@ -143,6 +162,25 @@ extension SeriesPage: Page {
"None listed · alert me"
}
}
if let owned {
form(
.action("\(String.Route.series(series.slug))/own"),
.method(.post)
) {
input(
.type(.hidden),
.name("issue"),
.value("\(entry.number)")
)
button(
.class("series__own"),
.type(.submit),
.custom(name: "aria-pressed", value: owned.contains(entry.number) ? "true" : "false")
) {
owned.contains(entry.number) ? "Owned ✓" : "I own this"
}
}
}
}
}
}
@@ -274,6 +312,24 @@ extension SeriesPage: Page {
private extension SeriesPage {
/// The issues of the run the viewer does not own.
var gaps: [(number: Int, copies: [Listing])] {
issues.filter { !(owned ?? []).contains($0.number) }
}
/// An issue's tile: a gap reads quieter, and an owned issue is dimmed.
func issueClass(
_ entry: (number: Int, copies: [Listing])
) -> String {
[
"series__issue",
entry.copies.isEmpty ? "series__issue--gap" : nil,
owned?.contains(entry.number) == true ? "series__issue--owned" : nil,
]
.compactMap(\.self)
.joined(separator: " ")
}
/// The series' manga, trade paperbacks, and hardcovers.
var collected: [Listing] {
listings.filter { $0.issueNumber == nil }
@@ -24,6 +24,9 @@ struct ShopPage {
/// The tab, quick filters, and sort order applied.
let parameters: SearchParameters
/// The upcoming events the shop trades at.
let pickups: [Event]
/// The shop shown.
let shop: Shop
@@ -38,6 +41,7 @@ struct ShopPage {
/// - inventory: every listing of the shop, counted per format on the tabs.
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
/// - parameters: the tab, quick filters, and sort order applied.
/// - pickups: the upcoming events the shop trades at; none by default.
/// - 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.
@@ -46,6 +50,7 @@ struct ShopPage {
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
pickups: [Event] = [],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -64,6 +69,7 @@ struct ShopPage {
self.locale = .init(identifier: "en")
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.pickups = pickups
self.shop = shop
}
@@ -104,6 +110,12 @@ extension ShopPage: Page {
}
p(.class("type-body-sm shop__standing")) {
"\(shop.standing) · \(shop.shipsNote)"
if let event = pickups.first {
" · trading at "
a(.href(.Route.events)) {
"\(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))"
}
}
}
p(.class("shop__about")) {
shop.about
@@ -197,10 +209,12 @@ extension ShopPage: Page {
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing here right now"
counts.isEmpty ? "\(shop.name) is restocking" : "Nothing here right now"
}
p(.class("type-body-sm")) {
"No listing in this shop matches those filters."
counts.isEmpty
? "Nothing listed right now\(pickups.first.map { " — the shop is trading at \($0.name), \(Date.span(from: $0.startsOn, to: $0.endsOn))" } ?? "")."
: "No listing in this shop matches those filters."
}
a(
.class("button button--outline"),
@@ -110,6 +110,7 @@ extension SignInPage: Page {
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
PasskeyUnsupported()
div(.class("field")) {
label(
.class("field__label"),
@@ -154,6 +155,11 @@ extension SignInPage: Page {
}
}
}
p(.class("gate__recover")) {
a(.href(.Route.recover)) {
"Lost access to your passkeys?"
}
}
}
}
}
@@ -81,10 +81,13 @@ extension WantsPage: Page {
if wants.isEmpty, draft == nil {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing on your list yet"
"No wants yet"
}
p(.class("type-body-sm")) {
"Add the issue you're missing, and CCN checks it against every listing published."
"Add the issues you're hunting. We check every new listing against them and tell you the moment one appears."
}
p(.class("type-small")) {
"Tip: on any series page, a missing issue is one tap from an alert."
}
}
}
@@ -71,6 +71,9 @@ extension SearchParameters {
// MARK: Constants
/// The `pickup` value standing for any upcoming event, rather than one event's slug.
static let anyEvent = "any"
/// The number of listings on a page.
static let pageSize = 12
@@ -152,6 +155,10 @@ extension SearchParameters {
))
}
if has(.pickup) {
pills.append(.init(label: "Event pickup", removed: removing(.pickup)))
}
if filter.keysOnly {
pills.append(.init(label: "Key issues", removed: removing(.keys)))
}
@@ -278,6 +285,7 @@ extension SearchParameters {
case keys
case offers
case page
case pickup
case priceMax = "price-max"
case priceMin = "price-min"
case publisher
@@ -18,6 +18,9 @@ public struct CatalogueController<Context: RequestContext> {
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
private let assetVersion: String?
/// The repository the events are read through, or `nil` to leave pickup out.
private let collecting: CollectingRepository?
/// The repository the shops and listings are read through.
private let repository: CatalogueRepository
@@ -30,16 +33,19 @@ public struct CatalogueController<Context: RequestContext> {
/// - Parameters:
/// - repository: the repository the shops and listings are read through.
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
/// - collecting: the repository the events are read through, or `nil` (the default) to leave pickup out.
/// - 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(
repository: CatalogueRepository,
sessions: SessionStore? = nil,
collecting: CollectingRepository? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.collecting = collecting
self.repository = repository
self.sessions = sessions
}
@@ -88,6 +94,13 @@ private extension CatalogueController {
// MARK: Methods
/// The upcoming events a shop trades at, soonest first.
func events(
tradedAtBy slug: String
) async throws -> [Event] {
try await collecting?.upcomingEvents().filter { $0.shops.contains { $0.slug == slug } } ?? []
}
/// Handles a request for the grading guide.
@Sendable
func gradingGuide(
@@ -115,10 +128,13 @@ private extension CatalogueController {
throw HTTPError(.notFound)
}
let related = try await repository
let inShop = try await repository
.listings(matching: .init(shop: listing.shop.slug))
.filter { $0.slug != listing.slug }
.prefix(Self.relatedCount)
let related = inShop.prefix(Self.relatedCount)
let pickups = try await events(tradedAtBy: listing.shop.slug)
// Only a sold copy says how many others there are.
let sameTitle = listing.isAvailable ? [] : try await repository.listings().filter { $0.title == listing.title }
let viewer = await sessions?.viewer(for: request)
@@ -126,6 +142,8 @@ private extension CatalogueController {
ListingPage(
listing: listing,
related: Array(related),
pickups: pickups,
otherCopies: (inShop.count { $0.title == listing.title }, sameTitle.count),
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
@@ -140,7 +158,16 @@ private extension CatalogueController {
context: Context
) async throws -> HTMLResponse {
let parameters = SearchParameters(query: request.uri.query)
let matches = try await repository.listings(matching: parameters.filter)
var filter = parameters.filter
// Pickup narrows the search to the shops trading at one event — or at any, for the rail's checkbox.
if let pickup = parameters.value(.pickup) {
filter.shops = try await collecting?.upcomingEvents()
.filter { pickup == SearchParameters.anyEvent || $0.slug == pickup }
.flatMap { $0.shops.map(\.slug) } ?? []
}
let matches = try await repository.listings(matching: filter)
let publishers = try await repository.publishers()
let viewer = await sessions?.viewer(for: request)
@@ -170,6 +197,7 @@ private extension CatalogueController {
let parameters = SearchParameters(query: request.uri.query)
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
let pickups = try await events(tradedAtBy: shop.slug)
let viewer = await sessions?.viewer(for: request)
@@ -179,6 +207,7 @@ private extension CatalogueController {
inventory: inventory,
matches: matches,
parameters: parameters,
pickups: pickups,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
@@ -64,7 +64,11 @@ extension CollectingController: RouterController {
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.post(.init("\(String.Route.series)/:slug/own"), use: toggleOwned)
routes.get(.init(.Route.compare), use: compare)
routes.get(.init(.Route.collection), use: collection)
routes.post(.init(.Route.collection), use: addBooks)
routes.get(.init(.Route.events), use: events)
return routes
}
@@ -240,12 +244,18 @@ private extension CollectingController {
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)
var owned: Set<Int>?
if let user = await sessions.user(for: request) {
owned = try await collecting.ownedIssues(of: user)[slug] ?? []
}
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SeriesPage(
series: series,
listings: listings,
issue: issue,
owned: owned,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
@@ -277,8 +287,114 @@ private extension CollectingController {
}
// MARK: - Collection and events
private extension CollectingController {
/// Handles books added to the collection from its form.
@Sendable
func addBooks(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(BooksForm.self, from: request, context: context)
try await collecting.own(issues: CollectionPage.issues(in: form.issues), ofSeries: form.series, by: user)
return .redirect(to: .Route.collection, type: .normal)
}
/// Handles a request for the collection tracker.
@Sendable
func collection(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .found)
}
let catalogue = try await collecting.allSeries()
let owned = try await collecting.ownedIssues(of: user)
let listings = try await self.catalogue.listings()
let viewer = await sessions.viewer(for: request)
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
CollectionPage(
catalogue: catalogue,
owned: owned,
listings: listings,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a request for the events.
@Sendable
func events(
request: Request,
context: Context
) async throws -> HTMLResponse {
let events = try await collecting.upcomingEvents()
let listings = try await catalogue.listings()
let viewer = await sessions.viewer(for: request)
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
EventsPage(
events: events,
listings: listings,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles the switch on an issue of a series: into the collection, or out of it.
@Sendable
func toggleOwned(
request: Request,
context: Context
) async throws -> Response {
let slug = try context.parameters.require("slug")
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.series(slug)), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(IssueForm.self, from: request, context: context)
if try await collecting.ownedIssues(of: user)[slug]?.contains(form.issue) == true {
try await collecting.disown(issue: form.issue, ofSeries: slug, by: user)
} else {
try await collecting.own(issues: [form.issue], ofSeries: slug, by: user)
}
return .redirect(to: .Route.series(slug), type: .normal)
}
}
// MARK: - Structs
/// The fields the add-books form posts.
private struct BooksForm: Decodable {
let series: String
let issues: String
}
/// The field an issue's switch posts.
private struct IssueForm: Decodable {
let issue: Int
}
/// The fields the add-a-want form posts.
private struct WantForm: Decodable {
let title: String
@@ -28,6 +28,9 @@ public struct PasskeyController<Context: RequestContext> {
/// The origin the sign-in screen is served at, which passkeys are bound to.
private let origin: String
/// The one-time recovery links a recovery ceremony answers to.
private let recoveryLinks: RecoveryLinks
/// The store sessions are started in.
private let sessions: SessionStore
@@ -40,12 +43,15 @@ public struct PasskeyController<Context: RequestContext> {
/// - accounts: the repository accounts and their passkeys are read and created through.
/// - sessions: the store sessions are started in.
/// - ceremonies: the ceremonies under way; a set of its own by default.
/// - recoveryLinks: the one-time recovery links a recovery ceremony answers to; a set of its own by default.
public init(
origin: String,
accounts: AccountRepository,
sessions: SessionStore,
ceremonies: PasskeyCeremonies = .init()
ceremonies: PasskeyCeremonies = .init(),
recoveryLinks: RecoveryLinks = .init()
) {
self.recoveryLinks = recoveryLinks
self.accounts = accounts
self.ceremonies = ceremonies
self.isSecure = origin.hasPrefix("https://")
@@ -136,6 +142,29 @@ private extension PasskeyController {
// A new account picks its display name before going on.
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
case .recovery(let token):
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))
}
)
// Used up only now: a prompt cancelled leaves the link good for another try.
guard let owner = await recoveryLinks.redeem(token) else {
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
}
try await accounts.replacePasskeys(
of: owner,
credentialID: Self.base64URL(credential.id),
publicKey: credential.publicKey,
signCount: credential.signCount
)
return try await signedIn(owner, returningTo: .Route.account)
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.")
@@ -198,6 +227,21 @@ private extension PasskeyController {
let start = try? await request.decode(as: Start.self, context: context)
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
// From a recovery link: a new passkey, replacing the account's others.
if let token = start?.recover {
guard let owner = await recoveryLinks.user(for: token) else {
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
}
let options = manager.beginRegistration(user: .init(
id: Array(UUID().uuidString.utf8),
name: owner.email,
displayName: owner.displayName
))
return try await begin(.recovery(token: token), options: options, challenge: options.challenge, mode: "create")
}
// From the account screen: another passkey for the account signed in.
if start?.add == true {
guard let owner = await sessions.user(for: request) else {
@@ -343,6 +387,7 @@ private extension PasskeyController {
private struct Start: Decodable {
let email: String?
let add: Bool?
let recover: String?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
@@ -29,6 +29,9 @@ public struct SessionController<Context: RequestContext> {
/// Whether the session cookie is limited to HTTPS.
private let isSecure: Bool
/// The one-time recovery links handed out.
private let recoveryLinks: RecoveryLinks
/// The store sessions are started and ended in.
private let sessions: SessionStore
@@ -39,6 +42,7 @@ public struct SessionController<Context: RequestContext> {
/// - sessions: the store sessions are started and ended in.
/// - accounts: the repository accounts are found and created through.
/// - catalogue: the repository the visitor's intent is described from.
/// - recoveryLinks: the one-time recovery links handed out; a set of its own by default.
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
/// - 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.
@@ -46,6 +50,7 @@ public struct SessionController<Context: RequestContext> {
sessions: SessionStore,
accounts: AccountRepository,
catalogue: CatalogueRepository,
recoveryLinks: RecoveryLinks = .init(),
isSecure: Bool = false,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -55,6 +60,7 @@ public struct SessionController<Context: RequestContext> {
self.assetVersion = assetVersion
self.catalogue = catalogue
self.isSecure = isSecure
self.recoveryLinks = recoveryLinks
self.sessions = sessions
}
@@ -81,6 +87,9 @@ extension SessionController: RouterController {
.Session.signOut,
use: signOut
)
routes.get(.init(.Route.recover), use: recover)
routes.post(.init(.Route.recover), use: sendRecoveryLink)
routes.get(.init("\(String.Route.recover)/:token"), use: followRecoveryLink)
return routes
}
@@ -112,6 +121,19 @@ private extension SessionController {
.split(separator: "/")
.map(String.init)
guard let first = segments.first else {
return nil
}
switch first {
case "messages": return "To read and send messages"
case "wants": return "To keep a want list"
case "collection": return "To track your collection"
case "sell": return "To sell on CCN"
case "account": return "To manage your account"
default: break
}
guard segments.count >= 2 else {
return nil
}
@@ -155,6 +177,79 @@ private extension SessionController {
.response(from: request, context: context)
}
/// Handles a one-time recovery link followed: the way to a new passkey while it is good, and word that it ran out otherwise.
@Sendable
func followRecoveryLink(
request: Request,
context: Context
) async throws -> Response {
let token = try context.parameters.require("token")
return recoveryPage(
await recoveryLinks.user(for: token) == nil ? .expired : .renew(token: token),
request: request,
context: context
)
}
/// Handles a request for the recovery screen.
@Sendable
func recover(
request: Request,
context: Context
) async throws -> Response {
recoveryPage(.ask(problem: nil), request: request, context: context)
}
/// Renders a step of the recovery, never stored.
func recoveryPage(
_ step: RecoverPage.Step,
request: Request,
context: Context
) -> Response {
let status: HTTPResponse.Status = switch step {
case .ask(let problem) where problem != nil: .unprocessableContent
case .expired: .gone
default: .ok
}
return HTMLResponse(
status: status,
additionalHeaders: [.cacheControl: "no-store"]
) { [analytics, assetVersion] in
RecoverPage(
step: step,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a request for a recovery link.
///
/// **Stubbed**: with no mail to send, the link is shown on the screen — to anyone who asks. It must be mailed, and the screen say the
/// same whether or not the email has an account, before anything real depends on it.
@Sendable
func sendRecoveryLink(
request: Request,
context: Context
) async throws -> Response {
let form = try await URLEncodedFormDecoder().decode(RecoveryForm.self, from: request, context: context)
guard let email = NormalizeEmail()(form.email) else {
return recoveryPage(.ask(problem: "That doesn't look like an email address."), request: request, context: context)
}
var link: String?
if let user = try await accounts.existingAccount(email: email) {
link = .Route.recover(token: await recoveryLinks.make(for: user))
}
return recoveryPage(.sent(link: link), request: request, context: context)
}
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
@Sendable
func show(
@@ -237,3 +332,8 @@ private extension RouterPath {
static let signOut: RouterPath = .init(.Route.signOut)
}
}
/// The field the recovery form posts.
private struct RecoveryForm: Decodable {
let email: String
}
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
/// The repository listings and shops are read through.
private let catalogue: CatalogueRepository
/// The repository the events a shop trades at are read and set through, or `nil` to leave them out.
private let collecting: CollectingRepository?
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void
@@ -36,6 +39,7 @@ public struct TradeController<Context: RequestContext> {
/// - trade: the repository conversations, offers, and sales are read and written through.
/// - catalogue: the repository listings and shops are read through.
/// - sessions: the store the signed-in account is read from.
/// - collecting: the repository the events a shop trades at are read and set through, or `nil` (the default) to leave them out.
/// - 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.
@@ -43,6 +47,7 @@ public struct TradeController<Context: RequestContext> {
trade: TradeRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
collecting: CollectingRepository? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
@@ -51,6 +56,7 @@ public struct TradeController<Context: RequestContext> {
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.collecting = collecting
self.sessions = sessions
self.trade = trade
}
@@ -78,6 +84,7 @@ extension TradeController: RouterController {
routes.post(.Trade.sale, use: takeStep)
routes.get(.Trade.dashboard, use: dashboard)
routes.post(.Trade.answer, use: answer)
routes.post(.init("\(String.Route.sellerDashboard)/events"), use: setEvents)
return routes
}
@@ -429,6 +436,26 @@ private extension TradeController {
)
}
/// Handles the events a seller ticked on the dashboard. A form with no box ticked posts no field at all.
@Sendable
func setEvents(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
let body = try await request.body.collect(upTo: 16 * 1_024)
let slugs = SearchParameters(query: String(buffer: body)).items
.filter { $0.name == "events" }
.map(\.value)
try await collecting?.setEvents(slugs, forShopOwnedBy: user)
return .redirect(to: .Route.sellerDashboard, type: .normal)
}
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
@Sendable
func dashboard(
@@ -446,6 +473,7 @@ private extension TradeController {
let offers = try await trade.openOffers(for: user)
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
let events = try await collecting?.upcomingEvents() ?? []
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SellerDashboardPage(
@@ -453,6 +481,7 @@ private extension TradeController {
offers: offers,
sales: sales,
listings: listings,
events: events,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
@@ -90,6 +90,8 @@ extension PasskeyCeremonies {
case addition(userID: UUID)
/// Signs in with a passkey registered earlier.
case authentication
/// Replaces the passkeys of the account a one-time recovery link names.
case recovery(token: String)
/// Creates the account of the given email, with its first passkey.
case registration(email: String)
}
@@ -0,0 +1,70 @@
import Foundation
import Persistence
/// The one-time recovery links handed out: each an unguessable token naming an account, good once, for an hour.
public actor RecoveryLinks {
// MARK: Constants
/// How long a link works: an hour.
static let lifetime: TimeInterval = 3_600
// MARK: Properties
/// The clock expiries are judged by.
private let now: @Sendable () -> Date
/// The links still good, by token.
private var links: [String: (user: User, expiresAt: Date)] = [:]
// MARK: Initializers
/// Creates an empty set of links.
/// - Parameter now: the clock expiries are judged by; the system's by default.
public init(
now: @escaping @Sendable () -> Date = { .init() }
) {
self.now = now
}
// MARK: Methods
/// Makes a link for an account, ending any it had before.
/// - Returns: the link's token.
func make(
for user: User
) -> String {
let token = "\(UUID().uuidString)\(UUID().uuidString)"
.replacingOccurrences(of: "-", with: "")
.lowercased()
links = links.filter { $0.value.user.id != user.id && $0.value.expiresAt > now() }
links[token] = (user, now().addingTimeInterval(Self.lifetime))
return token
}
/// The account a link still good names, without using the link up.
func user(
for token: String
) -> User? {
guard let link = links[token], link.expiresAt > now() else {
return nil
}
return link.user
}
/// Uses a link up.
/// - Returns: the account it named, or `nil` when it was no longer good.
func redeem(
_ token: String
) -> User? {
defer {
links[token] = nil
}
return user(for: token)
}
}