diff --git a/Services/Website/README.md b/Services/Website/README.md index 0e5ae09..6f03b15 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -52,9 +52,9 @@ LogRequestsMiddleware → FileMiddleware (serves Resources/Static) RootController (GET / → landing page, language negotiated; GET / → 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. diff --git a/Services/Website/Resources/Static/css/collecting.css b/Services/Website/Resources/Static/css/collecting.css index 7c113b6..9380e42 100644 --- a/Services/Website/Resources/Static/css/collecting.css +++ b/Services/Website/Resources/Static/css/collecting.css @@ -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); +} diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index e951f21..e5b9536 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -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; diff --git a/Services/Website/Resources/Static/css/trade.css b/Services/Website/Resources/Static/css/trade.css index 89d491e..49d2898 100644 --- a/Services/Website/Resources/Static/css/trade.css +++ b/Services/Website/Resources/Static/css/trade.css @@ -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); +} diff --git a/Services/Website/Resources/Static/js/passkey.js b/Services/Website/Resources/Static/js/passkey.js index 54e8dc8..f8f0d4a 100644 --- a/Services/Website/Resources/Static/js/passkey.js +++ b/Services/Website/Resources/Static/js/passkey.js @@ -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; } }); diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 49713dc..63ada33 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -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( 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( origin: passkeysOrigin, accounts: accounts, - sessions: sessions + sessions: sessions, + recoveryLinks: recoveryLinks ) ListingFormController( catalogue: repository, @@ -312,6 +316,7 @@ private func router( trade: trade, catalogue: repository, sessions: sessions, + collecting: collecting, assetVersion: assetVersion, analytics: analytics, onCatalogueChange: refreshHome diff --git a/Services/Website/Sources/Library/Internal/Components/PasskeyUnsupported.swift b/Services/Website/Sources/Library/Internal/Components/PasskeyUnsupported.swift new file mode 100644 index 0000000..12dd156 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/PasskeyUnsupported.swift @@ -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." + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift index 2862651..e8066f0 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift @@ -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), ] ), ] diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift index cd7f50b..6318232 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift @@ -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" + } } } diff --git a/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift b/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift index d8241b4..4382519 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift @@ -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. diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift index ec11180..37a3080 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -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( diff --git a/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift b/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift index a8b3725..abd3003 100644 --- a/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift @@ -88,6 +88,9 @@ extension AccountPage: Page { a(.href(.Route.wants)) { "Want list" } + a(.href(.Route.collection)) { + "Collection" + } a(.href(.Route.messages)) { "Messages" } diff --git a/Services/Website/Sources/Library/Internal/Pages/CollectionPage.swift b/Services/Website/Sources/Library/Internal/Pages/CollectionPage.swift new file mode 100644 index 0000000..b607d1e --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/CollectionPage.swift @@ -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], + 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 + let listed: Set + let cover: Listing.Cover? + + /// The issues of the run not owned yet. + var missing: Set { + 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> + ) -> 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) } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/EventsPage.swift b/Services/Website/Sources/Library/Internal/Pages/EventsPage.swift new file mode 100644 index 0000000..86a3ed3 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/EventsPage.swift @@ -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" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift index 6749851..04e20bf 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -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")) { diff --git a/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift b/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift index 81423d3..77a1be3 100644 --- a/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift @@ -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, diff --git a/Services/Website/Sources/Library/Internal/Pages/RecoverPage.swift b/Services/Website/Sources/Library/Internal/Pages/RecoverPage.swift new file mode 100644 index 0000000..e5aaa78 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/RecoverPage.swift @@ -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." + } + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift index f30a550..3a6b02e 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift @@ -137,16 +137,27 @@ extension SearchPage: Page { if listings.isEmpty { div(.class("empty")) { h2(.class("type-panel")) { - "No listings match" + parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match" } p(.class("type-body-sm")) { - "Try fewer filters, or let listings ship from anywhere." + "Nothing listed meets that right now. Clear the filters — listings outside the EU are left out by default — or ask to be told when a copy appears." } - a( - .class("button button--outline"), - .href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path)) - ) { - "Clear all filters" + div(.class("empty__actions")) { + if let query = parameters.value(.query) { + a( + .class("button button--primary"), + .href(.Route.newWant(title: query)) + ) { + Icon(.bell, size: 16) + "Alert me" + } + } + a( + .class("button button--outline"), + .href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path)) + ) { + "Clear all filters" + } } } } else { @@ -268,6 +279,7 @@ private extension SearchPage { span(.class("type-small")) { "Outside the EU, import VAT may apply." } + checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event") } group("Key issues") { checkbox(.keys, value: "1", label: "Key issues only") diff --git a/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift index d68cfc4..c137530 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift @@ -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" + } + } + } + } } } } diff --git a/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift b/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift index 499f94a..4d3530d 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift @@ -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? + /// 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? = 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 } diff --git a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift index 4b8d017..0bc641e 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift @@ -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"), diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift index c45a0ba..c606b41 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift @@ -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?" + } + } } } } diff --git a/Services/Website/Sources/Library/Internal/Pages/WantsPage.swift b/Services/Website/Sources/Library/Internal/Pages/WantsPage.swift index 9ed6672..5b88759 100644 --- a/Services/Website/Sources/Library/Internal/Pages/WantsPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/WantsPage.swift @@ -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." } } } diff --git a/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift b/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift index ad72f79..1a5c832 100644 --- a/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift +++ b/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift @@ -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 diff --git a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift index 2937950..014b684 100644 --- a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift @@ -18,6 +18,9 @@ public struct CatalogueController { /// 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 { /// - 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 diff --git a/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift b/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift index b62d628..97905ab 100644 --- a/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift @@ -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? + + 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 diff --git a/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift index 3688129..3a5aad5 100644 --- a/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift @@ -28,6 +28,9 @@ public struct PasskeyController { /// 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 { /// - 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.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. diff --git a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift index 1e51ea1..93eb978 100644 --- a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift @@ -29,6 +29,9 @@ public struct SessionController { /// 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 { /// - 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 { 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 { 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 +} diff --git a/Services/Website/Sources/Library/Public/Controllers/TradeController.swift b/Services/Website/Sources/Library/Public/Controllers/TradeController.swift index 566194c..5e5d4c3 100644 --- a/Services/Website/Sources/Library/Public/Controllers/TradeController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/TradeController.swift @@ -20,6 +20,9 @@ public struct TradeController { /// 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 { /// - 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 { 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 { 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 diff --git a/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift index 2875fc2..7ca518e 100644 --- a/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift +++ b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift @@ -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) } diff --git a/Services/Website/Sources/Library/Public/Types/RecoveryLinks.swift b/Services/Website/Sources/Library/Public/Types/RecoveryLinks.swift new file mode 100644 index 0000000..7979187 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/RecoveryLinks.swift @@ -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) + } + +} diff --git a/Services/Website/Tests/Browser/passkeys.py b/Services/Website/Tests/Browser/passkeys.py index 73fc5f1..5fd269f 100644 --- a/Services/Website/Tests/Browser/passkeys.py +++ b/Services/Website/Tests/Browser/passkeys.py @@ -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("/") diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift index d4921c6..432198f 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift @@ -19,7 +19,7 @@ struct SiteFooterTests { #expect(html.contains(">\(title)")) } - 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)""#)) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift index fc7e33c..d823253 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift @@ -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""#)) } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift index da8831f..6788994 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift @@ -96,7 +96,7 @@ struct CollectorPagesTests { #expect(html.contains(#"name="title" value="Nightward #2""#)) #expect(html.contains(#"