diff --git a/Services/Website/README.md b/Services/Website/README.md index aa9698a..0e5ae09 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -7,6 +7,7 @@ The service: - Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)). - Serves the path to a sale, for signed-in accounts: the offer sheet, the messages, the sale records, and the seller dashboard (see [Path to a sale](#path-to-a-sale)). - Serves the gate: the sign-in screen at `GET /sign-in`, its passkey ceremonies under `POST /sign-in/passkey/…`, the demo sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)). +- Serves what an account does for itself — its first sign-in, its profile and passkeys, opening a shop — and what a collector uses: the want list, the series screens, and the comparison of copies (see [Accounts and collecting](#accounts-and-collecting)). - Serves the public grading guide at `GET /grading-guide`, and the new-listing form at `GET`/`POST /sell/new` for the owner of a shop (see [Selling](#selling)). - Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. - Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)). @@ -52,6 +53,8 @@ LogRequestsMiddleware 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) +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) 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) @@ -69,9 +72,9 @@ The screens follow the **CCN marketplace** design canvas: ### Catalogue - **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch. -- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`) — a copy sold since still shows there until the next launch. +- **Landing page**: rendered once and cached (`HomePages`), and rendered again whenever a sale or a new listing changes the catalogue — so its entity tag changes with it. - **Search and shop**: `SearchParameters` turns the query string into a `ListingFilter` and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain `GET` forms and links. Searches default to listings shipping from the EU. -- **Gated actions**: signed out, buy, offer, message, save, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message lead on (see [Path to a sale](#path-to-a-sale)), while save and follow render disabled. A sold copy, and a seller's own listing, show no actions. +- **Gated actions**: signed out, buy, offer, message, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message lead on (see [Path to a sale](#path-to-a-sale)), while follow renders disabled. Saving a copy adds a want for it. A sold copy, and a seller's own listing, show no actions. - **Covers**: a listing shows its first photo, or — like every seeded listing — a placeholder `Cover` drawn from its lettering and tone. - **Scripts**: `js/shared.js` only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it. @@ -86,8 +89,9 @@ How it holds together: - **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` over HTTPS). They end with the process. - **Return to the intent**: the `return` parameter is followed only when it is a rooted path on this site; anything else returns home. - **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. -- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out. -- **Left out**: the first-sign-in screen (display name, backup passkey), recovery, and managing passkeys from an account screen. +- **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. 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). @@ -105,6 +109,16 @@ The signatures need a WebAuthn client, so the ceremonies are checked in a browse - **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. +### 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. +- **Account**: the display name, the passkeys with a way to add and revoke them, and the sign-out. Notifications and language settings are left out: nothing stands behind them yet. +- **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. +- **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. + ### Selling - **One vocabulary**: `Grading` holds the scale, the conditions, and the eight-box defect checklist. The grading guide prints it, the form offers it, and `ListingForm` applies it — a colour-breaking crease caps a raw book at 7.0, and case scuffs are a slab's alone. - **The form is the data model**: format, catalogue fields, slabbed or raw, grade, declared defects, photos, price. It posts as `multipart/form-data`, parsed with [MultipartKit](https://github.com/vapor/multipart-kit). Without scripts every field shows, labelled by what it applies to; with them, `js/shared.js` hides the rest. diff --git a/Services/Website/Resources/Static/css/collecting.css b/Services/Website/Resources/Static/css/collecting.css new file mode 100644 index 0000000..7c113b6 --- /dev/null +++ b/Services/Website/Resources/Static/css/collecting.css @@ -0,0 +1,235 @@ +/* CCN — series index, series, and the comparison of copies. */ + +.series, +.series__section, +.series__intro, +.compare { + display: flex; + flex-direction: column; +} + +.series, +.compare { + gap: var(--space-8); +} + +.series__section { + gap: var(--space-5); +} + +.series__intro { + gap: var(--space-3); + min-width: 0; + max-width: 760px; +} + +.series__head { + display: flex; + align-items: flex-start; + gap: var(--space-5); +} + +.cover--series { + flex-shrink: 0; + width: 96px; + box-shadow: var(--shadow-cover-sm); +} + +.series__keys { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.series__keys .chip { + white-space: normal; +} + +/* The run, issue by issue: a gap reads quieter than a listed issue, and leads to an alert. */ +.series__issues { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-2); +} + +.series__issue { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--card); + font-size: 12px; +} + +.series__issue--gap { + border-style: dashed; + background: transparent; +} + +.series__issue a { + color: var(--ink); + font-weight: 600; +} + +.series__issue--gap a { + color: var(--muted); + font-weight: 400; +} + +.series__issue a[aria-current] { + color: var(--accent); +} + +.series__number { + font-family: var(--font-display); + font-size: 20px; + font-weight: 600; + line-height: 1; +} + +.series__copies { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-4); +} + +.series__copies .table-scroll { + width: 100%; +} + +.series__index { + display: grid; + gap: var(--space-4); +} + +.series__entry { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4); + color: var(--ink); + text-decoration: none; +} + +.series__entry:hover { + border-color: var(--ink); + color: var(--ink); +} + +.series__entry-text { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} + +.series__entry-name { + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; + line-height: 1.1; +} + +/* Comparison */ +.compare__head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + padding-bottom: var(--space-5); + border-bottom: 1px solid var(--line); +} + +.compare__lead { + color: var(--muted); +} + +.compare__copies { + display: grid; + gap: var(--space-5); + align-items: start; +} + +.compare__copy { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4); +} + +.compare__cover { + display: block; + width: min(100%, 180px); + text-decoration: none; +} + +.compare__price { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.compare__facts { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + column-gap: var(--space-3); + font-size: 13px; +} + +.compare__facts dt, +.compare__facts dd { + padding: 10px 0; + border-top: 1px solid var(--line); +} + +.compare__facts dt { + color: var(--muted); +} + +.compare__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +@media (min-width: 600px) { + .cover--series { + width: 160px; + } + + .series__index { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compare__copies { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .series, + .compare { + padding-top: var(--space-6); + } + + .series__head { + gap: var(--space-8); + } + + .cover--series { + width: 200px; + } + + .series__issues { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } + + .series__index { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/Services/Website/Resources/Static/css/listing.css b/Services/Website/Resources/Static/css/listing.css index 5a266fa..3832b9c 100644 --- a/Services/Website/Resources/Static/css/listing.css +++ b/Services/Website/Resources/Static/css/listing.css @@ -58,6 +58,11 @@ font-size: 15px; } +.listing__series { + color: var(--ink); + font-weight: 600; +} + .buy-box { display: flex; flex-direction: column; diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index 660b0f8..e951f21 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -1049,20 +1049,6 @@ textarea.input { color: #ffffff; } -/* The sign-out is a form, so it cannot be triggered by a link on another site. */ -.site-header__sign-out { - display: flex; - margin: 0; -} - -.site-header__sign-out button { - min-height: var(--control-md); - padding: 0 var(--space-1); - border: 0; - background: none; - cursor: pointer; -} - .site-header__account { display: inline-flex; align-items: center; @@ -1854,6 +1840,91 @@ textarea.input { color: var(--muted); } +/* Tables, scrolling sideways on their own when the screen is narrower than their columns */ +.table-scroll { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th, +.table td { + padding: var(--space-3) var(--space-3) var(--space-3) 0; + border-top: 1px solid var(--line); + text-align: left; + vertical-align: middle; + white-space: nowrap; +} + +.table th { + border-top: 0; + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.table td a { + color: var(--ink); +} + +/* A checkbox whose label runs over several lines */ +.check--block { + align-items: flex-start; + line-height: 1.45; +} + +.check--block input { + flex-shrink: 0; + margin-top: 2px; +} + +/* A switch: a button that flips a setting and says which way it is */ +.switch { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: var(--control-md); + padding: 0; + border: 0; + background: none; + font-size: 13px; + cursor: pointer; +} + +.switch__track { + position: relative; + flex-shrink: 0; + width: 44px; + height: 26px; + border-radius: 13px; + background: var(--rule); +} + +.switch__track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--card); +} + +.switch[aria-checked="true"] .switch__track { + background: var(--header); +} + +.switch[aria-checked="true"] .switch__track::after { + left: 21px; +} + /* Empty states */ .empty { display: flex; diff --git a/Services/Website/Resources/Static/css/sign-in.css b/Services/Website/Resources/Static/css/sign-in.css index df14e4e..2a925ba 100644 --- a/Services/Website/Resources/Static/css/sign-in.css +++ b/Services/Website/Resources/Static/css/sign-in.css @@ -69,6 +69,31 @@ gap: var(--space-7); } +/* The backup-passkey nudge on first sign-in */ +.gate__nudge { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4); + border-radius: var(--radius-sm); + background: var(--header); + color: var(--on-dark-muted); + font-size: 13px; + line-height: 1.45; +} + +.gate__nudge .icon { + margin-top: 1px; + color: var(--gold); +} + +.gate__nudge-title { + margin-bottom: 3px; + color: var(--on-dark); + font-size: 14px; + font-weight: 600; +} + .gate__demo { display: flex; flex-direction: column; diff --git a/Services/Website/Resources/Static/css/trade.css b/Services/Website/Resources/Static/css/trade.css index 8f702b4..89d491e 100644 --- a/Services/Website/Resources/Static/css/trade.css +++ b/Services/Website/Resources/Static/css/trade.css @@ -896,3 +896,173 @@ a.rows__row:hover { padding-top: var(--space-8); } } + +/* ========================================================================== + Want list + ========================================================================== */ + +.wants { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: calc(720px + 2 * var(--gutter)); +} + +.wants__form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.wants__list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.wants__want { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: 14px; +} + +.wants__top, +.wants__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.wants__title { + color: var(--ink); + font-size: 15px; + font-weight: 600; + text-decoration: none; +} + +.wants__title:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.wants__new { + padding: 3px 7px; + border-radius: var(--radius-pill); + background: var(--accent); + color: #ffffff; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.wants__match { + color: var(--ink); + font-size: 13px; + font-weight: 600; +} + +.wants__add { + display: flex; + align-items: center; + justify-content: center; + height: 50px; + border: 1.5px dashed var(--ink); + border-radius: var(--radius-sm); + color: var(--ink); + font-size: 14px; + font-weight: 600; + text-decoration: none; +} + +.wants__add:hover { + background: var(--card); + color: var(--ink); +} + +.wants__note { + text-align: center; +} + +/* ========================================================================== + Account + ========================================================================== */ + +.account { + display: grid; + gap: var(--space-6); + align-items: start; +} + +.account__rail, +.account__main, +.account__section, +.account__nav { + display: flex; + flex-direction: column; +} + +.account__rail { + gap: var(--space-2); +} + +.account__nav { + padding-top: var(--space-2); +} + +.account__nav a { + padding: 10px 0; + border-top: 1px solid var(--line); + color: var(--ink); + font-size: 14px; + text-decoration: none; +} + +.account__nav a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.account__main { + gap: var(--space-5); +} + +.account__section { + gap: var(--space-4); +} + +.account__form { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space-3); +} + +.account__form .field { + flex: 1 1 260px; +} + +.account__form .button { + margin-top: 23px; +} + +.account__text { + color: var(--ink-soft); +} + +.rows__row--passkey { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +@media (min-width: 1024px) { + .wants { + padding-top: var(--space-8); + } + + .account { + grid-template-columns: 240px minmax(0, 1fr); + gap: var(--space-10); + padding-top: var(--space-8); + } +} diff --git a/Services/Website/Resources/Static/js/passkey.js b/Services/Website/Resources/Static/js/passkey.js index ed76762..54e8dc8 100644 --- a/Services/Website/Resources/Static/js/passkey.js +++ b/Services/Website/Resources/Static/js/passkey.js @@ -1,4 +1,4 @@ -// CCN — passkey sign-in. Without it, or without WebAuthn, the sign-in screen offers the demo sign-in alone. +// CCN — passkeys: signing in or joining, and adding one from the account screen. Without it, or without WebAuthn, the demo sign-in remains. const form = document.querySelector("[data-passkey-form]"); const supported = window.PublicKeyCredential @@ -30,7 +30,10 @@ if (form && supported) { status.textContent = ""; try { - const { mode, options } = await post("/sign-in/passkey/options", { email: form.elements.email.value }); + // 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 }; + const { mode, options } = await post("/sign-in/passkey/options", start); let credential; if (mode === "create") { @@ -47,7 +50,7 @@ if (form && supported) { const { location } = await post("/sign-in/passkey/finish", { credential: credential.toJSON(), - return: form.elements.return.value, + return: form.querySelector("[name=return]")?.value, }); window.location.assign(location); diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index a1251c6..49713dc 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -1,5 +1,6 @@ import Configuration import Hummingbird +import HummingbirdFluent import Localization import Logging import Persistence @@ -71,6 +72,7 @@ func application( logLevel: reader.logLevel, passkeysOrigin: reader.passkeysOrigin, probe: Probe(fluent: fluent), + fluent: fluent, accounts: AccountRepository(fluent: fluent), trade: TradeRepository(fluent: fluent), repository: repository, @@ -186,6 +188,7 @@ private func logger( /// - logLevel: the level the request-logging middleware logs at. /// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to. /// - probe: the probe consulted by the `HealthController` readiness route. +/// - fluent: the service the remaining repositories are built on. /// - accounts: the repository the `SessionController` finds and creates accounts through. /// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through. /// - repository: the repository the `CatalogueController` reads the shops and listings through. @@ -204,6 +207,7 @@ private func router( logLevel: Logger.Level, passkeysOrigin: String, probe: Probe, + fluent: Fluent, accounts: AccountRepository, trade: TradeRepository, repository: CatalogueRepository, @@ -215,7 +219,26 @@ private func router( context: AppRequestContext.self, options: .autoGenerateHeadEndpoints ) - let sessions = SessionStore(trade: trade) + let collecting = CollectingRepository(fluent: fluent) + let sessions = SessionStore( + trade: trade, + collecting: collecting + ) + let root = RootController( + assetVersion: assetVersion, + siteOrigin: siteOrigin, + analytics: analytics, + listings: listings, + sessions: sessions + ) + // A sale takes a copy off the landing page and a new listing puts one on; either renders it again. + let refreshHome: @Sendable () async -> Void = { [home = root.home] in + guard let listings = try? await repository.listings() else { + return + } + + await home.refresh(with: .init(from: listings)) + } router.addMiddleware { LogRequestsMiddleware(logLevel) @@ -241,13 +264,7 @@ private func router( } router.addController { - RootController( - assetVersion: assetVersion, - siteOrigin: siteOrigin, - analytics: analytics, - listings: listings, - sessions: sessions - ) + root CatalogueController( repository: repository, sessions: sessions, @@ -263,6 +280,20 @@ private func router( assetVersion: assetVersion, analytics: analytics ) + AccountController( + accounts: accounts, + catalogue: repository, + sessions: sessions, + assetVersion: assetVersion, + analytics: analytics + ) + CollectingController( + collecting: collecting, + catalogue: repository, + sessions: sessions, + assetVersion: assetVersion, + analytics: analytics + ) PasskeyController( origin: passkeysOrigin, accounts: accounts, @@ -274,14 +305,16 @@ private func router( sessions: sessions, photos: PhotoStore(staticFilesPath: staticFilesPath), assetVersion: assetVersion, - analytics: analytics + analytics: analytics, + onCatalogueChange: refreshHome ) TradeController( trade: trade, catalogue: repository, sessions: sessions, assetVersion: assetVersion, - analytics: analytics + analytics: analytics, + onCatalogueChange: refreshHome ) HealthController( probe: probe diff --git a/Services/Website/Sources/Library/Internal/Components/Fields.swift b/Services/Website/Sources/Library/Internal/Components/Fields.swift new file mode 100644 index 0000000..c162027 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Fields.swift @@ -0,0 +1,112 @@ +import Elementary + +/// A labelled text field, with its hint or — tied to it for assistive technology — what is wrong with it. +struct TextField: HTML { + + // MARK: Properties + + let name: String + let title: String + var value: String = "" + var placeholder: String = "" + var hint: String? + var problem: String? + var isWide = false + var maxLength: Int? + + // MARK: Computed + + var body: some HTML { + div(.class(isWide ? "field field--wide" : "field")) { + label( + .class("field__label"), + .for(name) + ) { + title + } + input( + .class("input"), + .id(name), + .type(.text), + .name(name), + .value(value), + .placeholder(placeholder) + ) + .attributes(.custom(name: "maxlength", value: "\(maxLength ?? 0)"), when: maxLength != nil) + .attributes( + .custom(name: "aria-invalid", value: "true"), + .custom(name: "aria-describedby", value: "\(name)-error"), + when: problem != nil + ) + FieldNote(name: name, hint: hint, problem: problem) + } + } + +} + +/// A labelled select, showing the chosen option. +struct SelectField: HTML { + + // MARK: Properties + + let name: String + let title: String + let options: [(value: String, label: String)] + var selected: String? + var problem: String? + + // MARK: Computed + + var body: some HTML { + div(.class("field")) { + label( + .class("field__label"), + .for(name) + ) { + title + } + select( + .class("select select--block"), + .id(name), + .name(name) + ) { + ForEach(options) { option in + Elementary.option(.value(option.value)) { + option.label + } + .attributes(.selected, when: option.value == selected) + } + } + FieldNote(name: name, hint: nil, problem: problem) + } + } + +} + +/// What is wrong with a field, under it; or, when nothing is, its hint. +struct FieldNote: HTML { + + // MARK: Properties + + let name: String + let hint: String? + let problem: String? + + // MARK: Computed + + var body: some HTML { + if let problem { + p( + .class("field__error"), + .id("\(name)-error") + ) { + problem + } + } else if let hint { + p(.class("field__hint")) { + hint + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/ListingCard.swift b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift index 65a9a6a..148731c 100644 --- a/Services/Website/Sources/Library/Internal/Components/ListingCard.swift +++ b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift @@ -30,13 +30,13 @@ struct ListingCard: HTML { var body: some HTML { article(.class("listing-card")) { - // Saving is a gated action: signed out, it leads to the sign-in screen. + // Saving a copy adds a want for it; signed out, the want list leads to the sign-in screen first. a( .class("listing-card__save"), - .href(.Route.signIn(returningTo: listing.path)), + .href(.Route.newWant(listing: listing.slug)), .custom( name: "aria-label", - value: "Save \(listing.title) to watchlist" + value: "Add \(listing.title) to your want list" ) ) { Icon(.bookmark, size: 18) diff --git a/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift index 1cde858..2862651 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift @@ -45,17 +45,6 @@ struct SiteFooter: HTML { span { "CCN B.V. · Amsterdam · KvK [NUMBER] · BTW-id [NUMBER] · Business sellers show their KvK number on their shop page; consumers have a 14-day right of withdrawal on purchases from business sellers." } - span(.class("site-footer__legal-links")) { - a(.href(.Route.terms)) { - "Terms" - } - a(.href(.Route.privacy)) { - "Privacy" - } - a(.href(.Route.sellerRules)) { - "Seller rules" - } - } } } } @@ -88,7 +77,6 @@ private extension SiteFooter { ("Open a shop", .Route.openShop), ("Seller dashboard", .Route.sellerDashboard), ("Grading guide", .Route.gradingGuide), - ("Trade at events", .Route.events), ] ), ( @@ -96,10 +84,8 @@ private extension SiteFooter { links: [ ("Sign in or join", .Route.signIn), ("Want list & alerts", .Route.wants), - ("My collection", .Route.collection), ("Messages", .Route.messages), ("Passkeys & settings", .Route.account), - ("Lost your passkey?", .Route.recover), ] ), ] diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift index f2b9f87..cd7f50b 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 and events links shown on tablet only. + /// The format links, plus the series link shown on tablet only. var navigation: some HTML { nav( .class("site-nav"), @@ -134,12 +134,6 @@ private extension SiteHeader { ) { "Series" } - a( - .class("site-nav__link site-nav__link--extra"), - .href(.Route.events) - ) { - "Events" - } } } @@ -193,7 +187,7 @@ private extension SiteHeader { .bell, label: "Want list", count: viewer.newWantMatches, - unit: "new matches", + unit: viewer.newWantMatches == 1 ? "new match" : "new matches", path: .Route.wants ) counter( @@ -203,13 +197,11 @@ private extension SiteHeader { unit: "unread", path: .Route.messages ) - if viewer.hasShop { - a( - .class("site-header__text-link site-header__text-link--strong from-tablet"), - .href(.Route.sellerDashboard) - ) { - "My shop" - } + a( + .class("site-header__text-link site-header__text-link--strong from-tablet"), + .href(viewer.hasShop ? .Route.sellerDashboard : .Route.openShop) + ) { + viewer.hasShop ? "My shop" : "Open a shop" } a( .class("site-header__account"), @@ -226,19 +218,6 @@ private extension SiteHeader { viewer.initials } } - // Not in the design, whose sign-out lives on the account screen the prototype leaves out. - form( - .class("site-header__sign-out"), - .action(.Route.signOut), - .method(.post) - ) { - button( - .class("site-header__text-link"), - .type(.submit) - ) { - "Sign out" - } - } } /// An icon link with a count badge, omitted at zero. diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift index e86af6e..ec11180 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -3,14 +3,12 @@ import Foundation extension String { /// A namespace for the paths of the site's screens. /// - /// Screens not built yet are listed too, and answer with the not-found page until they are. + /// Single-sourced, so the navigation and the controllers never spell a path differently. enum Route { /// The account screen. static let account = "/account" - /// The collection tracker. - static let collection = "/collection" - /// The events screen. - static let events = "/events" + /// The side-by-side comparison of copies. + static let compare = "/compare" /// The public grading guide. static let gradingGuide = "/grading-guide" /// The browse / home screen. @@ -19,26 +17,20 @@ extension String { static let messages = "/messages" /// The open-a-shop onboarding. static let openShop = "/sell/open-a-shop" - /// The privacy statement. - static let privacy = "/privacy" - /// The passkey recovery screen. - static let recover = "/recover" /// The search results screen. static let search = "/search" /// The seller dashboard. static let sellerDashboard = "/sell" - /// The rules sellers agree to. - static let sellerRules = "/seller-rules" /// The series index. static let series = "/series" /// The passkey sign-in screen. static let signIn = "/sign-in" /// The path a sign-out is posted to. static let signOut = "/sign-out" - /// The terms of service. - static let terms = "/terms" /// The want list. static let wants = "/wants" + /// The first-sign-in screen. + static let welcome = "/welcome" /// The path an offer is answered at. /// - Parameter id: the offer's identifier. @@ -117,6 +109,17 @@ extension String { "/sales/\(number)" } + /// The path of a series' screen, optionally on the copies of one issue. + /// - Parameters: + /// - slug: the series' URL slug. + /// - issue: the issue whose copies are listed, or `nil` (the default) for none. + static func series( + _ slug: String, + issue: Int? = nil + ) -> String { + "\(series)/\(slug)\(issue.map { "?issue=\($0)" } ?? "")" + } + /// The path of a shop's public screen. /// - Parameter slug: the shop's URL slug. static func shop( @@ -146,6 +149,25 @@ extension String { return path } + /// The path of the first-sign-in screen, continuing to the given path afterwards. + static func welcome( + returningTo path: String + ) -> String { + "\(welcome)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" + } + + /// The path of the form adding a want, filled from a listing or with a title. + static func newWant( + listing slug: String? = nil, + title: String? = nil + ) -> String { + let query = [slug.map { "listing=\($0)" }, title.map { "title=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")" }] + .compactMap(\.self) + .joined(separator: "&") + + return "\(wants)/new\(query.isEmpty ? "" : "?\(query)")" + } + /// The path of the sign-in screen, returning to the given path afterwards. /// - Parameter path: the path the visitor's gated action started from. static func signIn( diff --git a/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift b/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift new file mode 100644 index 0000000..a8b3725 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/AccountPage.swift @@ -0,0 +1,236 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// The account: the display name, the passkeys that sign it in, and the way out. +struct AccountPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The moment stamps are read relative to. + let now: Date + + /// The account's passkeys, the oldest first, with when each was created. + let passkeys: [(passkey: Passkey, createdAt: Date)] + + /// What is wrong with the display name posted, or `nil` when nothing is. + let problem: String? + + /// The account shown. + let user: User + + /// The signed-in viewer. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates the account screen. + /// - Parameters: + /// - user: the account shown. + /// - passkeys: the account's passkeys, the oldest first, with when each was created. + /// - problem: what is wrong with the display name posted, or `nil` (the default) when nothing is. + /// - viewer: the signed-in viewer. + /// - now: the moment stamps are read relative to; the present by default. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + user: User, + passkeys: [(passkey: Passkey, createdAt: Date)], + problem: String? = nil, + viewer: Viewer? = nil, + now: Date = .init(), + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.locale = .init(identifier: "en") + self.now = now + self.passkeys = passkeys + self.problem = problem + self.user = user + self.viewer = viewer + } + +} + +// MARK: - Page + +extension AccountPage: Page { + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page account")) { + aside(.class("account__rail")) { + p(.class("type-eyebrow")) { + "Account" + } + h1(.class("type-panel")) { + user.displayName + } + nav( + .class("account__nav"), + .custom(name: "aria-label", value: "Account sections") + ) { + a(.href("#profile")) { + "Profile" + } + a(.href("#passkeys")) { + "Passkeys" + } + a(.href(.Route.wants)) { + "Want list" + } + a(.href(.Route.messages)) { + "Messages" + } + a(.href(viewer?.hasShop == true ? .Route.sellerDashboard : .Route.openShop)) { + viewer?.hasShop == true ? "My shop" : "Open a shop" + } + } + } + div(.class("account__main")) { + section( + .class("card account__section"), + .id("profile") + ) { + h2(.class("type-panel")) { + "Profile" + } + form( + .class("account__form"), + .action("\(String.Route.account)/name"), + .method(.post) + ) { + TextField( + name: "name", + title: "Display name", + value: user.displayName, + hint: "How sellers and buyers see you. Signed in as \(user.email).", + problem: problem, + maxLength: 40 + ) + button( + .class("button button--dark"), + .type(.submit) + ) { + "Save" + } + } + } + section( + .class("card account__section"), + .id("passkeys"), + .custom(name: "data-passkey-form", value: "") + ) { + div(.class("section-head")) { + h2(.class("type-panel")) { + "Passkeys" + } + // Shown by `js/passkey.js` once the browser is known to do WebAuthn. + button( + .class("button button--primary button--sm"), + .type(.button), + .hidden, + .custom(name: "data-passkey", value: "add") + ) { + Icon(.key, size: 15) + "Add a passkey" + } + } + p(.class("type-body-sm account__text")) { + "Any of these signs you in. Keep at least two on different devices so a lost phone never locks you out. Revoking one takes effect immediately." + } + p( + .class("field__error"), + .custom(name: "role", value: "alert"), + .custom(name: "data-passkey-status", value: "") + ) {} + if passkeys.isEmpty { + p(.class("notice")) { + "No passkey yet: this account signs in through the prototype's demo sign-in." + } + } else { + ul(.class("rows")) { + ForEach(Array(passkeys.enumerated())) { index, entry in + li(.class("rows__row rows__row--passkey")) { + Icon(.key) + span { + strong { + "Passkey \(index + 1)" + } + span(.class("type-small")) { + " · created \(entry.createdAt.stamp(relativeTo: now))" + } + } + form( + .action("\(String.Route.account)/passkeys/revoke"), + .method(.post) + ) { + input( + .type(.hidden), + .name("credential"), + .value(entry.passkey.credentialID) + ) + button( + .class("button button--sm"), + .type(.submit) + ) { + "Revoke" + } + } + } + } + } + } + } + section(.class("card account__section")) { + h2(.class("type-panel")) { + "Sign out" + } + form( + .action(.Route.signOut), + .method(.post) + ) { + button( + .class("button button--outline"), + .type(.submit) + ) { + "Sign out of this device" + } + } + } + } + } + } + } + + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared, StaticFile.passkey] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.trade] + } + + var title: String { + "Account — CCN" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ComparePage.swift b/Services/Website/Sources/Library/Internal/Pages/ComparePage.swift new file mode 100644 index 0000000..f083891 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/ComparePage.swift @@ -0,0 +1,194 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// Copies side by side: grade, page quality, declared defects, and what each costs delivered to the Netherlands. +struct ComparePage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The copies compared, cheapest delivered first. + let copies: [Listing] + + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates the comparison. + /// - Parameters: + /// - copies: the copies compared, in any order. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + copies: [Listing], + viewer: Viewer? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.copies = copies.sorted { Self.delivered($0) < Self.delivered($1) } + self.locale = .init(identifier: "en") + self.viewer = viewer + } + +} + +// MARK: - Page + +extension ComparePage: Page { + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page compare")) { + div(.class("compare__head")) { + div(.class("series__intro")) { + p(.class("type-eyebrow")) { + "Compare · \(copies.first?.title ?? "copies") · \(copies.count) \(copies.count == 1 ? "copy" : "copies")" + } + h1(.class("type-h1")) { + "Which copy?" + } + p(.class("type-body-sm compare__lead")) { + "Grade, page quality, declared defects and total cost delivered to the Netherlands, side by side." + } + } + if let first = copies.first { + a( + .class("button button--outline"), + .href(.Route.series(first.seriesSlug, issue: first.issueNumber)) + ) { + "Back to the series" + } + } + } + if copies.isEmpty { + div(.class("empty")) { + h2(.class("type-panel")) { + "Nothing to compare" + } + p(.class("type-body-sm")) { + "Tick up to \(SeriesPage.maxCompared) copies of an issue on its series page." + } + a( + .class("button button--outline"), + .href(.Route.series) + ) { + "Browse series" + } + } + } + ul(.class("compare__copies")) { + ForEach(copies) { copy in + li(.class("card compare__copy")) { + a( + .class("compare__cover"), + .href(copy.path), + .custom(name: "aria-label", value: "\(copy.title), \(copy.chip.label), from \(copy.shop.name)") + ) { + ListingImage(copy) + } + div(.class("compare__price")) { + span(.class("type-stat")) { + Self.delivered(copy).euros + } + Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified) + } + dl(.class("compare__facts")) { + ForEach(Self.facts(of: copy)) { fact in + dt { + fact.term + } + dd { + fact.value + } + } + } + div(.class("compare__actions")) { + a( + .class("button button--primary button--sm"), + .href(.Route.gated(.Route.buy(copy.slug), from: copy.path, viewer: viewer)) + ) { + "Buy now" + } + a( + .class("button button--outline button--sm"), + .href(copy.path) + ) { + "View listing" + } + } + } + } + } + p(.class("gate-note")) { + Icon(.info, size: 16) + span { + "The delivered price adds each seller's tracked shipping to the Netherlands. Sales from outside the EU may attract import VAT at delivery." + } + } + } + } + } + + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.collecting] + } + + var title: String { + "Compare copies — CCN" + } + +} + +// MARK: - Helpers + +private extension ComparePage { + + /// What a copy costs delivered to the Netherlands, in euro cents. + static func delivered( + _ copy: Listing + ) -> Int { + copy.priceCents + copy.shippingCents + } + + /// What sets a copy apart, row by row. + static func facts( + of copy: Listing + ) -> [(term: String, value: String)] { + let customs = copy.shop.shipsFromEU ? "" : " · customs may apply" + + return [ + ("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")), + ("Page quality", copy.pageQuality ?? "Not stated"), + ("Printing", copy.printingDetail), + ("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")), + ("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"), + ("Shop", "\(copy.shop.name) · \(copy.shop.location)"), + ("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(String(format: "%.1f", copy.shop.rating)) · \(copy.shop.returnsNote)"), + ] + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift index 0a75c38..6749851 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -95,7 +95,13 @@ extension ListingPage: Page { listing.title } p(.class("listing__byline")) { - "\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased())" + "\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased()) · " + a( + .class("listing__series"), + .href(.Route.series(listing.seriesSlug, issue: listing.issueNumber) + (listing.issueNumber == nil ? "" : "#copies")) + ) { + listing.issueNumber == nil ? "\(listing.seriesName) series" : "\(listing.seriesName) series · compare copies" + } } } buyBox @@ -224,16 +230,12 @@ private extension ListingPage { listing.acceptsOffers ? " · accepts offers" : "" } } - if viewer == nil { - a( - .class("button button--sm"), - .href(.Route.signIn(returningTo: listing.path)) - ) { - Icon(.bookmark, size: 16) - "Save" - } - } else { - Unavailable("Save", modifier: "button--sm") + a( + .class("button button--sm"), + .href(.Route.newWant(listing: listing.slug)) + ) { + Icon(.bookmark, size: 16) + "Save" } } if !listing.isAvailable { diff --git a/Services/Website/Sources/Library/Internal/Pages/OpenShopPage.swift b/Services/Website/Sources/Library/Internal/Pages/OpenShopPage.swift new file mode 100644 index 0000000..a7dc098 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/OpenShopPage.swift @@ -0,0 +1,274 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// The open-a-shop form: the shop's public identity, how its owner sells, and its policies. One screen; the first listing follows. +struct OpenShopPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The values the form is filled with. + let form: ShopForm + + /// What is wrong with the form, by field. + let problems: [ShopForm.Field: String] + + /// The signed-in viewer. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates the open-a-shop form. + /// - Parameters: + /// - form: the values the form is filled with; an empty form by default. + /// - problems: what is wrong with the form, by field; nothing by default. + /// - viewer: the signed-in viewer. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + form: ShopForm = .init(), + problems: [ShopForm.Field: String] = [:], + viewer: Viewer? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.form = form + self.locale = .init(identifier: "en") + self.problems = problems + self.viewer = viewer + } + +} + +// MARK: - Page + +extension OpenShopPage: Page { + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page listing-form")) { + div(.class("listing-form__head")) { + p(.class("type-eyebrow")) { + "Open a shop · one screen, about five minutes" + } + h1(.class("type-h1")) { + "Set up your shop" + } + p(.class("type-lead")) { + "A shop is a public page plus your policies. You can list the first book right after." + } + } + if !problems.isEmpty { + p( + .class("notice notice--bad"), + .custom(name: "role", value: "alert") + ) { + strong { + problems.count == 1 ? "One thing to fix before opening." : "\(problems.count) things to fix before opening." + } + } + } + Elementary.form( + .class("listing-form__form"), + .action(.Route.openShop), + .method(.post) + ) { + section(.class("card listing-form__section")) { + h2(.class("type-panel")) { + "1. Shop" + } + div(.class("listing-form__grid")) { + field(.name, "Shop name", placeholder: "Kestrel Row Comics", isWide: true) + field(.handle, "Handle", placeholder: "kestrel-row", hint: "Your page: /shop/your-handle") + field(.city, "Ships from", placeholder: "Amsterdam", hint: "Sets the ships-from filter", isWide: true) + SelectField( + name: ShopForm.Field.country.rawValue, + title: "Country", + options: ShopForm.countries.map { ($0.0, $0.1) }, + selected: form.value(.country), + problem: problems[.country] + ) + } + div(.class("field")) { + label( + .class("field__label"), + .for("about") + ) { + "About the shop (shown at the top of your page)" + } + textarea( + .class("input"), + .id("about"), + .name(ShopForm.Field.about.rawValue), + .custom(name: "rows", value: "3"), + .custom(name: "maxlength", value: "600") + ) { + form.value(.about) ?? "" + } + FieldNote(name: "about", hint: nil, problem: problems[.about]) + } + } + section(.class("card listing-form__section")) { + div(.class("section-head")) { + h2(.class("type-panel")) { + "2. Private or business seller" + } + span(.class("type-small")) { + "Required by Dutch consumer law for business sellers" + } + } + fieldset(.class("listing-form__modes")) { + legend(.class("visually-hidden")) { + "Seller type" + } + kind(.privateSeller, "Private collector", "Selling your own books now and then. No KvK needed. Buyers get no statutory right of withdrawal.") + kind(.business, "Business (eenmanszaak, VOF, BV…)", "KvK shown on your shop page; consumers get the 14-day right of withdrawal by law.") + } + FieldNote(name: "kind", hint: nil, problem: problems[.kind]) + div(.class("listing-form__grid")) { + field(.kvk, "KvK number", placeholder: "12345678", hint: "Business sellers only") + field(.vat, "BTW-id", placeholder: "NL123456789B01", hint: "Optional") + field(.legalName, "Legal name", placeholder: "As registered") + } + } + section(.class("card listing-form__section")) { + h2(.class("type-panel")) { + "3. Policies" + } + div(.class("listing-form__grid")) { + SelectField( + name: ShopForm.Field.returns.rawValue, + title: "Returns", + options: ShopForm.returns.map { ($0, $0) }, + selected: form.value(.returns), + problem: problems[.returns] + ) + SelectField( + name: ShopForm.Field.handling.rawValue, + title: "Handling time", + options: ShopForm.handlingDays.map { ("\($0)", $0 == 1 ? "1 business day" : "\($0) business days") }, + selected: form.value(.handling), + problem: problems[.handling] + ) + } + label(.class("check check--block")) { + input( + .type(.checkbox), + .name(ShopForm.Field.declaration.rawValue), + .value("1") + ) + .attributes(.checked, when: form.value(.declaration) != nil) + span { + "I'll declare every defect on the checklist and photograph the actual copy. Undeclared defects are grounds for a problem report against my shop. " + a(.href(.Route.gradingGuide)) { + "Grading guide" + } + } + } + FieldNote(name: "declaration", hint: nil, problem: problems[.declaration]) + p(.class("gate-note")) { + Icon(.info, size: 16) + span { + "Your shop is public from the moment you open it — anyone can browse it without an account. Shipping is priced per listing. A new shop starts unverified and unrated; ratings come only from finished sales." + } + } + } + div(.class("listing-form__submit")) { + a( + .class("button"), + .href(.Route.home) + ) { + "Cancel" + } + button( + .class("button button--primary button--lg"), + .type(.submit) + ) { + "Open shop" + } + } + } + } + } + } + + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.trade] + } + + var title: String { + "Open a shop — CCN" + } + +} + +// MARK: - Helpers + +private extension OpenShopPage { + + /// A text field of the form, showing its value and its problem. + func field( + _ field: ShopForm.Field, + _ title: String, + placeholder: String, + hint: String? = nil, + isWide: Bool = false + ) -> TextField { + .init( + name: field.rawValue, + title: title, + value: form.value(field) ?? "", + placeholder: placeholder, + hint: hint, + problem: problems[field], + isWide: isWide + ) + } + + /// A way of selling, as a radio card. + func kind( + _ kind: Shop.Kind, + _ title: String, + _ text: String + ) -> some HTML { + label(.class("listing-form__mode")) { + input( + .type(.radio), + .name(ShopForm.Field.kind.rawValue), + .value(kind.rawValue) + ) + .attributes(.checked, when: form.value(.kind) == kind.rawValue) + span { + strong { + title + } + span(.class("type-small")) { + text + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SeriesIndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/SeriesIndexPage.swift new file mode 100644 index 0000000..b9f0a15 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SeriesIndexPage.swift @@ -0,0 +1,109 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// Every series in the catalogue, with how many copies of each are listed. +struct SeriesIndexPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The series, by name, each with the listings of it still for sale. + let entries: [(series: Series, listings: [Listing])] + + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates the series index. + /// - Parameters: + /// - entries: the series, by name, each with the listings of it still for sale. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + entries: [(series: Series, listings: [Listing])], + viewer: Viewer? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.entries = entries + self.locale = .init(identifier: "en") + self.viewer = viewer + } + +} + +// MARK: - Page + +extension SeriesIndexPage: Page { + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page series")) { + div(.class("series__intro")) { + p(.class("type-eyebrow")) { + "Browse by series" + } + h1(.class("type-h1")) { + "Series" + } + p(.class("type-lead")) { + "Every issue of a run, what is listed of each, and the gaps — one tap from an alert." + } + } + ul(.class("series__index")) { + ForEach(entries) { entry in + li { + a( + .class("card series__entry"), + .href(.Route.series(entry.series.slug)) + ) { + if let cover = entry.listings.first { + ListingImage(cover, modifier: "cover--agreed") + } + span(.class("series__entry-text")) { + span(.class("series__entry-name")) { + entry.series.name + } + span(.class("type-caption")) { + "\(entry.series.publisher) · \(entry.series.years) · \(entry.series.issueCount) issues" + } + span(.class("type-small")) { + "\(entry.listings.count) \(entry.listings.count == 1 ? "copy" : "copies") listed" + } + } + } + } + } + } + } + } + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.collecting] + } + + var summary: String? { + "Browse comics by series on CCN: every issue of a run, what is listed of each, and the gaps." + } + + var title: String { + "Series — CCN" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift b/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift new file mode 100644 index 0000000..499f94a --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SeriesPage.swift @@ -0,0 +1,312 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// A series: its issues with what is listed of each — and what is not, one tap from an alert — the copies of one issue, and its collected editions. +struct SeriesPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The issue whose copies are listed, or `nil` for none. + let issue: Int? + + /// Every listing of the series still for sale. + let listings: [Listing] + + /// The series shown. + let series: Series + + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates a series' screen. + /// - Parameters: + /// - series: the series shown. + /// - listings: every listing of the series still for sale. + /// - issue: the issue whose copies are listed, or `nil` (the default) for none. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + series: Series, + listings: [Listing], + issue: Int? = nil, + viewer: Viewer? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.issue = issue + self.listings = listings + self.locale = .init(identifier: "en") + self.series = series + self.viewer = viewer + } + +} + +// MARK: - Page + +extension SeriesPage: Page { + + // MARK: Constants + + /// The most copies compared side by side. + static let maxCompared = 3 + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page series")) { + nav( + .class("breadcrumb"), + .custom(name: "aria-label", value: "Breadcrumb") + ) { + a(.href(.Route.home)) { + "Home" + } + span(.custom(name: "aria-hidden", value: "true")) { + "/" + } + a(.href(.Route.series)) { + "Series" + } + span(.custom(name: "aria-hidden", value: "true")) { + "/" + } + span( + .class("breadcrumb__current"), + .custom(name: "aria-current", value: "page") + ) { + series.name + } + } + section(.class("series__head")) { + if let cover = listings.first(where: { $0.issueNumber != nil }) ?? listings.first { + ListingImage(cover, modifier: "cover--series") + } + div(.class("series__intro")) { + p(.class("type-eyebrow")) { + "Series · \(series.publisher) · \(series.years)\(series.issueCount > 0 ? " · \(series.issueCount) issues" : "")" + } + h1(.class("type-h1")) { + series.name + } + p(.class("type-lead")) { + "\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now." + } + ul(.class("series__keys")) { + ForEach(keys) { key in + li(.class("chip chip--warn")) { + "#\(key.issue) · \(key.notes)" + } + } + } + } + } + if !issues.isEmpty { + section(.class("series__section")) { + div(.class("section-head")) { + h2(.class("type-h2")) { + series.issueCount > 0 ? "All \(series.issueCount) issues" : "Issues listed" + } + span(.class("type-small")) { + "Cheapest listed copy per issue" + } + } + ol(.class("series__issues")) { + ForEach(issues) { entry in + li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) { + span(.class("series__number")) { + "#\(entry.number)" + } + if let cheapest = entry.copies.min(by: { $0.priceCents < $1.priceCents }) { + a(.href(.Route.series(series.slug, issue: entry.number) + "#copies")) { + "\(entry.copies.count) listed · from \(cheapest.priceCents.euros)" + } + .attributes(.custom(name: "aria-current", value: "true"), when: entry.number == issue) + } else { + a(.href(.Route.gated( + .Route.newWant(title: "\(series.name) #\(entry.number)"), + from: .Route.series(series.slug), + viewer: viewer + ))) { + "None listed · alert me" + } + } + } + } + } + } + } + if let issue, !copies.isEmpty { + section( + .class("series__section"), + .id("copies") + ) { + h2(.class("type-h2")) { + "Copies of #\(issue) — \(copies.count) listed" + } + // A plain `GET` form: the copies ticked become the comparison's query. + form( + .class("series__copies"), + .action(.Route.compare), + .method(.get) + ) { + div(.class("table-scroll")) { + table(.class("table")) { + thead { + tr { + th { + span(.class("visually-hidden")) { + "Compare" + } + } + ForEach(["Grade", "Printing", "Pages", "Defects", "Price", "Ship to NL", "Shop", ""]) { heading in + th { + heading + } + } + } + } + tbody { + ForEach(copies) { copy in + tr { + td { + input( + .type(.checkbox), + .name("copy"), + .value(copy.slug), + .custom(name: "aria-label", value: "Compare the \(copy.chip.label) from \(copy.shop.name)") + ) + } + td { + Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified) + } + td { + copy.printing + } + td { + copy.pageQuality ?? "—" + } + td { + copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ") + } + td { + strong { + copy.priceCents.euros + } + } + td { + "\(copy.shippingCents.euros) · \(copy.shop.country)" + } + td { + a(.href(copy.shop.path)) { + copy.shop.name + } + } + td { + a( + .class("link-ink"), + .href(copy.path) + ) { + "View" + } + } + } + } + } + } + } + button( + .class("button button--dark"), + .type(.submit) + ) { + "Compare the copies ticked (up to \(Self.maxCompared))" + } + } + } + } + if !collected.isEmpty { + section(.class("series__section")) { + h2(.class("type-h2")) { + "Collected editions" + } + div(.class("card-grid")) { + ForEach(collected) { listing in + ListingCard(listing) + } + } + } + } + } + } + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.collecting] + } + + var summary: String? { + "\(series.name), \(series.publisher), \(series.years): every issue, the copies listed of each, and the gaps." + } + + var title: String { + "\(series.name) — CCN" + } + +} + +// MARK: - Helpers + +private extension SeriesPage { + + /// The series' manga, trade paperbacks, and hardcovers. + var collected: [Listing] { + listings.filter { $0.issueNumber == nil } + } + + /// The copies of the issue picked, cheapest delivered first. + var copies: [Listing] { + listings + .filter { $0.issueNumber == issue } + .sorted { $0.priceCents + $0.shippingCents < $1.priceCents + $1.shippingCents } + } + + /// Every issue of the run with the copies listed of it; for a series without a known run, the issues listed alone. + var issues: [(number: Int, copies: [Listing])] { + let listed = Dictionary(grouping: listings.filter { $0.issueNumber != nil }) { $0.issueNumber ?? 0 } + let numbers = series.issueCount > 0 ? Array(1...series.issueCount) : listed.keys.sorted() + + return numbers.map { ($0, listed[$0] ?? []) } + } + + /// The key issues, as the listings of them say. + var keys: [(issue: Int, notes: String)] { + var seen = Set() + + return listings + .compactMap { listing in + guard let issue = listing.issueNumber, let notes = listing.keyNotes, seen.insert(issue).inserted else { + return nil + } + + return (issue, notes) + } + .sorted { $0.issue < $1.issue } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift index d5cb4b5..c45a0ba 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift @@ -154,11 +154,6 @@ 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 new file mode 100644 index 0000000..9ed6672 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/WantsPage.swift @@ -0,0 +1,289 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// The want list: what the viewer is looking for, the listings that meet each want, and the form adding one. +struct WantsPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The values the add-a-want form is filled with, or `nil` to show the list alone. + let draft: Draft? + + /// The signed-in viewer. + let viewer: Viewer? + + /// The viewer's wants, the newest first. + let wants: [Want] + + // MARK: Initializers + + /// Creates the want list. + /// - Parameters: + /// - wants: the viewer's wants, the newest first. + /// - draft: the values the add-a-want form is filled with, or `nil` (the default) to show the list alone. + /// - viewer: the signed-in viewer. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + wants: [Want], + draft: Draft? = nil, + viewer: Viewer? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.draft = draft + self.locale = .init(identifier: "en") + self.viewer = viewer + self.wants = wants + } + +} + +// MARK: - Structs + +extension WantsPage { + /// What the add-a-want form is filled with: a listing saved, a gap in a series, or nothing yet. + struct Draft: Sendable, Equatable { + var title = "" + var minGrade: Double? + var maxPriceCents: Int? + } +} + +// MARK: - Page + +extension WantsPage: Page { + + // MARK: Computed + + var content: some HTML { + SiteLayout(viewer: viewer) { + div(.class("page wants")) { + div(.class("section-head")) { + h1(.class("type-h1")) { + "Wants" + } + span(.class("type-small")) { + "\(wants.count) \(wants.count == 1 ? "want" : "wants") · \(wants.reduce(0) { $0 + $1.newMatchCount }) new matches" + } + } + if let draft { + add(draft) + } + if wants.isEmpty, draft == nil { + div(.class("empty")) { + h2(.class("type-panel")) { + "Nothing on your list yet" + } + p(.class("type-body-sm")) { + "Add the issue you're missing, and CCN checks it against every listing published." + } + } + } + ul(.class("wants__list")) { + ForEach(wants) { want in + li(.class("card wants__want")) { + div(.class("wants__top")) { + a( + .class("wants__title"), + .href(Self.search(for: want)) + ) { + want.title + } + if want.newMatchCount > 0 { + span(.class("wants__new")) { + "\(want.newMatchCount) new" + } + } else { + span(.class("type-small")) { + want.matches.isEmpty ? "no matches yet" : "\(want.matches.count) listed" + } + } + } + p(.class("type-small")) { + Self.limits(of: want) + } + if let cheapest = want.matches.min(by: { $0.priceCents < $1.priceCents }) { + a( + .class("wants__match"), + .href(cheapest.path) + ) { + "From \(cheapest.priceCents.euros) · \(cheapest.chip.label) · \(cheapest.shop.name)" + } + } + div(.class("wants__actions")) { + form( + .action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/alerts"), + .method(.post) + ) { + button( + .class("switch"), + .type(.submit), + .custom(name: "role", value: "switch"), + .custom(name: "aria-checked", value: want.alerts ? "true" : "false") + ) { + span(.class("switch__track")) {} + "Alert" + } + } + form( + .action("\(String.Route.wants)/\(want.id.uuidString.lowercased())/remove"), + .method(.post) + ) { + button( + .class("button button--bare button--sm"), + .type(.submit) + ) { + "Remove" + } + } + } + } + } + } + if draft == nil { + a( + .class("wants__add"), + .href(.Route.newWant()) + ) { + "+ Add a want" + } + } + p(.class("type-small wants__note")) { + "Matches are checked every time a listing is published, among copies shipping from the EU. The bell in the header counts the new ones." + } + } + } + } + + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.trade] + } + + var title: String { + "Want list — CCN" + } + +} + +// MARK: - Helpers + +private extension WantsPage { + + /// The form adding a want. + func add( + _ draft: Draft + ) -> some HTML { + form( + .class("card wants__form"), + .action(.Route.wants), + .method(.post) + ) { + h2(.class("type-panel")) { + "Add a want" + } + div(.class("listing-form__grid")) { + TextField( + name: "title", + title: "Series and issue", + value: draft.title, + placeholder: "Nightward #7", + hint: "Every word has to appear in a listing's title", + isWide: true, + maxLength: 120 + ) + SelectField( + name: "min-grade", + title: "Lowest grade", + options: [("", "Any grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") }, + selected: draft.minGrade?.gradeLabel ?? "" + ) + TextField( + name: "max-price", + title: "Most you'd pay (EUR)", + value: draft.maxPriceCents.map { "\($0 / 100)" } ?? "", + placeholder: "40" + ) + label(.class("check")) { + input( + .type(.checkbox), + .name("slabbed"), + .value("1") + ) + "Slabbed copies only" + } + } + div(.class("listing-form__submit")) { + a( + .class("button"), + .href(.Route.wants) + ) { + "Cancel" + } + button( + .class("button button--primary"), + .type(.submit) + ) { + "Add to want list" + } + } + } + } + + /// A want's limits in a line, e.g. `grade ≥ 8.0 · max €40 · ships from EU only`. + static func limits( + of want: Want + ) -> String { + [ + want.slabbedOnly ? "slabbed only" : nil, + want.minGrade.map { "grade ≥ \($0.gradeLabel)" }, + want.maxPriceCents.map { "max \($0.euros)" }, + "ships from EU only", + ] + .compactMap(\.self) + .joined(separator: " · ") + } + + /// The search showing a want's matches. + static func search( + for want: Want + ) -> String { + var parameters = SearchParameters().setting(.query, to: want.title.replacingOccurrences(of: "·", with: " ")) + + if let minGrade = want.minGrade { + parameters = parameters.setting(.gradeMin, to: minGrade.gradeLabel) + } + + if let maxPriceCents = want.maxPriceCents { + parameters = parameters.setting(.priceMax, to: "\(maxPriceCents / 100)") + } + + if want.slabbedOnly { + parameters = parameters.setting(.grading, to: SearchParameters.Grading.slabbed.rawValue) + } + + return parameters.path(.Route.search) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/WelcomePage.swift b/Services/Website/Sources/Library/Internal/Pages/WelcomePage.swift new file mode 100644 index 0000000..641d0fb --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/WelcomePage.swift @@ -0,0 +1,151 @@ +import Elementary +import Foundation +import Infrastructure + +/// The first sign-in: shown once, to a new account, for the one thing sign-up never asked — a display name. Drawn bare, like the sign-in. +struct WelcomePage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// The name the field is filled with. + let displayName: String + + /// What is wrong with the name, or `nil` when nothing is. + let problem: String? + + /// The path the visitor continues to. + let returnPath: String + + // MARK: Initializers + + /// Creates the first-sign-in screen. + /// - Parameters: + /// - displayName: the name the field is filled with. + /// - returnPath: the path the visitor continues to. + /// - problem: what is wrong with the name, or `nil` (the default) when nothing is. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + displayName: String, + returnPath: String, + problem: String? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.displayName = displayName + self.locale = .init(identifier: "en") + self.problem = problem + self.returnPath = returnPath + } + +} + +// MARK: - Page + +extension WelcomePage: Page { + + // MARK: Computed + + var content: some HTML { + div(.class("gate")) { + header(.class("gate__bar")) { + span(.class("gate__wordmark")) { + "CCN" + } + } + main(.class("gate__main")) { + div(.class("gate__intro")) { + p(.class("type-eyebrow")) { + "Shown once, on first sign-in" + } + h1(.class("type-h1")) { + "You're in." + } + p(.class("gate__lead")) { + "One thing before sellers and buyers see you." + } + } + form( + .class("gate__form"), + .action(.Route.welcome), + .method(.post) + ) { + input( + .type(.hidden), + .name("return"), + .value(returnPath) + ) + div(.class("field")) { + label( + .class("field__label"), + .for("name") + ) { + "Display name" + } + input( + .class("input input--lg"), + .id("name"), + .type(.text), + .name("name"), + .value(displayName), + .required, + .placeholder("How sellers and buyers will see you"), + .custom(name: "autocomplete", value: "nickname"), + .custom(name: "maxlength", value: "40") + ) + FieldNote( + name: "name", + hint: "You can open a shop later from your account — no need to decide now.", + problem: problem + ) + } + div(.class("gate__nudge")) { + Icon(.key, size: 20) + div { + p(.class("gate__nudge-title")) { + "Keep a second way in" + } + p { + "A passkey lives on the device that made it. Add a backup from another device on your account page, so a lost phone doesn't lock you out of open orders." + } + } + } + button( + .class("button button--primary button--xl button--block"), + .type(.submit) + ) { + "Continue" + } + } + } + } + } + + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.signIn] + } + + var title: String { + "Welcome — CCN" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Types/ShopForm.swift b/Services/Website/Sources/Library/Internal/Types/ShopForm.swift new file mode 100644 index 0000000..37b7033 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Types/ShopForm.swift @@ -0,0 +1,158 @@ +import Foundation +import Persistence + +/// The open-a-shop form as posted: the raw values, kept to fill the form back in, and what is wrong with them. +struct ShopForm: Sendable, Equatable { + + // MARK: Properties + + /// The values posted, by field name. + let values: [String: String] + + // MARK: Initializers + + /// Creates the form from the values posted; an empty form by default, with the most common choices made. + init( + values: [String: String] = [ + Field.country.rawValue: "NL", + Field.kind.rawValue: Shop.Kind.privateSeller.rawValue, + Field.returns.rawValue: ShopForm.returns[0], + Field.handling.rawValue: "2", + ] + ) { + self.values = values + } + +} + +// MARK: - Enumerations + +extension ShopForm { + /// A field of the form. + enum Field: String, CaseIterable, Sendable { + case about + case city + case country + case declaration + case handle + case handling + case kind + case kvk + case legalName = "legal-name" + case name + case returns + case vat + } +} + +// MARK: - Extensions + +extension ShopForm { + + // MARK: Constants + + /// The countries a shop ships from, by ISO code: the EU's nearest markets, and the UK. + static let countries = [("NL", "Netherlands"), ("BE", "Belgium"), ("DE", "Germany"), ("FR", "France"), ("GB", "United Kingdom")] + + /// The handling times offered, in business days. + static let handlingDays = [1, 2, 5] + + /// The returns policies offered. + static let returns = ["14-day returns", "14-day returns, if not as described", "Returns by agreement"] + + // MARK: Methods + + /// A field's value, trimmed, or `nil` when it was left blank. + func value( + _ field: Field + ) -> String? { + values[field.rawValue] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : $0 } + } + + /// Checks the form. + /// - Returns: the draft the form describes, or what is wrong, by field. + func validated() -> Result { + var problems = [Field: String]() + let name = value(.name) ?? "" + let handle = value(.handle)?.lowercased() ?? "" + let kind = value(.kind).flatMap(Shop.Kind.init) + + if !(3...60).contains(name.count) { + problems[.name] = "Enter a shop name of 3 to 60 characters." + } + + if handle.wholeMatch(of: /[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])/) == nil { + problems[.handle] = "Use 3 to 40 lowercase letters, digits and hyphens." + } + + if value(.city) == nil { + problems[.city] = "Enter the city you ship from." + } + + if !Self.countries.contains(where: { $0.0 == value(.country) }) { + problems[.country] = "Pick the country you ship from." + } + + if (value(.about) ?? "").count > 600 { + problems[.about] = "Keep the description under 600 characters." + } + + if kind == nil { + problems[.kind] = "Say whether you sell privately or as a business." + } + + // Dutch consumer law has a business seller show who they are. + if kind == .business { + if value(.kvk)?.wholeMatch(of: /\d{8}/) == nil { + problems[.kvk] = "Enter your 8-digit KvK number." + } + + if value(.legalName) == nil { + problems[.legalName] = "Enter the name your business is registered under." + } + } + + if !Self.returns.contains(value(.returns) ?? "") { + problems[.returns] = "Pick a returns policy." + } + + if !Self.handlingDays.contains(value(.handling).flatMap(Int.init) ?? 0) { + problems[.handling] = "Pick a handling time." + } + + if value(.declaration) == nil { + problems[.declaration] = "Every seller agrees to declare defects and photograph the actual copy." + } + + guard problems.isEmpty, let kind else { + return .failure(.init(fields: problems)) + } + + return .success(.init( + name: name, + handle: handle, + city: value(.city) ?? "", + country: value(.country) ?? "", + about: value(.about) ?? "", + kind: kind, + kvkNumber: value(.kvk), + vatID: value(.vat), + legalName: value(.legalName), + returnsNote: value(.returns) ?? "", + handlingDays: value(.handling).flatMap(Int.init) ?? 2 + )) + } + +} + +// MARK: - Structs + +extension ShopForm { + /// What is wrong with a form, by field. + struct Problems: Error, Equatable, Sendable { + /// The problem with each field that has one. + var fields: [Field: String] + } +} diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 250a72c..8d2868e 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -50,6 +50,7 @@ extension StaticFile { /// Spelled out because Swift cannot enumerate a type's own constants: a file missing here is still served, but goes untested. static let all: [Self] = [ .appleTouchIcon, + .collecting, .favicon, .fontAnton, .fontArchivo, @@ -111,6 +112,9 @@ extension StaticFile { /// The `apple-touch-icon.png` icon. static let appleTouchIcon = Self("apple-touch-icon", as: .png) + /// The `css/collecting.css` stylesheet shared by the series screens and the comparison. + static let collecting = Self("collecting", as: .css) + /// The `favicon.ico` icon. static let favicon = Self("favicon", as: .ico) diff --git a/Services/Website/Sources/Library/Internal/Types/Viewer.swift b/Services/Website/Sources/Library/Internal/Types/Viewer.swift index 77b2405..8a960cf 100644 --- a/Services/Website/Sources/Library/Internal/Types/Viewer.swift +++ b/Services/Website/Sources/Library/Internal/Types/Viewer.swift @@ -49,15 +49,18 @@ extension Viewer { /// - Parameters: /// - user: the signed-in account. /// - shopSlug: the slug of the shop the account owns, or `nil` (the default) when it owns none. + /// - newWantMatches: the number of new listings matching the account's want list; none by default. /// - unreadMessages: the number of conversations holding something the account has not seen; none by default. init( _ user: User, shopSlug: String? = nil, + newWantMatches: Int = 0, unreadMessages: Int = 0 ) { self.init( displayName: user.displayName, shopSlug: shopSlug, + newWantMatches: newWantMatches, unreadMessages: unreadMessages ) } diff --git a/Services/Website/Sources/Library/Public/Controllers/AccountController.swift b/Services/Website/Sources/Library/Public/Controllers/AccountController.swift new file mode 100644 index 0000000..620afdd --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/AccountController.swift @@ -0,0 +1,332 @@ +import Hummingbird +import HummingbirdElementary +import Infrastructure +import Persistence + +/// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop. +/// +/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. +public struct AccountController { + + // MARK: Properties + + /// The repository accounts and their passkeys are read and changed through. + private let accounts: AccountRepository + + private let analytics: Analytics? + private let assetVersion: String? + + /// The repository shops are opened through. + private let catalogue: CatalogueRepository + + /// The store the signed-in account is read from. + private let sessions: SessionStore + + // MARK: Initializers + + /// Creates an account controller. + /// - Parameters: + /// - accounts: the repository accounts and their passkeys are read and changed through. + /// - catalogue: the repository shops are opened through. + /// - sessions: the store the signed-in account is read from. + /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. + public init( + accounts: AccountRepository, + catalogue: CatalogueRepository, + sessions: SessionStore, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.accounts = accounts + self.analytics = analytics + self.assetVersion = assetVersion + self.catalogue = catalogue + self.sessions = sessions + } + +} + +// MARK: - RouterController + +extension AccountController: RouterController { + + // MARK: Properties + + public var routes: RouteCollection { + let routes = RouteCollection(context: Context.self) + + routes.get(.init(.Route.welcome), use: welcome) + routes.post(.init(.Route.welcome), use: introduce) + routes.get(.init(.Route.account), use: account) + routes.post(.init("\(String.Route.account)/name"), use: rename) + routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke) + routes.get(.init(.Route.openShop), use: shopForm) + routes.post(.init(.Route.openShop), use: openShop) + + return routes + } + +} + +// MARK: - Account + +private extension AccountController { + + /// Handles a request for the account screen. + @Sendable + func account( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found) + } + + return try await accountPage(user, request: request, context: context) + } + + /// Renders the account screen — with a `422` when it reports a problem. + func accountPage( + _ user: User, + problem: String? = nil, + request: Request, + context: Context + ) async throws -> Response { + let passkeys = try await accounts.passkeys(of: user) + let viewer = await sessions.viewer(for: request) + + return HTMLResponse( + status: problem == nil ? .ok : .unprocessableContent, + additionalHeaders: .perViewer + ) { [analytics, assetVersion] in + AccountPage( + user: user, + passkeys: passkeys, + problem: problem, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + .response(from: request, context: context) + } + + /// Handles a new display name from the account screen. + @Sendable + func rename( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal) + } + + let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context) + + guard let name = Self.displayName(form.name) else { + return try await accountPage(user, problem: Self.nameProblem, request: request, context: context) + } + + await sessions.update(try await accounts.rename(user, to: name)) + + return .redirect(to: .Route.account, type: .normal) + } + + /// Handles the revoking of one of the account's own passkeys. + @Sendable + func revoke( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal) + } + + let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context) + + try await accounts.revokePasskey(form.credential, of: user) + + return .redirect(to: "\(String.Route.account)#passkeys", type: .normal) + } + +} + +// MARK: - First sign-in + +private extension AccountController { + + /// Handles the display name picked on first sign-in, and leads on to what the visitor came for. + @Sendable + func introduce( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn, type: .normal) + } + + let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context) + let returnPath = String.Route.safeReturn(form.return) + + guard let name = Self.displayName(form.name) else { + return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context) + } + + await sessions.update(try await accounts.rename(user, to: name)) + + return .redirect(to: returnPath, type: .normal) + } + + /// Handles a request for the first-sign-in screen. + @Sendable + func welcome( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn, type: .found) + } + + return welcomePage( + name: user.displayName, + returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")), + request: request, + context: context + ) + } + + /// Renders the first-sign-in screen — with a `422` when it reports a problem. + func welcomePage( + name: String, + returnPath: String, + problem: String? = nil, + request: Request, + context: Context + ) -> Response { + HTMLResponse( + status: problem == nil ? .ok : .unprocessableContent, + additionalHeaders: [.cacheControl: "no-store"] + ) { [analytics, assetVersion] in + WelcomePage( + displayName: name, + returnPath: returnPath, + problem: problem, + assetVersion: assetVersion, + analytics: analytics + ) + } + .response(from: request, context: context) + } + +} + +// MARK: - Opening a shop + +private extension AccountController { + + /// Handles the open-a-shop form posted: opens the shop, and leads to its first listing. + @Sendable + func openShop( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal) + } + + let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context) + let form = ShopForm(values: values) + + switch form.validated() { + case .failure(let problems): + return await shopPage(form, problems: problems.fields, request: request, context: context) + case .success(let draft): + do { + _ = try await catalogue.openShop(draft, for: user) + } catch ShopError.handleTaken { + return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context) + } catch ShopError.alreadyOwner { + return .redirect(to: .Route.sellerDashboard, type: .normal) + } + + return .redirect(to: NewListingPage.path, type: .normal) + } + } + + /// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard. + @Sendable + func shopForm( + request: Request, + context: Context + ) async throws -> Response { + guard await sessions.user(for: request) != nil else { + return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found) + } + + guard await sessions.viewer(for: request)?.hasShop != true else { + return .redirect(to: .Route.sellerDashboard, type: .found) + } + + return await shopPage(.init(), request: request, context: context) + } + + /// Renders the open-a-shop form — with a `422` when it reports problems. + func shopPage( + _ form: ShopForm, + problems: [ShopForm.Field: String] = [:], + request: Request, + context: Context + ) async -> Response { + let viewer = await sessions.viewer(for: request) + + return HTMLResponse( + status: problems.isEmpty ? .ok : .unprocessableContent, + additionalHeaders: .perViewer + ) { [analytics, assetVersion] in + OpenShopPage( + form: form, + problems: problems, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + .response(from: request, context: context) + } + +} + +// MARK: - Helpers + +private extension AccountController { + + // MARK: Constants + + static var nameProblem: String { "Enter a display name of 2 to 40 characters." } + + // MARK: Methods + + /// A display name as posted, trimmed; or `nil` when it is too short or too long. + static func displayName( + _ name: String + ) -> String? { + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + + return (2...40).contains(name.count) ? name : nil + } + +} + +// MARK: - Structs + +/// The fields a display name form posts. +private struct NameForm: Decodable { + let name: String + let `return`: String? +} + +/// The field a passkey's revoke form posts. +private struct RevokeForm: Decodable { + let credential: String +} diff --git a/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift b/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift new file mode 100644 index 0000000..b62d628 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift @@ -0,0 +1,295 @@ +import Foundation +import Hummingbird +import HummingbirdElementary +import Infrastructure +import Persistence + +/// Serves what a collector uses: the want list, for a signed-in account, and the public series screens and comparison. +/// +/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. +public struct CollectingController { + + // MARK: Properties + + private let analytics: Analytics? + private let assetVersion: String? + + /// The repository listings are read through. + private let catalogue: CatalogueRepository + + /// The repository wants and series are read and written through. + private let collecting: CollectingRepository + + /// The store the signed-in account is read from. + private let sessions: SessionStore + + // MARK: Initializers + + /// Creates a collecting controller. + /// - Parameters: + /// - collecting: the repository wants and series are read and written through. + /// - catalogue: the repository listings are read through. + /// - sessions: the store the signed-in account is read from. + /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. + public init( + collecting: CollectingRepository, + catalogue: CatalogueRepository, + sessions: SessionStore, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.catalogue = catalogue + self.collecting = collecting + self.sessions = sessions + } + +} + +// MARK: - RouterController + +extension CollectingController: RouterController { + + // MARK: Properties + + public var routes: RouteCollection { + let routes = RouteCollection(context: Context.self) + + routes.get(.init(.Route.wants), use: wants) + routes.post(.init(.Route.wants), use: addWant) + routes.get(.init("\(String.Route.wants)/new"), use: newWant) + routes.post(.init("\(String.Route.wants)/:id/alerts"), use: toggleAlerts) + routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant) + routes.get(.init(.Route.series), use: seriesIndex) + routes.get(.init("\(String.Route.series)/:slug"), use: series) + routes.get(.init(.Route.compare), use: compare) + + return routes + } + +} + +// MARK: - Wants + +private extension CollectingController { + + /// Handles a want posted from the form. + @Sendable + func addWant( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) + } + + let form = try await URLEncodedFormDecoder().decode(WantForm.self, from: request, context: context) + + try await collecting.addWant( + title: form.title, + minGrade: form.minGrade.flatMap(Double.init), + maxPriceCents: form.maxPrice.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }.flatMap { $0 > 0 ? $0 * 100 : nil }, + slabbedOnly: form.slabbed == "1", + for: user + ) + + return .redirect(to: .Route.wants, type: .normal) + } + + /// Handles a request for the add-a-want form, filled from a listing being saved, or with a title — a gap in a series. + @Sendable + func newWant( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found) + } + + var draft = WantsPage.Draft(title: request.uri.queryParameters.get("title") ?? "") + + if + let slug = request.uri.queryParameters.get("listing"), + let listing = try await catalogue.listing(slug: slug) + { + draft = .init(title: listing.title, minGrade: listing.grade, maxPriceCents: listing.priceCents) + } + + return try await wantsPage(user, draft: draft, request: request, context: context) + } + + /// Handles the removal of one of the account's wants. + @Sendable + func removeWant( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) + } + + if let id = context.parameters.get("id", as: UUID.self) { + try await collecting.removeWant(id, of: user) + } + + return .redirect(to: .Route.wants, type: .normal) + } + + /// Handles the switching of a want's alerts. + @Sendable + func toggleAlerts( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) + } + + if let id = context.parameters.get("id", as: UUID.self) { + try await collecting.toggleAlerts(ofWant: id, of: user) + } + + return .redirect(to: .Route.wants, type: .normal) + } + + /// Handles a request for the want list. Looking at it is what makes its matches no longer new. + @Sendable + func wants( + request: Request, + context: Context + ) async throws -> Response { + guard let user = await sessions.user(for: request) else { + return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .found) + } + + return try await wantsPage(user, draft: nil, request: request, context: context) + } + + /// Renders the want list with its matches still marked new, then marks them seen — so the header's bell is quiet on the next page. + func wantsPage( + _ user: User, + draft: WantsPage.Draft?, + request: Request, + context: Context + ) async throws -> Response { + let wants = try await collecting.wants(of: user) + + try await collecting.markWantsSeen(by: user) + + let viewer = await sessions.viewer(for: request) + + return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in + WantsPage( + wants: wants, + draft: draft, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + .response(from: request, context: context) + } + +} + +// MARK: - Series + +private extension CollectingController { + + /// Handles a request for the comparison of the copies named in the query, up to three. + @Sendable + func compare( + request: Request, + context: Context + ) async throws -> HTMLResponse { + var copies = [Listing]() + + for slug in request.uri.queryParameters.getAll("copy").prefix(SeriesPage.maxCompared) { + if let copy = try await catalogue.listing(slug: slug), copy.isAvailable { + copies.append(copy) + } + } + + let viewer = await sessions.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion, copies] in + ComparePage( + copies: copies, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + } + + /// Handles a request for a series; an unknown slug is not found. + @Sendable + func series( + request: Request, + context: Context + ) async throws -> HTMLResponse { + let slug = try context.parameters.require("slug") + + guard let series = try await collecting.series(slug: slug) else { + throw HTTPError(.notFound) + } + + // Every copy of the series, wherever it ships from: a series screen is the whole market for it. + let listings = try await catalogue.listings().filter { $0.seriesSlug == slug } + let issue = request.uri.queryParameters.get("issue", as: Int.self) + let viewer = await sessions.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in + SeriesPage( + series: series, + listings: listings, + issue: issue, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + } + + /// Handles a request for the series index. + @Sendable + func seriesIndex( + request: Request, + context: Context + ) async throws -> HTMLResponse { + let listings = try await catalogue.listings() + let entries = try await collecting.allSeries().map { series in + (series, listings.filter { $0.seriesSlug == series.slug }) + } + let viewer = await sessions.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in + SeriesIndexPage( + entries: entries, + viewer: viewer, + assetVersion: assetVersion, + analytics: analytics + ) + } + } + +} + +// MARK: - Structs + +/// The fields the add-a-want form posts. +private struct WantForm: Decodable { + let title: String + let minGrade: String? + let maxPrice: String? + let slabbed: String? + + enum CodingKeys: String, CodingKey { + case title + case minGrade = "min-grade" + case maxPrice = "max-price" + case slabbed + } +} diff --git a/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift b/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift index fa2311a..803ebb5 100644 --- a/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift @@ -21,6 +21,9 @@ public struct ListingFormController { /// The repository listings are published through. private let catalogue: CatalogueRepository + /// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up. + private let onCatalogueChange: @Sendable () async -> Void + /// The store the photos are kept in. private let photos: PhotoStore @@ -40,15 +43,18 @@ public struct ListingFormController { /// - photos: the store the photos are kept in. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it. + /// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default. public init( catalogue: CatalogueRepository, trade: TradeRepository, sessions: SessionStore, photos: PhotoStore, assetVersion: String? = nil, - analytics: Analytics? = nil + analytics: Analytics? = nil, + onCatalogueChange: @escaping @Sendable () async -> Void = {} ) { self.analytics = analytics + self.onCatalogueChange = onCatalogueChange self.assetVersion = assetVersion self.catalogue = catalogue self.photos = photos @@ -151,6 +157,8 @@ private extension ListingFormController { throw HTTPError(.internalServerError) } + await onCatalogueChange() + return .redirect(to: listing.path, type: .normal) } diff --git a/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift index 968705a..3688129 100644 --- a/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift @@ -8,8 +8,8 @@ import WebAuthn /// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`. /// /// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a -/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has -/// no other proof of who is asking, so that would hand the account over. +/// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's +/// own session: signed out there is no proof of who is asking, so it would hand the account over. /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. public struct PasskeyController { @@ -134,7 +134,35 @@ private extension PasskeyController { user = joined - return try await signedIn(user, returningTo: answer.return) + // A new account picks its display name before going on. + return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return))) + case .addition(let userID): + guard let owner = await sessions.user(for: request), owner.id == userID else { + return Self.refusal(.unauthorized, "Sign in again to add a passkey.") + } + + let answer = try JSONDecoder().decode(Answer.self, from: body) + let credential = try await manager.finishRegistration( + challenge: ceremony.challenge, + credentialCreationData: answer.credential, + confirmCredentialIDNotRegisteredYet: { [accounts] in + try await accounts.isUnregistered(credentialID: Self.base64URL($0)) + } + ) + + try await accounts.addPasskey( + to: owner, + credentialID: Self.base64URL(credential.id), + publicKey: credential.publicKey, + signCount: credential.signCount + ) + + // The bare path: from the account screen, one ending in a fragment would not reload it. + var response = Self.json(.ok, #"{"location":"\#(String.Route.account)"}"#) + + response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure)) + + return response case .authentication: let answer = try JSONDecoder().decode(Answer.self, from: body) let credentialID = Self.base64URL(answer.credential.id.asString()) @@ -167,8 +195,23 @@ private extension PasskeyController { request: Request, context: Context ) async throws -> Response { - let typed = (try? await request.decode(as: Start.self, context: context))?.email? - .trimmingCharacters(in: .whitespaces) ?? "" + let start = try? await request.decode(as: Start.self, context: context) + let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? "" + + // From the account screen: another passkey for the account signed in. + if start?.add == true { + guard let owner = await sessions.user(for: request) else { + return Self.refusal(.unauthorized, "Sign in again to add a passkey.") + } + + let options = manager.beginRegistration(user: .init( + id: Array(owner.id.uuidString.utf8), + name: owner.email, + displayName: owner.displayName + )) + + return try await begin(.addition(userID: owner.id), options: options, challenge: options.challenge, mode: "create") + } // No email: whichever passkey the browser holds for this site. guard !typed.isEmpty else { @@ -299,6 +342,7 @@ private extension PasskeyController { /// What the script posts to start a ceremony. private struct Start: Decodable { let email: String? + let add: Bool? } /// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards. diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 56241ab..379d184 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -19,17 +19,14 @@ public struct RootController { // MARK: Properties - /// What a page rendered per request is built from, the same as the cached ones. - private let page: @Sendable (Locale, Viewer?) -> IndexPage - - /// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page. - private let sessions: SessionStore? + /// The landing pages. Whatever changes the catalogue refreshes them through it. + public let home: HomePages /// Negotiates the language the bare route answers in. private let negotiate: Negotiate - /// The landing page, rendered once per supported language and reused for every request. - private let responses: LocalizedHTMLCollectionResponse + /// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page. + private let sessions: SessionStore? // MARK: Initializers @@ -47,28 +44,14 @@ public struct RootController { listings: HomeListings = .init(), sessions: SessionStore? = nil ) { - let page: @Sendable (Locale, Viewer?) -> IndexPage = { - .init( - locale: $0, - assetVersion: assetVersion, - siteOrigin: siteOrigin, - analytics: analytics, - listings: listings, - viewer: $1 - ) - } - + self.home = .init( + assetVersion: assetVersion, + siteOrigin: siteOrigin, + analytics: analytics, + listings: listings + ) self.negotiate = .init(bundle: .module) - self.page = page self.sessions = sessions - // The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache - // and carry it too. - self.responses = .init( - bundle: .module, - variesOnAcceptLanguage: true - ) { - page($0, nil) - } } } @@ -156,14 +139,19 @@ private extension RootController { context: Context ) async -> Response { guard let viewer = await sessions?.viewer(for: request) else { - return responses.response( - for: language, + return await home.response( + in: language, request: request ) } - return HTMLResponse(additionalHeaders: .perViewer) { [page] in - page(.init(identifier: language), viewer) + let page = await home.page( + in: language, + for: viewer + ) + + return HTMLResponse(additionalHeaders: .perViewer) { + page } .response(from: request, context: context) } diff --git a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift index 69ea216..1e51ea1 100644 --- a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift @@ -194,8 +194,13 @@ private extension SessionController { ) } - let token = await sessions.start(for: try await accounts.account(email: email)) - var response = Response.redirect(to: returnPath, type: .normal) + let account = try await accounts.signIn(email: email) + let token = await sessions.start(for: account.user) + // A new account picks its display name before going on. + var response = Response.redirect( + to: account.isNew ? .Route.welcome(returningTo: returnPath) : returnPath, + type: .normal + ) response.setCookie(SessionStore.cookie(token, isSecure: isSecure)) diff --git a/Services/Website/Sources/Library/Public/Controllers/TradeController.swift b/Services/Website/Sources/Library/Public/Controllers/TradeController.swift index 0648a4c..566194c 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 + /// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up. + private let onCatalogueChange: @Sendable () async -> Void + /// The store the signed-in account is read from. private let sessions: SessionStore @@ -35,14 +38,17 @@ public struct TradeController { /// - sessions: the store the signed-in account is read from. /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. + /// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default. public init( trade: TradeRepository, catalogue: CatalogueRepository, sessions: SessionStore, assetVersion: String? = nil, - analytics: Analytics? = nil + analytics: Analytics? = nil, + onCatalogueChange: @escaping @Sendable () async -> Void = {} ) { self.analytics = analytics + self.onCatalogueChange = onCatalogueChange self.assetVersion = assetVersion self.catalogue = catalogue self.sessions = sessions @@ -95,7 +101,11 @@ private extension TradeController { } do { - return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal) + let number = try await trade.buy(listing: slug, buyer: user) + + await onCatalogueChange() + + return .redirect(to: .Route.sale(number), type: .normal) } catch let error as TradeError { return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context) } @@ -409,6 +419,10 @@ private extension TradeController { let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context) let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user) + if number.flatMap(\.self) != nil { + await onCatalogueChange() + } + return .redirect( to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return), type: .normal diff --git a/Services/Website/Sources/Library/Public/Types/HomePages.swift b/Services/Website/Sources/Library/Public/Types/HomePages.swift new file mode 100644 index 0000000..cb83200 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/HomePages.swift @@ -0,0 +1,107 @@ +import Foundation +import Hummingbird +import Infrastructure + +/// The landing page, rendered once per language and cached — and rendered again whenever the catalogue changes. +/// +/// A sale takes a copy off the page and a new listing puts one on, so whatever changes the catalogue calls ``refresh(with:)``. +public actor HomePages { + + // MARK: Properties + + /// What a page is built from, given its locale, its listings, and its viewer. + private let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage + + /// The listings the pages show in their rows. + private var listings: HomeListings + + /// The pages rendered for signed-out visitors, one per language. + private var responses: LocalizedHTMLCollectionResponse + + // MARK: Initializers + + /// Renders the landing pages. + /// - Parameters: + /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. + /// - siteOrigin: the public origin the pages derive their canonical URL and language alternates from, or `nil` (the default) to omit them. + /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. + /// - listings: the listings the pages show in their rows; none by default. + public init( + assetVersion: String? = nil, + siteOrigin: String? = nil, + analytics: Analytics? = nil, + listings: HomeListings = .init() + ) { + let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage = { + .init( + locale: $0, + assetVersion: assetVersion, + siteOrigin: siteOrigin, + analytics: analytics, + listings: $1, + viewer: $2 + ) + } + + self.listings = listings + self.page = page + self.responses = Self.render(page, listings) + } + + // MARK: Methods + + /// Renders the pages again, around new listings. + public func refresh( + with listings: HomeListings + ) { + self.listings = listings + self.responses = Self.render(page, listings) + } + +} + +// MARK: - Extensions + +extension HomePages { + + // MARK: Methods + + /// The page rendered around a signed-in viewer, with the current listings. + func page( + in language: String, + for viewer: Viewer + ) -> IndexPage { + page(.init(identifier: language), listings, viewer) + } + + /// The cached response for a signed-out visitor. + func response( + in language: String, + request: Request + ) -> Response { + responses.response( + for: language, + request: request + ) + } + +} + +// MARK: - Helpers + +private extension HomePages { + + /// Renders the signed-out pages. The bare route negotiates its language, so they declare `Vary: Accept-Language`. + static func render( + _ page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage, + _ listings: HomeListings + ) -> LocalizedHTMLCollectionResponse { + .init( + bundle: .module, + variesOnAcceptLanguage: true + ) { + page($0, listings, nil) + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift index bbb50f1..2875fc2 100644 --- a/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift +++ b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift @@ -86,6 +86,8 @@ extension PasskeyCeremonies { /// What a ceremony does. enum Kind: Sendable, Equatable { + /// Adds a passkey to the signed-in account of the given identifier. + case addition(userID: UUID) /// Signs in with a passkey registered earlier. case authentication /// Creates the account of the given email, with its first passkey. @@ -109,7 +111,7 @@ extension PasskeyCeremonies { name: cookieName, value: token, maxAge: token.isEmpty ? 0 : Int(lifetime), - path: String.Route.signIn, + path: "\(String.Route.signIn)/passkey", secure: isSecure, httpOnly: true, sameSite: .strict diff --git a/Services/Website/Sources/Library/Public/Types/SessionStore.swift b/Services/Website/Sources/Library/Public/Types/SessionStore.swift index 40cfce2..eff0ca9 100644 --- a/Services/Website/Sources/Library/Public/Types/SessionStore.swift +++ b/Services/Website/Sources/Library/Public/Types/SessionStore.swift @@ -10,6 +10,9 @@ public actor SessionStore { // MARK: Properties + /// The repository a viewer's new want matches are read through, or `nil` to leave them out. + private let collecting: CollectingRepository? + /// The repository a viewer's unread count and shop are read through, or `nil` to leave both out. private let trade: TradeRepository? @@ -19,10 +22,14 @@ public actor SessionStore { // MARK: Initializers /// Creates an empty session store. - /// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out. + /// - Parameters: + /// - trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out. + /// - collecting: the repository a viewer's new want matches are read through, or `nil` (the default) to leave them out. public init( - trade: TradeRepository? = nil + trade: TradeRepository? = nil, + collecting: CollectingRepository? = nil ) { + self.collecting = collecting self.trade = trade } @@ -50,6 +57,15 @@ public actor SessionStore { return token } + /// Replaces an account in every session it holds, after it was renamed. + public func update( + _ user: User + ) { + for (token, held) in users where held.id == user.id { + users[token] = user + } + } + /// The account the session with the given token belongs to, or `nil` when there is no such session. public func user( for token: String @@ -114,6 +130,7 @@ extension SessionStore { return .init( user, shopSlug: (try? await trade?.shop(ownedBy: user))?.slug, + newWantMatches: (try? await collecting?.newMatchCount(for: user)) ?? 0, unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0 ) } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 38503e0..72a0978 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -173,6 +173,38 @@ struct AppTests { } } + @Test + func `landing page to catch up once a copy is sold`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + let sold = #"href="/listing/echo-country-3""# + let before = try await client.execute(uri: "/", method: .get) { response in + #expect(String(buffer: response.body).contains(sold)) + + return try #require(response.headers[.eTag]) + } + let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"] + let cookie = try await client.execute(uri: "/sign-in", method: .post, headers: form, body: ByteBuffer(string: "return=/")) { response in + String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) + } + var signedIn = form + + signedIn[.cookie] = cookie + + try await client.execute(uri: "/listing/echo-country-3/buy", method: .post, headers: signedIn) { response in + #expect(response.status == .seeOther) + } + + try await client.execute(uri: "/", method: .get, headers: [.ifNoneMatch: before]) { response in + // The page changed, so the entity tag a browser holds no longer revalidates. + #expect(response.status == .ok) + #expect(response.headers[.eTag] != before) + #expect(!String(buffer: response.body).contains(sold)) + } + } + } + @Test func `error page to be served for an unknown listing`() async throws { try await app( diff --git a/Services/Website/Tests/Browser/links.py b/Services/Website/Tests/Browser/links.py new file mode 100644 index 0000000..8dcd5b3 --- /dev/null +++ b/Services/Website/Tests/Browser/links.py @@ -0,0 +1,58 @@ +"""Crawls every internal link of a locally running Website — signed out, as a buyer, and as a seller — and reports the ones that fail. + + swift run Website & + python3 Tests/Browser/links.py + +Stdlib only. Exits non-zero when a link answers 4xx or 5xx. +""" +import html, re, sys, urllib.error, urllib.parse, urllib.request + +BASE = "http://localhost:8080" + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + +opener = urllib.request.build_opener(NoRedirect) + +def fetch(path, cookie=None, data=None): + request = urllib.request.Request(BASE + path, data=data, headers={"Cookie": cookie} if cookie else {}) + try: + response = opener.open(request) + except urllib.error.HTTPError as error: + response = error + return response.status, response.headers, response.read().decode("utf-8", "replace") + +def sign_in(email): + _, headers, _ = fetch("/sign-in", data=urllib.parse.urlencode({"email": email, "return": "/"}).encode()) + return headers["Set-Cookie"].split(";")[0] + +def crawl(name, cookie): + seen, queue, broken = set(), ["/"], [] + while queue: + path = queue.pop() + if path in seen: + continue + seen.add(path) + status, headers, body = fetch(path, cookie) + if status >= 400: + broken.append((status, path)) + continue + if 300 <= status < 400: + target = urllib.parse.urlsplit(headers["Location"]) + queue.append(target.path + ("?" + target.query if target.query else "")) + continue + for href in re.findall(r'href="(/[^"#]*)', body): + href = html.unescape(href) + # Assets are covered by the app's own tests; the crawl is for screens. + if not re.match(r"/(css|js|font|uploads)/|/[^/]+\.(ico|svg|png|webmanifest|xml|txt)", href): + queue.append(href) + print(f"{name}: {len(seen)} links, {len(broken)} broken") + for status, path in sorted(broken): + print(f" {status} {path}") + return broken + +broken = crawl("signed out", None) +broken += crawl("buyer", sign_in("")) +broken += crawl("seller", sign_in("joost@kestrelrow.example")) +sys.exit(1 if broken else 0) diff --git a/Services/Website/Tests/Browser/passkeys.py b/Services/Website/Tests/Browser/passkeys.py index 8cff0a5..73fc5f1 100644 --- a/Services/Website/Tests/Browser/passkeys.py +++ b/Services/Website/Tests/Browser/passkeys.py @@ -44,9 +44,12 @@ def check(name, ok, detail=""): # 1. An unknown email registers a passkey and creates the account, then returns to the intent. open_sign_in("/search") press("sam.okafor@example.com") +welcomed = chrome.wait_for("location.pathname === '/welcome'") +check("join: registers a passkey and meets the first-sign-in screen", welcomed, status() if not welcomed else "") +chrome.evaluate("document.querySelector('#name').value = 'Sam Okafor'; document.querySelector('form').submit(); true") landed = chrome.wait_for("location.pathname === '/search'") -check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname")) -check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML")) +check("join: a display name later, lands on the intent", landed, chrome.evaluate("location.pathname")) +check("join: the page is rendered for the new account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML")) held = credentials() check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held") @@ -56,7 +59,7 @@ open_sign_in("/grading-guide") press("") landed = chrome.wait_for("location.pathname === '/grading-guide'") check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "") -check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML")) +check("sign-in: as the same account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML")) check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"]) # 3. By email, the ceremony names the account's credential. @@ -65,12 +68,27 @@ open_sign_in("/") press("Sam.Okafor@Example.com") check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status()) -# 4. A demo account has no passkey, and none can be added to it. +# 3b. Signed in, the account adds a second passkey, then revokes one. +chrome.send("Page.navigate", url=f"{BASE}/account") +assert chrome.wait_for("document.querySelector('[data-passkey=add]') && !document.querySelector('[data-passkey=add]').hidden") +chrome.evaluate("document.querySelector('[data-passkey=add]').click(); true") +added = chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 2") +check("account: adds a second passkey from its own session", added and len(credentials()) == 2, status()) +chrome.evaluate("document.querySelector('.rows__row--passkey form').submit(); true") +check("account: revokes one, at once", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1")) +chrome.send("WebAuthn.clearCredentials", authenticatorId=authenticator) chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)") +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 + +# 4. A demo account has no passkey, and none can be added to it. open_sign_in("/") press("mara.v@example.com") refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')") -check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status()) +check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == held_before, status()) check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in") sys.exit(0 if all(results) else 1) diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift index 83ea130..d5efc22 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift @@ -40,11 +40,11 @@ struct CatalogueComponentsTests { // MARK: ListingCard tests @Test - func `links a card to its listing and gates saving behind the sign-in`() { + func `links a card to its listing, and saving it to a want for it`() { let html = ListingCard(.stub()).render() #expect(html.contains(#"href="/listing/nightward-1">Nightward #1"#)) - #expect(html.contains(#"href="/sign-in?return=/listing/nightward-1""#)) + #expect(html.contains(#"href="/wants/new?listing=nightward-1""#)) #expect(html.contains("€240")) #expect(html.contains("CGC 9.8")) } diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteFooterTests.swift index c5ec1ca..d4921c6 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.recover, .Route.terms] { + for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] { #expect(html.contains(#"href="\#(path)""#)) } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift index 14c0991..5b6448d 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift @@ -35,13 +35,23 @@ struct SiteHeaderTests { #expect(html.contains(#"href="/sell""#)) #expect(html.contains(">MV<")) #expect(html.contains(">Mara<")) - #expect(html.contains(#"action="/sign-out" method="post""#)) + // Signing out lives on the account screen. + #expect(!html.contains("/sign-out")) #expect(!html.contains(#"href="/sign-in""#)) } @Test - func `links the shop of a seller alone`() { - #expect(!SiteHeader(viewer: .init(displayName: "Mara")).render().contains(#"href="/sell""#)) + func `leads a seller to their shop, and anyone else to opening one`() { + let buyer = SiteHeader(viewer: .init(displayName: "Mara")).render() + + #expect(!buyer.contains(#"href="/sell""#)) + #expect(buyer.contains(#"href="/sell/open-a-shop""#)) + #expect(buyer.contains(">Open a shop<")) + } + + @Test + func `counts one new match in the singular`() { + #expect(SiteHeader(viewer: .init(displayName: "Mara", newWantMatches: 1)).render().contains(#"aria-label="Want list, 1 new match""#)) } @Test diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift index b042586..fc7e33c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift @@ -105,7 +105,8 @@ struct CataloguePagesTests { #expect(html.contains(action)) } - #expect(html.components(separatedBy: #"href="/sign-in?return=/listing/nightward-1""#).count - 1 >= 5) + // Buy, offer, message, and the note's own link. Saving goes through the want list, which gates itself. + #expect(html.components(separatedBy: #"href="/sign-in?return=/listing/nightward-1""#).count - 1 == 4) } @Test @@ -134,7 +135,8 @@ struct CataloguePagesTests { #expect(html.contains(#"href="/messages/new?listing=nightward-1""#)) #expect(html.contains(#"aria-label="Account of Mara Visser""#)) #expect(!html.contains("gate-note")) - #expect(html.contains(#"disabled title="Not part of the prototype">Save"#)) + #expect(html.contains(#"href="/wants/new?listing=nightward-1""#)) + #expect(html.contains(#"href="/series/nightward?issue=1#copies""#)) } // MARK: ShopPage tests diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift new file mode 100644 index 0000000..da8831f --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CollectorPagesTests.swift @@ -0,0 +1,200 @@ +import Elementary +import Foundation +import Persistence +import Testing + +@testable import WebsiteLibrary + +@Suite( + "Account and collector pages", + .tags(.page) +) +struct CollectorPagesTests { + + // MARK: Constants + + private static let nightward = Series(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 4, blurb: "A short run.") + + // MARK: OpenShopPage tests + + @Test + func `open-a-shop form starts as a private seller, and gives back what was typed with its problems`() { + let empty = OpenShopPage().render() + let sent = OpenShopPage( + form: ShopFormTests.form(["kind": "business", "about": "Spare "]), + problems: [.kvk: "Enter your 8-digit KvK number."] + ).render() + + #expect(empty.contains(#"action="/sell/open-a-shop" method="post""#)) + #expect(empty.contains(#"name="kind" value="private" checked"#)) + #expect(!empty.contains(#"role="alert""#)) + #expect(sent.contains("One thing to fix before opening.")) + #expect(sent.contains(#"name="kind" value="business" checked"#)) + #expect(sent.contains(#"name="name" value="Ana's Longbox""#) || sent.contains(#"name="name" value="Ana's Longbox""#)) + #expect(sent.contains(#"id="kvk-error">Enter your 8-digit KvK number."#)) + #expect(sent.contains("Spare <copies>")) + #expect(!sent.contains("style=")) + } + + // MARK: WelcomePage and AccountPage tests + + @Test + func `welcome screen is bare, asks for a name, and carries the intent on`() { + let html = WelcomePage(displayName: "ana.devries", returnPath: "/listing/nightward-1").render() + + #expect(html.contains("You're in.") || html.contains("You're in.")) + #expect(html.contains(#"action="/welcome" method="post""#)) + #expect(html.contains(#"name="name" value="ana.devries""#)) + #expect(html.contains(#"name="return" value="/listing/nightward-1""#)) + #expect(html.contains(#"content="noindex""#)) + #expect(!html.contains("site-header")) + } + + @Test + func `account screen lists the passkeys with a way to revoke each, and to add one`() { + let passkey = Passkey(credentialID: "cred-1", publicKey: [], signCount: 0, user: .mara) + let html = AccountPage(user: .mara, passkeys: [(passkey, .stub)], viewer: .init(.mara), now: .stub).render() + let bare = AccountPage(user: .mara, passkeys: [], now: .stub).render() + + #expect(html.contains(#"action="/account/name" method="post""#)) + #expect(html.contains(#"name="name" value="Mara Visser""#)) + #expect(html.contains("Passkey 1")) + #expect(html.contains("created today, 09:14")) + #expect(html.contains(#"name="credential" value="cred-1""#)) + #expect(html.contains(#"hidden data-passkey="add""#)) + #expect(html.contains("/js/passkey.js")) + #expect(html.contains(#"href="/sell/open-a-shop">Open a shop"#)) + #expect(bare.contains("No passkey yet")) + } + + // MARK: WantsPage tests + + @Test + func `want list shows each want's limits, its cheapest match, and what is new`() { + let want = Want( + id: .init(uuidString: "00000000-0000-0000-0000-0000000000EE") ?? .init(), + title: "Nightward #1", minGrade: 8, maxPriceCents: 30_000, slabbedOnly: true, alerts: true, + matches: [.stub(slug: "a", priceCents: 24_000), .stub(slug: "b", priceCents: 21_000)], + newMatchCount: 1 + ) + let html = WantsPage(wants: [want]).render() + + #expect(html.contains("1 want · 1 new matches")) + #expect(html.contains("slabbed only · grade ≥ 8.0 · max €300 · ships from EU only")) + #expect(html.contains(#"href="/listing/b">From €210"#)) + #expect(html.contains(#"class="wants__new">1 new"#)) + #expect(html.contains(#"role="switch" aria-checked="true""#)) + #expect(html.contains(#"action="/wants/00000000-0000-0000-0000-0000000000ee/remove""#)) + #expect(html.contains(#"href="/search?q=Nightward%20%231&grade-min=8.0&price-max=300&grading=slabbed""#)) + #expect(html.contains(#"href="/wants/new""#)) + } + + @Test + func `want list fills its form from what is being saved`() { + let html = WantsPage(wants: [], draft: .init(title: "Nightward #2", minGrade: 9.6, maxPriceCents: 8_500)).render() + + #expect(html.contains(#"name="title" value="Nightward #2""#)) + #expect(html.contains(#"