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

+7 -4
View File
@@ -52,9 +52,9 @@ LogRequestsMiddleware
→ FileMiddleware (serves Resources/Static) → FileMiddleware (serves Resources/Static)
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions) RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request) CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
SessionController (GET /sign-in, POST /sign-in, POST /sign-out) SessionController (GET /sign-in, POST /sign-in, POST /sign-out, GET/POST /recover, GET /recover/{token})
AccountController (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop) AccountController (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop)
CollectingController (GET/POST /wants…, GET /series, GET /series/{slug}, GET /compare) CollectingController (GET/POST /wants…, GET/POST /collection, GET /series…, GET /compare, GET /events)
PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON) PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON)
ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner) ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only) TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
@@ -91,7 +91,8 @@ How it holds together:
- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones. - **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
- **Adding a passkey** happens from the account screen alone: the account's own session is the proof of who is asking. - **Adding a passkey** happens from the account screen alone: the account's own session is the proof of who is asking.
- **Sign-out** is a `POST` form on the account screen, so a link on another site cannot trigger it. The not-found page always renders signed out. - **Sign-out** is a `POST` form on the account screen, so a link on another site cannot trigger it. The not-found page always renders signed out.
- **Left out**: passkey recovery. - **Recovery**: `/recover` hands out a one-time link (`RecoveryLinks`: in memory, good once, for an hour); following it runs a WebAuthn registration that replaces every passkey on the account and signs in. **Stubbed**: with no mail to send, the link is shown on the screen — so anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built.
- **States**: a cancelled passkey prompt says so, and a browser without WebAuthn — or without scripts — is told it can't use passkeys.
The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in `swift test`: `Tests/Browser/passkeys.py` drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands). The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in `swift test`: `Tests/Browser/passkeys.py` drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands).
@@ -107,7 +108,7 @@ The signatures need a WebAuthn client, so the ceremonies are checked in a browse
- **The rules live in `Persistence`**: `TradeRepository` decides who may offer, answer, or take a step; `TradeController` only maps a refusal to the sheet re-rendered with a `422`, or to a redirect that changes nothing. - **The rules live in `Persistence`**: `TradeRepository` decides who may offer, answer, or take a step; `TradeController` only maps a refusal to the sheet re-rendered with a `422`, or to a redirect that changes nothing.
- **Privacy**: a conversation or sale the account is no party to is not found, rather than forbidden. - **Privacy**: a conversation or sale the account is no party to is not found, rather than forbidden.
- **Demo accounts**: every seeded shop has an owner; sign in as `joost@kestrelrow.example` to sell for Kestrel Row Comics. - **Demo accounts**: every seeded shop has an owner; sign in as `joost@kestrelrow.example` to sell for Kestrel Row Comics.
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode. - **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode. Terms, privacy, and seller rules have no screens: the design has none to build them from.
### Accounts and collecting ### Accounts and collecting
- **First sign-in**: a new account — joined by passkey or through the demo sign-in — meets `/welcome` once, picks its display name, and goes on to what it came for. - **First sign-in**: a new account — joined by passkey or through the demo sign-in — meets `/welcome` once, picks its display name, and goes on to what it came for.
@@ -115,6 +116,8 @@ The signatures need a WebAuthn client, so the ceremonies are checked in a browse
- **Open a shop**: one form — the shop's public identity, private or business seller (a business states its KvK number, as Dutch consumer law asks), and policies — then straight on to the first listing. A new shop starts unverified and unrated. - **Open a shop**: one form — the shop's public identity, private or business seller (a business states its KvK number, as Dutch consumer law asks), and policies — then straight on to the first listing. A new shop starts unverified and unrated.
- **Want list**: a want is words a listing's title must carry, within a grade and a price, among copies shipping from the EU. Matches listed since the list was last looked at are new, and ring the header's bell. Saving a copy, and a gap in a series, both fill the want form. - **Want list**: a want is words a listing's title must carry, within a grade and a price, among copies shipping from the EU. Matches listed since the list was last looked at are new, and ring the header's bell. Saving a copy, and a gap in a series, both fill the want form.
- **Series**: `Persistence` seeds a small catalogue of runs, which is what lets a gap show: every issue of the run, the cheapest copy of each, and *alert me* where none is listed. A series sellers list but the catalogue lacks is made up from its listings, without gaps. - **Series**: `Persistence` seeds a small catalogue of runs, which is what lets a gap show: every issue of the run, the cheapest copy of each, and *alert me* where none is listed. A series sellers list but the catalogue lacks is made up from its listings, without gaps.
- **Collection**: the issues an account owns of each series, added from a form (`1-6, 9, 12`) or a switch per issue on the series screen — which then dims what is owned and counts the gaps listed right now.
- **Events**: a seeded calendar of cons and the shops trading at each. A seller picks theirs on the dashboard; a listing and its shop say where a copy can be picked up; and `pickup=` narrows a search to the shops at one event, or at any.
- **Compare**: up to three copies of an issue, ordered by what each costs delivered to the Netherlands, flagging a customs border. - **Compare**: up to three copies of an issue, ordered by what each costs delivered to the Netherlands, flagging a customs border.
Every link on the site leads somewhere: `Tests/Browser/links.py` crawls them all — signed out, as a buyer, and as a seller — against a running service. Every link on the site leads somewhere: `Tests/Browser/links.py` crawls them all — signed out, as a buyer, and as a seller — against a running service.
@@ -233,3 +233,36 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
/* The viewer's collection on a series */
.series__collection {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-width: 320px;
padding: var(--space-4);
}
.series__issue--owned {
background: var(--tint);
opacity: 0.7;
}
.series__own {
padding: 0;
border: 0;
background: none;
color: var(--muted);
font-size: 11px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.series__own:hover {
color: var(--ink);
}
.series__own[aria-pressed="true"] {
color: var(--good);
}
@@ -1937,6 +1937,58 @@ textarea.input {
color: var(--ink-soft); color: var(--ink-soft);
} }
.empty__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* A progress bar, in twentieths: inline widths are off the table under the site's Content-Security-Policy. */
.progress {
position: relative;
display: block;
height: 6px;
overflow: hidden;
border-radius: 3px;
background: var(--tint);
}
.progress::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: var(--progress, 0%);
background: var(--header);
}
.progress--5 { --progress: 5%; }
.progress--10 { --progress: 10%; }
.progress--15 { --progress: 15%; }
.progress--20 { --progress: 20%; }
.progress--25 { --progress: 25%; }
.progress--30 { --progress: 30%; }
.progress--35 { --progress: 35%; }
.progress--40 { --progress: 40%; }
.progress--45 { --progress: 45%; }
.progress--50 { --progress: 50%; }
.progress--55 { --progress: 55%; }
.progress--60 { --progress: 60%; }
.progress--65 { --progress: 65%; }
.progress--70 { --progress: 70%; }
.progress--75 { --progress: 75%; }
.progress--80 { --progress: 80%; }
.progress--85 { --progress: 85%; }
.progress--90 { --progress: 90%; }
.progress--95 { --progress: 95%; }
.progress--100 {
--progress: 100%;
}
.progress--100::after {
background: var(--good);
}
/* The gate note under gated actions */ /* The gate note under gated actions */
.gate-note { .gate-note {
display: flex; display: flex;
@@ -1066,3 +1066,79 @@ a.rows__row:hover {
padding-top: var(--space-8); padding-top: var(--space-8);
} }
} }
/* ==========================================================================
Collection and events
========================================================================== */
.tiles--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.collection__series,
.events__event {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
color: var(--ink);
text-decoration: none;
}
.collection__series:hover,
.events__event:hover {
border-color: var(--ink);
color: var(--ink);
}
.collection__text {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.collection__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
font-size: 15px;
}
.collection__complete {
color: var(--good);
font-size: 12px;
font-weight: 600;
}
.events__intro {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.events__event {
flex-direction: column;
gap: var(--space-2);
padding: 14px;
}
.events__name {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 15px;
}
.events__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.listing__sold {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
@@ -25,14 +25,23 @@ if (form && supported) {
}; };
button.hidden = false; button.hidden = false;
// The word for a browser without passkeys is on the page by default; this one has them.
for (const notice of document.querySelectorAll("[data-passkey-unsupported]")) {
notice.hidden = true;
}
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
button.disabled = true; button.disabled = true;
status.textContent = ""; status.textContent = "";
try { try {
// On the account screen the button adds a passkey to the account signed in; on the sign-in screen it signs in or joins. // The button adds a passkey on the account screen, replaces them all from a recovery link, and otherwise signs in or joins.
const adding = button.dataset.passkey === "add"; const start = button.dataset.passkey === "add"
const start = adding ? { add: true } : { email: form.querySelector("[name=email]").value }; ? { add: true }
: button.dataset.passkey === "recover"
? { recover: form.querySelector("[name=recover]").value }
: { email: form.querySelector("[name=email]").value };
const { mode, options } = await post("/sign-in/passkey/options", start); const { mode, options } = await post("/sign-in/passkey/options", start);
let credential; let credential;
@@ -55,8 +64,9 @@ if (form && supported) {
window.location.assign(location); window.location.assign(location);
} catch (error) { } catch (error) {
// Dismissing the browser's prompt is not an error worth a message. status.textContent = error.name === "NotAllowedError"
status.textContent = error.name === "NotAllowedError" ? "" : error.message; ? "That didn't go through: the passkey prompt was cancelled. Nothing was created or changed — try again."
: error.message;
button.disabled = false; button.disabled = false;
} }
}); });
@@ -220,6 +220,7 @@ private func router(
options: .autoGenerateHeadEndpoints options: .autoGenerateHeadEndpoints
) )
let collecting = CollectingRepository(fluent: fluent) let collecting = CollectingRepository(fluent: fluent)
let recoveryLinks = RecoveryLinks()
let sessions = SessionStore( let sessions = SessionStore(
trade: trade, trade: trade,
collecting: collecting collecting: collecting
@@ -268,6 +269,7 @@ private func router(
CatalogueController<AppRequestContext>( CatalogueController<AppRequestContext>(
repository: repository, repository: repository,
sessions: sessions, sessions: sessions,
collecting: collecting,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics analytics: analytics
) )
@@ -275,6 +277,7 @@ private func router(
sessions: sessions, sessions: sessions,
accounts: accounts, accounts: accounts,
catalogue: repository, catalogue: repository,
recoveryLinks: recoveryLinks,
// Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable. // Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"), isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"),
assetVersion: assetVersion, assetVersion: assetVersion,
@@ -297,7 +300,8 @@ private func router(
PasskeyController<AppRequestContext>( PasskeyController<AppRequestContext>(
origin: passkeysOrigin, origin: passkeysOrigin,
accounts: accounts, accounts: accounts,
sessions: sessions sessions: sessions,
recoveryLinks: recoveryLinks
) )
ListingFormController<AppRequestContext>( ListingFormController<AppRequestContext>(
catalogue: repository, catalogue: repository,
@@ -312,6 +316,7 @@ private func router(
trade: trade, trade: trade,
catalogue: repository, catalogue: repository,
sessions: sessions, sessions: sessions,
collecting: collecting,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics, analytics: analytics,
onCatalogueChange: refreshHome onCatalogueChange: refreshHome
@@ -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), ("Open a shop", .Route.openShop),
("Seller dashboard", .Route.sellerDashboard), ("Seller dashboard", .Route.sellerDashboard),
("Grading guide", .Route.gradingGuide), ("Grading guide", .Route.gradingGuide),
("Trade at events", .Route.events),
] ]
), ),
( (
@@ -84,8 +85,10 @@ private extension SiteFooter {
links: [ links: [
("Sign in or join", .Route.signIn), ("Sign in or join", .Route.signIn),
("Want list & alerts", .Route.wants), ("Want list & alerts", .Route.wants),
("My collection", .Route.collection),
("Messages", .Route.messages), ("Messages", .Route.messages),
("Passkeys & settings", .Route.account), ("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 { var navigation: some HTML {
nav( nav(
.class("site-nav"), .class("site-nav"),
@@ -134,6 +134,12 @@ private extension SiteHeader {
) { ) {
"Series" "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. /// 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. /// Read on Amsterdam's clock: the marketplace is NL-first.
@@ -7,8 +7,12 @@ extension String {
enum Route { enum Route {
/// The account screen. /// The account screen.
static let account = "/account" static let account = "/account"
/// The collection tracker.
static let collection = "/collection"
/// The side-by-side comparison of copies. /// The side-by-side comparison of copies.
static let compare = "/compare" static let compare = "/compare"
/// The events where shops trade in person.
static let events = "/events"
/// The public grading guide. /// The public grading guide.
static let gradingGuide = "/grading-guide" static let gradingGuide = "/grading-guide"
/// The browse / home screen. /// The browse / home screen.
@@ -17,6 +21,8 @@ extension String {
static let messages = "/messages" static let messages = "/messages"
/// The open-a-shop onboarding. /// The open-a-shop onboarding.
static let openShop = "/sell/open-a-shop" static let openShop = "/sell/open-a-shop"
/// The passkey recovery screen.
static let recover = "/recover"
/// The search results screen. /// The search results screen.
static let search = "/search" static let search = "/search"
/// The seller dashboard. /// The seller dashboard.
@@ -101,6 +107,21 @@ extension String {
"\(listing(slug))/offer" "\(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. /// The path of a sale record.
/// - Parameter number: the record's number. /// - Parameter number: the record's number.
static func sale( static func sale(
@@ -88,6 +88,9 @@ extension AccountPage: Page {
a(.href(.Route.wants)) { a(.href(.Route.wants)) {
"Want list" "Want list"
} }
a(.href(.Route.collection)) {
"Collection"
}
a(.href(.Route.messages)) { a(.href(.Route.messages)) {
"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. /// The listing shown.
let listing: Listing 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. /// Other listings from the same shop.
let related: [Listing] let related: [Listing]
@@ -27,12 +33,16 @@ struct ListingPage {
/// - Parameters: /// - Parameters:
/// - listing: the listing shown. /// - listing: the listing shown.
/// - related: other listings from the same shop. /// - 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. /// - 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. /// - 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. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init( init(
listing: Listing, listing: Listing,
related: [Listing], related: [Listing],
pickups: [Event] = [],
otherCopies: (inShop: Int, everywhere: Int) = (0, 0),
viewer: Viewer? = nil, viewer: Viewer? = nil,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil analytics: Analytics? = nil
@@ -42,6 +52,8 @@ struct ListingPage {
self.assetVersion = assetVersion self.assetVersion = assetVersion
self.listing = listing self.listing = listing
self.locale = .init(identifier: "en") self.locale = .init(identifier: "en")
self.otherCopies = otherCopies
self.pickups = pickups
self.related = related self.related = related
} }
@@ -228,6 +240,15 @@ private extension ListingPage {
span(.class("type-body-sm buy-box__terms")) { span(.class("type-body-sm buy-box__terms")) {
"+ \(listing.shippingCents.euros) tracked shipping to NL" "+ \(listing.shippingCents.euros) tracked shipping to NL"
listing.acceptsOffers ? " · accepts offers" : "" 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( a(
@@ -239,8 +260,28 @@ private extension ListingPage {
} }
} }
if !listing.isAvailable { if !listing.isAvailable {
p(.class("notice")) { div(.class("notice listing__sold")) {
"This copy is sold. A sale was agreed on CCN." 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 { } else if viewer?.shopSlug == listing.shop.slug {
p(.class("notice")) { p(.class("notice")) {
@@ -332,6 +332,25 @@ private extension MessagesPage {
"“\(note)”" "“\(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 { if offer.status == .open, conversation.side == .selling {
AnswerOffer( AnswerOffer(
offer: offer, 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 { if listings.isEmpty {
div(.class("empty")) { div(.class("empty")) {
h2(.class("type-panel")) { h2(.class("type-panel")) {
"No listings match" parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match"
} }
p(.class("type-body-sm")) { 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( div(.class("empty__actions")) {
.class("button button--outline"), if let query = parameters.value(.query) {
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path)) a(
) { .class("button button--primary"),
"Clear all filters" .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 { } else {
@@ -268,6 +279,7 @@ private extension SearchPage {
span(.class("type-small")) { span(.class("type-small")) {
"Outside the EU, import VAT may apply." "Outside the EU, import VAT may apply."
} }
checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event")
} }
group("Key issues") { group("Key issues") {
checkbox(.keys, value: "1", label: "Key issues only") checkbox(.keys, value: "1", label: "Key issues only")
@@ -12,6 +12,9 @@ struct SellerDashboardPage {
let assetVersion: String? let assetVersion: String?
let locale: Locale 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. /// The shop's listings still for sale, newest first.
let listings: [Listing] let listings: [Listing]
@@ -38,6 +41,7 @@ struct SellerDashboardPage {
/// - offers: the offers waiting for the seller's answer, the soonest to expire first. /// - offers: the offers waiting for the seller's answer, the soonest to expire first.
/// - sales: the shop's sales, the latest agreed first. /// - sales: the shop's sales, the latest agreed first.
/// - listings: the shop's listings still for sale, newest first. /// - listings: the shop's listings still for sale, newest first.
/// - events: the upcoming events; none by default.
/// - viewer: the signed-in viewer. /// - viewer: the signed-in viewer.
/// - now: the moment stamps are read relative to; the present by default. /// - 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. /// - 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], offers: [Offer],
sales: [Sale], sales: [Sale],
listings: [Listing], listings: [Listing],
events: [Event] = [],
viewer: Viewer? = nil, viewer: Viewer? = nil,
now: Date = .init(), now: Date = .init(),
assetVersion: String? = nil, assetVersion: String? = nil,
@@ -54,6 +59,7 @@ struct SellerDashboardPage {
) { ) {
self.analytics = analytics self.analytics = analytics
self.assetVersion = assetVersion self.assetVersion = assetVersion
self.events = events.map { event in (event, event.shops.contains { $0.slug == shop.slug }) }
self.listings = listings self.listings = listings
self.locale = .init(identifier: "en") self.locale = .init(identifier: "en")
self.now = now 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. /// Every listing of the series still for sale.
let listings: [Listing] let listings: [Listing]
/// The issues the viewer owns of the series, or `nil` signed out.
let owned: Set<Int>?
/// The series shown. /// The series shown.
let series: Series let series: Series
@@ -31,6 +34,7 @@ struct SeriesPage {
/// - series: the series shown. /// - series: the series shown.
/// - listings: every listing of the series still for sale. /// - listings: every listing of the series still for sale.
/// - issue: the issue whose copies are listed, or `nil` (the default) for none. /// - 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. /// - 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. /// - 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. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
@@ -38,6 +42,7 @@ struct SeriesPage {
series: Series, series: Series,
listings: [Listing], listings: [Listing],
issue: Int? = nil, issue: Int? = nil,
owned: Set<Int>? = nil,
viewer: Viewer? = nil, viewer: Viewer? = nil,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil analytics: Analytics? = nil
@@ -47,6 +52,7 @@ struct SeriesPage {
self.issue = issue self.issue = issue
self.listings = listings self.listings = listings
self.locale = .init(identifier: "en") self.locale = .init(identifier: "en")
self.owned = owned
self.series = series self.series = series
self.viewer = viewer self.viewer = viewer
} }
@@ -104,6 +110,19 @@ extension SeriesPage: Page {
p(.class("type-lead")) { p(.class("type-lead")) {
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now." "\(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")) { ul(.class("series__keys")) {
ForEach(keys) { key in ForEach(keys) { key in
li(.class("chip chip--warn")) { li(.class("chip chip--warn")) {
@@ -125,7 +144,7 @@ extension SeriesPage: Page {
} }
ol(.class("series__issues")) { ol(.class("series__issues")) {
ForEach(issues) { entry in ForEach(issues) { entry in
li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) { li(.class(issueClass(entry))) {
span(.class("series__number")) { span(.class("series__number")) {
"#\(entry.number)" "#\(entry.number)"
} }
@@ -143,6 +162,25 @@ extension SeriesPage: Page {
"None listed · alert me" "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 { 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. /// The series' manga, trade paperbacks, and hardcovers.
var collected: [Listing] { var collected: [Listing] {
listings.filter { $0.issueNumber == nil } listings.filter { $0.issueNumber == nil }
@@ -24,6 +24,9 @@ struct ShopPage {
/// The tab, quick filters, and sort order applied. /// The tab, quick filters, and sort order applied.
let parameters: SearchParameters let parameters: SearchParameters
/// The upcoming events the shop trades at.
let pickups: [Event]
/// The shop shown. /// The shop shown.
let shop: Shop let shop: Shop
@@ -38,6 +41,7 @@ struct ShopPage {
/// - inventory: every listing of the shop, counted per format on the tabs. /// - 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. /// - 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. /// - 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. /// - 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. /// - 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. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
@@ -46,6 +50,7 @@ struct ShopPage {
inventory: [Listing], inventory: [Listing],
matches: [Listing], matches: [Listing],
parameters: SearchParameters, parameters: SearchParameters,
pickups: [Event] = [],
viewer: Viewer? = nil, viewer: Viewer? = nil,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil analytics: Analytics? = nil
@@ -64,6 +69,7 @@ struct ShopPage {
self.locale = .init(identifier: "en") self.locale = .init(identifier: "en")
self.pages = (slice.current, slice.total) self.pages = (slice.current, slice.total)
self.parameters = parameters self.parameters = parameters
self.pickups = pickups
self.shop = shop self.shop = shop
} }
@@ -104,6 +110,12 @@ extension ShopPage: Page {
} }
p(.class("type-body-sm shop__standing")) { p(.class("type-body-sm shop__standing")) {
"\(shop.standing) · \(shop.shipsNote)" "\(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")) { p(.class("shop__about")) {
shop.about shop.about
@@ -197,10 +209,12 @@ extension ShopPage: Page {
if listings.isEmpty { if listings.isEmpty {
div(.class("empty")) { div(.class("empty")) {
h2(.class("type-panel")) { h2(.class("type-panel")) {
"Nothing here right now" counts.isEmpty ? "\(shop.name) is restocking" : "Nothing here right now"
} }
p(.class("type-body-sm")) { 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( a(
.class("button button--outline"), .class("button button--outline"),
@@ -110,6 +110,7 @@ extension SignInPage: Page {
.custom(name: "role", value: "alert"), .custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "") .custom(name: "data-passkey-status", value: "")
) {} ) {}
PasskeyUnsupported()
div(.class("field")) { div(.class("field")) {
label( label(
.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 { if wants.isEmpty, draft == nil {
div(.class("empty")) { div(.class("empty")) {
h2(.class("type-panel")) { h2(.class("type-panel")) {
"Nothing on your list yet" "No wants yet"
} }
p(.class("type-body-sm")) { 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 // 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. /// The number of listings on a page.
static let pageSize = 12 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 { if filter.keysOnly {
pills.append(.init(label: "Key issues", removed: removing(.keys))) pills.append(.init(label: "Key issues", removed: removing(.keys)))
} }
@@ -278,6 +285,7 @@ extension SearchParameters {
case keys case keys
case offers case offers
case page case page
case pickup
case priceMax = "price-max" case priceMax = "price-max"
case priceMin = "price-min" case priceMin = "price-min"
case publisher 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. /// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
private let assetVersion: String? 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. /// The repository the shops and listings are read through.
private let repository: CatalogueRepository private let repository: CatalogueRepository
@@ -30,16 +33,19 @@ public struct CatalogueController<Context: RequestContext> {
/// - Parameters: /// - Parameters:
/// - repository: the repository the shops and listings are read through. /// - 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. /// - 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. /// - 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. /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init( public init(
repository: CatalogueRepository, repository: CatalogueRepository,
sessions: SessionStore? = nil, sessions: SessionStore? = nil,
collecting: CollectingRepository? = nil,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil analytics: Analytics? = nil
) { ) {
self.analytics = analytics self.analytics = analytics
self.assetVersion = assetVersion self.assetVersion = assetVersion
self.collecting = collecting
self.repository = repository self.repository = repository
self.sessions = sessions self.sessions = sessions
} }
@@ -88,6 +94,13 @@ private extension CatalogueController {
// MARK: Methods // 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. /// Handles a request for the grading guide.
@Sendable @Sendable
func gradingGuide( func gradingGuide(
@@ -115,10 +128,13 @@ private extension CatalogueController {
throw HTTPError(.notFound) throw HTTPError(.notFound)
} }
let related = try await repository let inShop = try await repository
.listings(matching: .init(shop: listing.shop.slug)) .listings(matching: .init(shop: listing.shop.slug))
.filter { $0.slug != listing.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) let viewer = await sessions?.viewer(for: request)
@@ -126,6 +142,8 @@ private extension CatalogueController {
ListingPage( ListingPage(
listing: listing, listing: listing,
related: Array(related), related: Array(related),
pickups: pickups,
otherCopies: (inShop.count { $0.title == listing.title }, sameTitle.count),
viewer: viewer, viewer: viewer,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics analytics: analytics
@@ -140,7 +158,16 @@ private extension CatalogueController {
context: Context context: Context
) async throws -> HTMLResponse { ) async throws -> HTMLResponse {
let parameters = SearchParameters(query: request.uri.query) 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 publishers = try await repository.publishers()
let viewer = await sessions?.viewer(for: request) let viewer = await sessions?.viewer(for: request)
@@ -170,6 +197,7 @@ private extension CatalogueController {
let parameters = SearchParameters(query: request.uri.query) let parameters = SearchParameters(query: request.uri.query)
let inventory = try await repository.listings(matching: .init(shop: shop.slug)) let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: 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) let viewer = await sessions?.viewer(for: request)
@@ -179,6 +207,7 @@ private extension CatalogueController {
inventory: inventory, inventory: inventory,
matches: matches, matches: matches,
parameters: parameters, parameters: parameters,
pickups: pickups,
viewer: viewer, viewer: viewer,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics analytics: analytics
@@ -64,7 +64,11 @@ extension CollectingController: RouterController {
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant) routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
routes.get(.init(.Route.series), use: seriesIndex) routes.get(.init(.Route.series), use: seriesIndex)
routes.get(.init("\(String.Route.series)/:slug"), use: series) 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.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 return routes
} }
@@ -240,12 +244,18 @@ private extension CollectingController {
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug } let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
let issue = request.uri.queryParameters.get("issue", as: Int.self) let issue = request.uri.queryParameters.get("issue", as: Int.self)
let viewer = await sessions.viewer(for: request) 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 return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SeriesPage( SeriesPage(
series: series, series: series,
listings: listings, listings: listings,
issue: issue, issue: issue,
owned: owned,
viewer: viewer, viewer: viewer,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics 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 // 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. /// The fields the add-a-want form posts.
private struct WantForm: Decodable { private struct WantForm: Decodable {
let title: String 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. /// The origin the sign-in screen is served at, which passkeys are bound to.
private let origin: String private let origin: String
/// The one-time recovery links a recovery ceremony answers to.
private let recoveryLinks: RecoveryLinks
/// The store sessions are started in. /// The store sessions are started in.
private let sessions: SessionStore 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. /// - accounts: the repository accounts and their passkeys are read and created through.
/// - sessions: the store sessions are started in. /// - sessions: the store sessions are started in.
/// - ceremonies: the ceremonies under way; a set of its own by default. /// - 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( public init(
origin: String, origin: String,
accounts: AccountRepository, accounts: AccountRepository,
sessions: SessionStore, sessions: SessionStore,
ceremonies: PasskeyCeremonies = .init() ceremonies: PasskeyCeremonies = .init(),
recoveryLinks: RecoveryLinks = .init()
) { ) {
self.recoveryLinks = recoveryLinks
self.accounts = accounts self.accounts = accounts
self.ceremonies = ceremonies self.ceremonies = ceremonies
self.isSecure = origin.hasPrefix("https://") self.isSecure = origin.hasPrefix("https://")
@@ -136,6 +142,29 @@ private extension PasskeyController {
// A new account picks its display name before going on. // A new account picks its display name before going on.
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return))) 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): case .addition(let userID):
guard let owner = await sessions.user(for: request), owner.id == userID else { guard let owner = await sessions.user(for: request), owner.id == userID else {
return Self.refusal(.unauthorized, "Sign in again to add a passkey.") 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 start = try? await request.decode(as: Start.self, context: context)
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? "" 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. // From the account screen: another passkey for the account signed in.
if start?.add == true { if start?.add == true {
guard let owner = await sessions.user(for: request) else { guard let owner = await sessions.user(for: request) else {
@@ -343,6 +387,7 @@ private extension PasskeyController {
private struct Start: Decodable { private struct Start: Decodable {
let email: String? let email: String?
let add: Bool? let add: Bool?
let recover: String?
} }
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards. /// 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. /// Whether the session cookie is limited to HTTPS.
private let isSecure: Bool private let isSecure: Bool
/// The one-time recovery links handed out.
private let recoveryLinks: RecoveryLinks
/// The store sessions are started and ended in. /// The store sessions are started and ended in.
private let sessions: SessionStore private let sessions: SessionStore
@@ -39,6 +42,7 @@ public struct SessionController<Context: RequestContext> {
/// - sessions: the store sessions are started and ended in. /// - sessions: the store sessions are started and ended in.
/// - accounts: the repository accounts are found and created through. /// - accounts: the repository accounts are found and created through.
/// - catalogue: the repository the visitor's intent is described from. /// - 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. /// - 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. /// - 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. /// - 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, sessions: SessionStore,
accounts: AccountRepository, accounts: AccountRepository,
catalogue: CatalogueRepository, catalogue: CatalogueRepository,
recoveryLinks: RecoveryLinks = .init(),
isSecure: Bool = false, isSecure: Bool = false,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil analytics: Analytics? = nil
@@ -55,6 +60,7 @@ public struct SessionController<Context: RequestContext> {
self.assetVersion = assetVersion self.assetVersion = assetVersion
self.catalogue = catalogue self.catalogue = catalogue
self.isSecure = isSecure self.isSecure = isSecure
self.recoveryLinks = recoveryLinks
self.sessions = sessions self.sessions = sessions
} }
@@ -81,6 +87,9 @@ extension SessionController: RouterController {
.Session.signOut, .Session.signOut,
use: 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 return routes
} }
@@ -112,6 +121,19 @@ private extension SessionController {
.split(separator: "/") .split(separator: "/")
.map(String.init) .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 { guard segments.count >= 2 else {
return nil return nil
} }
@@ -155,6 +177,79 @@ private extension SessionController {
.response(from: request, context: context) .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. /// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
@Sendable @Sendable
func show( func show(
@@ -237,3 +332,8 @@ private extension RouterPath {
static let signOut: RouterPath = .init(.Route.signOut) 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. /// The repository listings and shops are read through.
private let catalogue: CatalogueRepository 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. /// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void 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. /// - trade: the repository conversations, offers, and sales are read and written through.
/// - catalogue: the repository listings and shops are read through. /// - catalogue: the repository listings and shops are read through.
/// - sessions: the store the signed-in account is read from. /// - 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. /// - 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. /// - 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. /// - 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, trade: TradeRepository,
catalogue: CatalogueRepository, catalogue: CatalogueRepository,
sessions: SessionStore, sessions: SessionStore,
collecting: CollectingRepository? = nil,
assetVersion: String? = nil, assetVersion: String? = nil,
analytics: Analytics? = nil, analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {} onCatalogueChange: @escaping @Sendable () async -> Void = {}
@@ -51,6 +56,7 @@ public struct TradeController<Context: RequestContext> {
self.onCatalogueChange = onCatalogueChange self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion self.assetVersion = assetVersion
self.catalogue = catalogue self.catalogue = catalogue
self.collecting = collecting
self.sessions = sessions self.sessions = sessions
self.trade = trade self.trade = trade
} }
@@ -78,6 +84,7 @@ extension TradeController: RouterController {
routes.post(.Trade.sale, use: takeStep) routes.post(.Trade.sale, use: takeStep)
routes.get(.Trade.dashboard, use: dashboard) routes.get(.Trade.dashboard, use: dashboard)
routes.post(.Trade.answer, use: answer) routes.post(.Trade.answer, use: answer)
routes.post(.init("\(String.Route.sellerDashboard)/events"), use: setEvents)
return routes 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. /// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
@Sendable @Sendable
func dashboard( func dashboard(
@@ -446,6 +473,7 @@ private extension TradeController {
let offers = try await trade.openOffers(for: user) let offers = try await trade.openOffers(for: user)
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id } 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 listings = try await catalogue.listings(matching: .init(shop: shop.slug))
let events = try await collecting?.upcomingEvents() ?? []
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SellerDashboardPage( SellerDashboardPage(
@@ -453,6 +481,7 @@ private extension TradeController {
offers: offers, offers: offers,
sales: sales, sales: sales,
listings: listings, listings: listings,
events: events,
viewer: viewer, viewer: viewer,
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics analytics: analytics
@@ -90,6 +90,8 @@ extension PasskeyCeremonies {
case addition(userID: UUID) case addition(userID: UUID)
/// Signs in with a passkey registered earlier. /// Signs in with a passkey registered earlier.
case authentication 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. /// Creates the account of the given email, with its first passkey.
case registration(email: String) 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)
}
}
+15 -1
View File
@@ -82,7 +82,21 @@ chrome.send("Page.navigate", url=f"{BASE}/")
chrome.wait_for("document.readyState === 'complete'") chrome.wait_for("document.readyState === 'complete'")
anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)") anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)")
check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous) check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous)
held_before = 0
# 3c. Every passkey lost, a one-time link sets up a new one — which replaces the others, and signs in.
link = chrome.evaluate("""fetch('/recover', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'email=sam.okafor%40example.com'})
.then(r => r.text()).then(html => (html.match(/\\/recover\\/[0-9a-f]+/) || [null])[0])""")
check("recover: a known email gets a one-time link", bool(link), link)
chrome.send("Page.navigate", url=f"{BASE}{link}")
assert chrome.wait_for("document.querySelector('[data-passkey=recover]') && !document.querySelector('[data-passkey=recover]').hidden")
chrome.evaluate("document.querySelector('[data-passkey=recover]').click(); true")
recovered = chrome.wait_for("location.pathname === '/account'")
check("recover: the new passkey signs in, on the account screen", recovered, status() if not recovered else "")
check("recover: it replaced every other passkey", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
chrome.send("Page.navigate", url=f"{BASE}{link}")
check("recover: the link works once", chrome.wait_for("document.body.textContent.includes('That link has run out')"))
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
held_before = len(credentials())
# 4. A demo account has no passkey, and none can be added to it. # 4. A demo account has no passkey, and none can be added to it.
open_sign_in("/") open_sign_in("/")
@@ -19,7 +19,7 @@ struct SiteFooterTests {
#expect(html.contains(">\(title)</h2>")) #expect(html.contains(">\(title)</h2>"))
} }
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] { for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.events, .Route.signIn, .Route.wants, .Route.collection, .Route.account, .Route.recover] {
#expect(html.contains(#"href="\#(path)""#)) #expect(html.contains(#"href="\#(path)""#))
} }
} }
@@ -81,7 +81,8 @@ struct CataloguePagesTests {
publishers: [] publishers: []
).render() ).render()
#expect(html.contains("No listings match")) #expect(html.contains("Nothing for “nothing”"))
#expect(html.contains(#"href="/wants/new?title=nothing""#))
#expect(html.contains(#"href="/search?ships-from=anywhere""#)) #expect(html.contains(#"href="/search?ships-from=anywhere""#))
} }
@@ -96,7 +96,7 @@ struct CollectorPagesTests {
#expect(html.contains(#"name="title" value="Nightward #2""#)) #expect(html.contains(#"name="title" value="Nightward #2""#))
#expect(html.contains(#"<option value="9.6" selected>"#)) #expect(html.contains(#"<option value="9.6" selected>"#))
#expect(html.contains(#"name="max-price" value="85""#)) #expect(html.contains(#"name="max-price" value="85""#))
#expect(!html.contains("Nothing on your list yet")) #expect(!html.contains("No wants yet"))
} }
// MARK: SeriesPage tests // MARK: SeriesPage tests
@@ -0,0 +1,185 @@
import Elementary
import Foundation
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Collection, events, and recovery pages",
.tags(.page)
)
struct CommunityPagesTests {
// MARK: Constants
private static let nightward = Series(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 4, blurb: "")
private static let con = Event(
slug: "dutch-comic-con", name: "Dutch Comic Con", venue: "Jaarbeurs, Utrecht",
startsOn: .stub.addingTimeInterval(3 * 86_400), endsOn: .stub.addingTimeInterval(4 * 86_400), shops: [.stub()]
)
// MARK: CollectionPage tests
@Test
func `collection counts what is owned, missing, and listed among the gaps`() {
let html = CollectionPage(
catalogue: [Self.nightward],
owned: ["nightward": [2, 3]],
// #1 is a gap that is listed; #4 a gap that is not.
listings: [.stub()]
).render()
#expect(html.contains("2 books · 1 series"))
#expect(html.contains(">2/4<"))
#expect(html.contains("progress--50"))
#expect(html.contains("2 missing · 1 listed now"))
#expect(html.contains(#"href="/series/nightward""#))
#expect(html.contains(#"action="/collection" method="post""#))
#expect(html.contains(#"<option value="nightward">Nightward · 4 issues"#))
}
@Test
func `collection marks a finished run, and says so when there is nothing yet`() {
let complete = CollectionPage(catalogue: [Self.nightward], owned: ["nightward": [1, 2, 3, 4]], listings: []).render()
let empty = CollectionPage(catalogue: [Self.nightward], owned: [:], listings: []).render()
#expect(complete.contains("progress--100"))
#expect(complete.contains(">Complete<"))
#expect(empty.contains("Your collection is empty"))
}
@Test(arguments: zip(
["1-6, 9, 12", "3", "5-3, x, 0, 1000", " 2 ; 2 - 4 ", "1-9999"],
[[1, 2, 3, 4, 5, 6, 9, 12], [3], [], [2, 2, 4], []]
))
func `reads the issue numbers a collector typed`(
_ text: String,
expects issues: [Int]
) {
#expect(CollectionPage.issues(in: text) == issues)
}
// MARK: SeriesPage tests
@Test
func `series shows the viewer's collection, a switch per issue, and dims what is owned`() {
let html = SeriesPage(series: Self.nightward, listings: [.stub()], owned: [1, 3], viewer: .init(displayName: "Mara")).render()
let signedOut = SeriesPage(series: Self.nightward, listings: [.stub()]).render()
#expect(html.contains("2 of 4 owned"))
#expect(html.contains("2 missing · 0 of them listed now"))
#expect(html.components(separatedBy: "series__issue--owned").count - 1 == 2)
#expect(html.contains(#"action="/series/nightward/own" method="post""#))
#expect(html.contains(#"aria-pressed="true">Owned ✓"#))
#expect(html.contains(#"aria-pressed="false">I own this"#))
#expect(!signedOut.contains("Your collection"))
#expect(!signedOut.contains("series__own"))
}
// MARK: EventsPage tests
@Test
func `events count the shops trading and the copies to pick up, and flag this weekend`() {
let later = Event(
slug: "comic-con-amsterdam", name: "Comic Con Amsterdam", venue: "RAI, Amsterdam",
startsOn: .stub.addingTimeInterval(59 * 86_400), endsOn: .stub.addingTimeInterval(60 * 86_400)
)
let html = EventsPage(
events: [Self.con, later],
listings: [.stub(), .stub(slug: "b"), .stub(slug: "c", shop: .stub(slug: "elsewhere", name: "Elsewhere"))],
now: .stub
).render()
#expect(html.contains("Sat 26 – Sun 27 Sep"))
#expect(html.contains("Sat 21 – Sun 22 Nov"))
#expect(html.components(separatedBy: "This weekend").count - 1 == 1)
#expect(html.contains("1 shop trading"))
#expect(html.contains("2 listings for pickup"))
#expect(html.contains("0 shops trading"))
#expect(html.contains(#"href="/search?pickup=dutch-comic-con&amp;ships-from=anywhere""#))
}
// MARK: Pickup and state tests
@Test
func `listing and shop say where the copy can be picked up`() {
let listing = ListingPage(listing: .stub(), related: [], pickups: [Self.con]).render()
let shop = ShopPage(shop: .stub(), inventory: [.stub()], matches: [.stub()], parameters: .init(), pickups: [Self.con]).render()
let restocking = ShopPage(shop: .stub(), inventory: [], matches: [], parameters: .init(), pickups: [Self.con]).render()
#expect(listing.contains("or pick up at Dutch Comic Con, Sat 26 – Sun 27 Sep"))
#expect(shop.contains("trading at "))
#expect(restocking.contains("Kestrel Row Comics is restocking"))
#expect(restocking.contains("the shop is trading at Dutch Comic Con"))
}
@Test
func `search offers pickup as a filter, and a pill to drop it`() {
let html = SearchPage(parameters: .init(query: "pickup=dutch-comic-con"), matches: [.stub()], publishers: []).render()
#expect(html.contains(#"name="pickup" value="dutch-comic-con" checked"#))
#expect(html.contains(#"aria-label="Remove filter: Event pickup""#))
#expect(SearchPage(parameters: .init(), matches: [], publishers: []).render().contains(#"name="pickup" value="any""#))
}
@Test
func `messages offer the ways on from an expired offer, to the buyer alone`() {
let entries = [Conversation.Entry(kind: .offer, sender: .mara, body: "", offer: .stub(status: .expired), createdAt: .stub)]
let buying = MessagesPage(conversations: [], open: .stub(entries: entries), now: .stub).render()
let selling = MessagesPage(conversations: [], open: .stub(side: .selling, entries: entries), now: .stub).render()
#expect(buying.contains("didn't answer within 48 hours") || buying.contains("didn&#39;t answer within 48 hours"))
#expect(buying.contains(">Offer again<"))
#expect(buying.contains(#"href="/listing/nightward-1/buy">Buy at €240"#))
#expect(!selling.contains("Offer again"))
}
@Test
func `seller dashboard ticks the events the shop trades at`() {
let other = Event(slug: "other", name: "Other Con", venue: "", startsOn: .stub, endsOn: .stub)
let html = SellerDashboardPage(shop: .stub(), offers: [], sales: [], listings: [], events: [Self.con, other], now: .stub).render()
#expect(html.contains(#"action="/sell/events" method="post""#))
#expect(html.contains(#"name="events" value="dutch-comic-con" checked"#))
#expect(html.contains(#"name="events" value="other">"#))
}
// MARK: RecoverPage tests
@Test
func `recovery walks from the email, to the link, to a new passkey`() {
let ask = RecoverPage(step: .ask(problem: "That doesn't look like an email address.")).render()
let sent = RecoverPage(step: .sent(link: "/recover/abc")).render()
let unknown = RecoverPage(step: .sent(link: nil)).render()
let renew = RecoverPage(step: .renew(token: "abc")).render()
let expired = RecoverPage(step: .expired).render()
#expect(ask.contains(#"action="/recover" method="post""#))
#expect(ask.contains(#"id="email-error""#))
#expect(sent.contains(#"href="/recover/abc""#))
#expect(sent.contains("Prototype: CCN sends no mail yet"))
#expect(unknown.contains("No account has that email"))
#expect(renew.contains(#"name="recover" value="abc""#))
#expect(renew.contains(#"hidden data-passkey="recover""#))
#expect(renew.contains("data-passkey-unsupported"))
#expect(renew.contains("/js/passkey.js"))
#expect(expired.contains("That link has run out"))
for html in [ask, sent, renew, expired] {
#expect(html.contains(#"content="noindex""#))
#expect(!html.contains("site-header"))
#expect(!html.contains("style="))
}
}
@Test
func `sign-in tells a browser without passkeys so, and leads to recovery`() {
let html = SignInPage(returnPath: "/").render()
#expect(html.contains("data-passkey-unsupported"))
#expect(html.contains(#"href="/recover">Lost access to your passkeys?"#))
}
}
@@ -228,7 +228,9 @@ struct TradePagesTests {
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics") viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
).render() ).render()
#expect(sold.contains("This copy is sold.")) #expect(sold.contains("This copy sold on 23 Sep."))
#expect(sold.contains("Kestrel Row Comics has 0 more copies of Nightward #1, and 0 are listed across CCN."))
#expect(sold.contains(#"href="/wants/new?title=Nightward%20%231""#))
#expect(!sold.contains("Buy now")) #expect(!sold.contains("Buy now"))
#expect(own.contains("This is your listing.")) #expect(own.contains("This is your listing."))
#expect(!own.contains("Make an offer")) #expect(!own.contains("Make an offer"))
@@ -140,7 +140,7 @@ struct AccountAndCollectingControllerTests {
_ = try await post(client, "/wants/\(id)/remove", "", as: mara) _ = try await post(client, "/wants/\(id)/remove", "", as: mara)
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
#expect(String(buffer: response.body).contains("Nothing on your list yet")) #expect(String(buffer: response.body).contains("No wants yet"))
} }
} }
} }
@@ -266,7 +266,7 @@ private extension AccountAndCollectingControllerTests {
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting) let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
let router = Router(context: WebsiteRequestContext.self) let router = Router(context: WebsiteRequestContext.self)
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes) router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions, collecting: collecting).routes)
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes) router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes)
router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes) router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes)
router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, catalogue: catalogue, sessions: sessions).routes) router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, catalogue: catalogue, sessions: sessions).routes)
@@ -282,3 +282,100 @@ private extension AccountAndCollectingControllerTests {
} }
} }
// MARK: - Collection, events, and recovery
extension AccountAndCollectingControllerTests {
@Test
func `keeps a collection through its form and the switches on a series`() async throws {
try await withApp { client in
let mara = try await signIn(client)
_ = try await post(client, "/collection", "series=echo-country&issues=1-3%2C+9", as: mara)
_ = try await post(client, "/series/echo-country/own", "issue=5", as: mara)
_ = try await post(client, "/series/echo-country/own", "issue=1", as: mara)
try await client.execute(uri: "/collection", method: .get, headers: [.cookie: mara]) { response in
let body = String(buffer: response.body)
// #2, #3 and #5: #9 is outside the six-issue run, and #1 was switched back off.
#expect(body.contains(">3/6<"))
#expect(body.contains("3 missing · 1 listed now"))
}
try await client.execute(uri: "/series/echo-country", method: .get, headers: [.cookie: mara]) { response in
#expect(String(buffer: response.body).contains("3 of 6 owned"))
}
}
}
@Test
func `serves the events, and narrows a search to the copies to pick up at one`() async throws {
try await withApp { client in
try await client.execute(uri: "/events", method: .get) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(body.contains("Dutch Comic Con"))
#expect(body.contains("Comic Con Brussels"))
}
// Silverfish alone trades at Stripdagen.
try await client.execute(uri: "/search?pickup=stripdagen-haarlem&ships-from=anywhere", method: .get) { response in
let body = String(buffer: response.body)
#expect(body.contains("4 listings"))
#expect(body.contains("from 1 shop"))
}
try await client.execute(uri: "/search?pickup=no-such-event", method: .get) { response in
#expect(String(buffer: response.body).contains("No listings match"))
}
try await client.execute(uri: "/listing/nightward-1-cgc-9-8", method: .get) { response in
#expect(String(buffer: response.body).contains("or pick up at Dutch Comic Con"))
}
}
}
@Test
func `hands a recovery link to an account's email alone, good until it runs out`() async throws {
try await withApp { client in
let link = try await client.execute(
uri: "/recover",
method: .post,
headers: Self.form,
body: ByteBuffer(string: "email=Mara.V%40example.com")
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.cacheControl] == "no-store")
return String(try #require(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/)).output)
}
try await client.execute(uri: link, method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body).contains("Set up a new passkey"))
}
try await client.execute(uri: "/recover/0000", method: .get) { response in
#expect(response.status == .gone)
}
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nobody%40example.com")) { response in
let body = String(buffer: response.body)
#expect(body.contains("No account has that email"))
#expect(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/) == nil)
}
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nope")) { response in
#expect(response.status == .unprocessableContent)
}
}
}
}
@@ -141,7 +141,11 @@ struct TradeControllerTests {
// The copy is off sale, for everyone. // The copy is off sale, for everyone.
try await client.execute(uri: listing, method: .get) { response in try await client.execute(uri: listing, method: .get) { response in
#expect(String(buffer: response.body).contains("This copy is sold.")) let body = String(buffer: response.body)
#expect(body.contains("This copy sold on "))
// Three other copies of Nightward #1 remain listed, none of them in this shop.
#expect(body.contains("has 0 more copies of Nightward #1, and 3 are listed across CCN."))
} }
// A step out of turn changes nothing. // A step out of turn changes nothing.
@@ -0,0 +1,55 @@
import Foundation
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"RecoveryLinks type",
.tags(.type)
)
struct RecoveryLinksTests {
// MARK: Methods tests
@Test
func `hands out a link that can be looked at, and used once`() async {
let links = RecoveryLinks()
let token = await links.make(for: .mara)
let peeked = await links.user(for: token)
let stillGood = await links.user(for: token)
let redeemed = await links.redeem(token)
let again = await links.redeem(token)
#expect(token.count == 64)
#expect(peeked == .mara)
#expect(stillGood == .mara)
#expect(redeemed == .mara)
#expect(again == nil)
}
@Test
func `lets a link run out after an hour, and ends an account's older link`() async {
let clock = Clock()
let links = RecoveryLinks(now: clock.now)
let older = await links.make(for: .mara)
let newer = await links.make(for: .mara)
let other = await links.make(for: .joost)
let replaced = await links.user(for: older)
clock.advance(by: 3_599)
let inTime = await links.user(for: newer)
clock.advance(by: 1)
let tooLate = await links.redeem(newer)
let othersToo = await links.user(for: other)
#expect(replaced == nil)
#expect(inTime == .mara)
#expect(tooLate == nil)
#expect(othersToo == nil)
}
}