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