Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -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 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 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 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)).
|
- 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.
|
- 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)).
|
- 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 /<lang> → its other editions)
|
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
|
||||||
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
|
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
|
||||||
SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
|
SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
|
||||||
|
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)
|
PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON)
|
||||||
ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
|
ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
|
||||||
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
|
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
|
||||||
@@ -69,9 +72,9 @@ The screens follow the **CCN marketplace** design canvas:
|
|||||||
|
|
||||||
### Catalogue
|
### Catalogue
|
||||||
- **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Adding a passkey** happens from the account screen alone: the account's own session is the proof of who is asking.
|
||||||
- **Left out**: the first-sign-in screen (display name, backup passkey), recovery, and managing passkeys from an account screen.
|
- **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).
|
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.
|
- **Demo accounts**: every seeded shop has an owner; sign in as `joost@kestrelrow.example` to sell for Kestrel Row Comics.
|
||||||
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode.
|
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode.
|
||||||
|
|
||||||
|
### 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
|
### 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing__series {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.buy-box {
|
.buy-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1049,20 +1049,6 @@ textarea.input {
|
|||||||
color: #ffffff;
|
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 {
|
.site-header__account {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1854,6 +1840,91 @@ textarea.input {
|
|||||||
color: var(--muted);
|
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 states */
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -69,6 +69,31 @@
|
|||||||
gap: var(--space-7);
|
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 {
|
.gate__demo {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -896,3 +896,173 @@ a.rows__row:hover {
|
|||||||
padding-top: var(--space-8);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 form = document.querySelector("[data-passkey-form]");
|
||||||
const supported = window.PublicKeyCredential
|
const supported = window.PublicKeyCredential
|
||||||
@@ -30,7 +30,10 @@ if (form && supported) {
|
|||||||
status.textContent = "";
|
status.textContent = "";
|
||||||
|
|
||||||
try {
|
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;
|
let credential;
|
||||||
|
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@@ -47,7 +50,7 @@ if (form && supported) {
|
|||||||
|
|
||||||
const { location } = await post("/sign-in/passkey/finish", {
|
const { location } = await post("/sign-in/passkey/finish", {
|
||||||
credential: credential.toJSON(),
|
credential: credential.toJSON(),
|
||||||
return: form.elements.return.value,
|
return: form.querySelector("[name=return]")?.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.assign(location);
|
window.location.assign(location);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Configuration
|
import Configuration
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
import HummingbirdFluent
|
||||||
import Localization
|
import Localization
|
||||||
import Logging
|
import Logging
|
||||||
import Persistence
|
import Persistence
|
||||||
@@ -71,6 +72,7 @@ func application(
|
|||||||
logLevel: reader.logLevel,
|
logLevel: reader.logLevel,
|
||||||
passkeysOrigin: reader.passkeysOrigin,
|
passkeysOrigin: reader.passkeysOrigin,
|
||||||
probe: Probe(fluent: fluent),
|
probe: Probe(fluent: fluent),
|
||||||
|
fluent: fluent,
|
||||||
accounts: AccountRepository(fluent: fluent),
|
accounts: AccountRepository(fluent: fluent),
|
||||||
trade: TradeRepository(fluent: fluent),
|
trade: TradeRepository(fluent: fluent),
|
||||||
repository: repository,
|
repository: repository,
|
||||||
@@ -186,6 +188,7 @@ private func logger(
|
|||||||
/// - logLevel: the level the request-logging middleware logs at.
|
/// - logLevel: the level the request-logging middleware logs at.
|
||||||
/// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to.
|
/// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to.
|
||||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
/// - 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.
|
/// - accounts: the repository the `SessionController` finds and creates accounts through.
|
||||||
/// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales 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.
|
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
|
||||||
@@ -204,6 +207,7 @@ private func router(
|
|||||||
logLevel: Logger.Level,
|
logLevel: Logger.Level,
|
||||||
passkeysOrigin: String,
|
passkeysOrigin: String,
|
||||||
probe: Probe,
|
probe: Probe,
|
||||||
|
fluent: Fluent,
|
||||||
accounts: AccountRepository,
|
accounts: AccountRepository,
|
||||||
trade: TradeRepository,
|
trade: TradeRepository,
|
||||||
repository: CatalogueRepository,
|
repository: CatalogueRepository,
|
||||||
@@ -215,7 +219,26 @@ private func router(
|
|||||||
context: AppRequestContext.self,
|
context: AppRequestContext.self,
|
||||||
options: .autoGenerateHeadEndpoints
|
options: .autoGenerateHeadEndpoints
|
||||||
)
|
)
|
||||||
let sessions = SessionStore(trade: trade)
|
let collecting = CollectingRepository(fluent: fluent)
|
||||||
|
let sessions = SessionStore(
|
||||||
|
trade: trade,
|
||||||
|
collecting: collecting
|
||||||
|
)
|
||||||
|
let root = RootController<AppRequestContext>(
|
||||||
|
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 {
|
router.addMiddleware {
|
||||||
LogRequestsMiddleware(logLevel)
|
LogRequestsMiddleware(logLevel)
|
||||||
@@ -241,13 +264,7 @@ private func router(
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.addController {
|
router.addController {
|
||||||
RootController<AppRequestContext>(
|
root
|
||||||
assetVersion: assetVersion,
|
|
||||||
siteOrigin: siteOrigin,
|
|
||||||
analytics: analytics,
|
|
||||||
listings: listings,
|
|
||||||
sessions: sessions
|
|
||||||
)
|
|
||||||
CatalogueController<AppRequestContext>(
|
CatalogueController<AppRequestContext>(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
@@ -263,6 +280,20 @@ private func router(
|
|||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
)
|
)
|
||||||
|
AccountController<AppRequestContext>(
|
||||||
|
accounts: accounts,
|
||||||
|
catalogue: repository,
|
||||||
|
sessions: sessions,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
CollectingController<AppRequestContext>(
|
||||||
|
collecting: collecting,
|
||||||
|
catalogue: repository,
|
||||||
|
sessions: sessions,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
PasskeyController<AppRequestContext>(
|
PasskeyController<AppRequestContext>(
|
||||||
origin: passkeysOrigin,
|
origin: passkeysOrigin,
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
@@ -274,14 +305,16 @@ private func router(
|
|||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
photos: PhotoStore(staticFilesPath: staticFilesPath),
|
photos: PhotoStore(staticFilesPath: staticFilesPath),
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics,
|
||||||
|
onCatalogueChange: refreshHome
|
||||||
)
|
)
|
||||||
TradeController<AppRequestContext>(
|
TradeController<AppRequestContext>(
|
||||||
trade: trade,
|
trade: trade,
|
||||||
catalogue: repository,
|
catalogue: repository,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics,
|
||||||
|
onCatalogueChange: refreshHome
|
||||||
)
|
)
|
||||||
HealthController<AppRequestContext>(
|
HealthController<AppRequestContext>(
|
||||||
probe: probe
|
probe: probe
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -30,13 +30,13 @@ struct ListingCard: HTML {
|
|||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
article(.class("listing-card")) {
|
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(
|
a(
|
||||||
.class("listing-card__save"),
|
.class("listing-card__save"),
|
||||||
.href(.Route.signIn(returningTo: listing.path)),
|
.href(.Route.newWant(listing: listing.slug)),
|
||||||
.custom(
|
.custom(
|
||||||
name: "aria-label",
|
name: "aria-label",
|
||||||
value: "Save \(listing.title) to watchlist"
|
value: "Add \(listing.title) to your want list"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(.bookmark, size: 18)
|
Icon(.bookmark, size: 18)
|
||||||
|
|||||||
@@ -45,17 +45,6 @@ struct SiteFooter: HTML {
|
|||||||
span {
|
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."
|
"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),
|
("Open a shop", .Route.openShop),
|
||||||
("Seller dashboard", .Route.sellerDashboard),
|
("Seller dashboard", .Route.sellerDashboard),
|
||||||
("Grading guide", .Route.gradingGuide),
|
("Grading guide", .Route.gradingGuide),
|
||||||
("Trade at events", .Route.events),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -96,10 +84,8 @@ private extension SiteFooter {
|
|||||||
links: [
|
links: [
|
||||||
("Sign in or join", .Route.signIn),
|
("Sign in or join", .Route.signIn),
|
||||||
("Want list & alerts", .Route.wants),
|
("Want list & alerts", .Route.wants),
|
||||||
("My collection", .Route.collection),
|
|
||||||
("Messages", .Route.messages),
|
("Messages", .Route.messages),
|
||||||
("Passkeys & settings", .Route.account),
|
("Passkeys & settings", .Route.account),
|
||||||
("Lost your passkey?", .Route.recover),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ private extension SiteHeader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The format links, plus the series and events links shown on tablet only.
|
/// The format links, plus the series link shown on tablet only.
|
||||||
var navigation: some HTML {
|
var navigation: some HTML {
|
||||||
nav(
|
nav(
|
||||||
.class("site-nav"),
|
.class("site-nav"),
|
||||||
@@ -134,12 +134,6 @@ private extension SiteHeader {
|
|||||||
) {
|
) {
|
||||||
"Series"
|
"Series"
|
||||||
}
|
}
|
||||||
a(
|
|
||||||
.class("site-nav__link site-nav__link--extra"),
|
|
||||||
.href(.Route.events)
|
|
||||||
) {
|
|
||||||
"Events"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +187,7 @@ private extension SiteHeader {
|
|||||||
.bell,
|
.bell,
|
||||||
label: "Want list",
|
label: "Want list",
|
||||||
count: viewer.newWantMatches,
|
count: viewer.newWantMatches,
|
||||||
unit: "new matches",
|
unit: viewer.newWantMatches == 1 ? "new match" : "new matches",
|
||||||
path: .Route.wants
|
path: .Route.wants
|
||||||
)
|
)
|
||||||
counter(
|
counter(
|
||||||
@@ -203,13 +197,11 @@ private extension SiteHeader {
|
|||||||
unit: "unread",
|
unit: "unread",
|
||||||
path: .Route.messages
|
path: .Route.messages
|
||||||
)
|
)
|
||||||
if viewer.hasShop {
|
a(
|
||||||
a(
|
.class("site-header__text-link site-header__text-link--strong from-tablet"),
|
||||||
.class("site-header__text-link site-header__text-link--strong from-tablet"),
|
.href(viewer.hasShop ? .Route.sellerDashboard : .Route.openShop)
|
||||||
.href(.Route.sellerDashboard)
|
) {
|
||||||
) {
|
viewer.hasShop ? "My shop" : "Open a shop"
|
||||||
"My shop"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
a(
|
a(
|
||||||
.class("site-header__account"),
|
.class("site-header__account"),
|
||||||
@@ -226,19 +218,6 @@ private extension SiteHeader {
|
|||||||
viewer.initials
|
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.
|
/// An icon link with a count badge, omitted at zero.
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import Foundation
|
|||||||
extension String {
|
extension String {
|
||||||
/// A namespace for the paths of the site's screens.
|
/// 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 {
|
enum Route {
|
||||||
/// The account screen.
|
/// The account screen.
|
||||||
static let account = "/account"
|
static let account = "/account"
|
||||||
/// The collection tracker.
|
/// The side-by-side comparison of copies.
|
||||||
static let collection = "/collection"
|
static let compare = "/compare"
|
||||||
/// The events screen.
|
|
||||||
static let events = "/events"
|
|
||||||
/// The public grading guide.
|
/// The public grading guide.
|
||||||
static let gradingGuide = "/grading-guide"
|
static let gradingGuide = "/grading-guide"
|
||||||
/// The browse / home screen.
|
/// The browse / home screen.
|
||||||
@@ -19,26 +17,20 @@ extension String {
|
|||||||
static let messages = "/messages"
|
static let messages = "/messages"
|
||||||
/// The open-a-shop onboarding.
|
/// The open-a-shop onboarding.
|
||||||
static let openShop = "/sell/open-a-shop"
|
static let openShop = "/sell/open-a-shop"
|
||||||
/// The privacy statement.
|
|
||||||
static let privacy = "/privacy"
|
|
||||||
/// The passkey recovery screen.
|
|
||||||
static let recover = "/recover"
|
|
||||||
/// The search results screen.
|
/// The search results screen.
|
||||||
static let search = "/search"
|
static let search = "/search"
|
||||||
/// The seller dashboard.
|
/// The seller dashboard.
|
||||||
static let sellerDashboard = "/sell"
|
static let sellerDashboard = "/sell"
|
||||||
/// The rules sellers agree to.
|
|
||||||
static let sellerRules = "/seller-rules"
|
|
||||||
/// The series index.
|
/// The series index.
|
||||||
static let series = "/series"
|
static let series = "/series"
|
||||||
/// The passkey sign-in screen.
|
/// The passkey sign-in screen.
|
||||||
static let signIn = "/sign-in"
|
static let signIn = "/sign-in"
|
||||||
/// The path a sign-out is posted to.
|
/// The path a sign-out is posted to.
|
||||||
static let signOut = "/sign-out"
|
static let signOut = "/sign-out"
|
||||||
/// The terms of service.
|
|
||||||
static let terms = "/terms"
|
|
||||||
/// The want list.
|
/// The want list.
|
||||||
static let wants = "/wants"
|
static let wants = "/wants"
|
||||||
|
/// The first-sign-in screen.
|
||||||
|
static let welcome = "/welcome"
|
||||||
|
|
||||||
/// The path an offer is answered at.
|
/// The path an offer is answered at.
|
||||||
/// - Parameter id: the offer's identifier.
|
/// - Parameter id: the offer's identifier.
|
||||||
@@ -117,6 +109,17 @@ extension String {
|
|||||||
"/sales/\(number)"
|
"/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.
|
/// The path of a shop's public screen.
|
||||||
/// - Parameter slug: the shop's URL slug.
|
/// - Parameter slug: the shop's URL slug.
|
||||||
static func shop(
|
static func shop(
|
||||||
@@ -146,6 +149,25 @@ extension String {
|
|||||||
return path
|
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.
|
/// The path of the sign-in screen, returning to the given path afterwards.
|
||||||
/// - Parameter path: the path the visitor's gated action started from.
|
/// - Parameter path: the path the visitor's gated action started from.
|
||||||
static func signIn(
|
static func signIn(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -95,7 +95,13 @@ extension ListingPage: Page {
|
|||||||
listing.title
|
listing.title
|
||||||
}
|
}
|
||||||
p(.class("listing__byline")) {
|
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
|
buyBox
|
||||||
@@ -224,16 +230,12 @@ private extension ListingPage {
|
|||||||
listing.acceptsOffers ? " · accepts offers" : ""
|
listing.acceptsOffers ? " · accepts offers" : ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewer == nil {
|
a(
|
||||||
a(
|
.class("button button--sm"),
|
||||||
.class("button button--sm"),
|
.href(.Route.newWant(listing: listing.slug))
|
||||||
.href(.Route.signIn(returningTo: listing.path))
|
) {
|
||||||
) {
|
Icon(.bookmark, size: 16)
|
||||||
Icon(.bookmark, size: 16)
|
"Save"
|
||||||
"Save"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Unavailable("Save", modifier: "button--sm")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !listing.isAvailable {
|
if !listing.isAvailable {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<Int>()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -154,11 +154,6 @@ extension SignInPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p(.class("gate__recover")) {
|
|
||||||
a(.href(.Route.recover)) {
|
|
||||||
"Lost access to your passkeys?"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<ShopDraft, Problems> {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
/// 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] = [
|
static let all: [Self] = [
|
||||||
.appleTouchIcon,
|
.appleTouchIcon,
|
||||||
|
.collecting,
|
||||||
.favicon,
|
.favicon,
|
||||||
.fontAnton,
|
.fontAnton,
|
||||||
.fontArchivo,
|
.fontArchivo,
|
||||||
@@ -111,6 +112,9 @@ extension StaticFile {
|
|||||||
/// The `apple-touch-icon.png` icon.
|
/// The `apple-touch-icon.png` icon.
|
||||||
static let appleTouchIcon = Self("apple-touch-icon", as: .png)
|
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.
|
/// The `favicon.ico` icon.
|
||||||
static let favicon = Self("favicon", as: .ico)
|
static let favicon = Self("favicon", as: .ico)
|
||||||
|
|
||||||
|
|||||||
@@ -49,15 +49,18 @@ extension Viewer {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - user: the signed-in account.
|
/// - user: the signed-in account.
|
||||||
/// - shopSlug: the slug of the shop the account owns, or `nil` (the default) when it owns none.
|
/// - 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.
|
/// - unreadMessages: the number of conversations holding something the account has not seen; none by default.
|
||||||
init(
|
init(
|
||||||
_ user: User,
|
_ user: User,
|
||||||
shopSlug: String? = nil,
|
shopSlug: String? = nil,
|
||||||
|
newWantMatches: Int = 0,
|
||||||
unreadMessages: Int = 0
|
unreadMessages: Int = 0
|
||||||
) {
|
) {
|
||||||
self.init(
|
self.init(
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
shopSlug: shopSlug,
|
shopSlug: shopSlug,
|
||||||
|
newWantMatches: newWantMatches,
|
||||||
unreadMessages: unreadMessages
|
unreadMessages: unreadMessages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Context: RequestContext> {
|
||||||
|
|
||||||
|
// 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<Context> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Context: RequestContext> {
|
||||||
|
|
||||||
|
// 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<Context> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ public struct ListingFormController<Context: RequestContext> {
|
|||||||
/// The repository listings are published through.
|
/// The repository listings are published through.
|
||||||
private let catalogue: CatalogueRepository
|
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.
|
/// The store the photos are kept in.
|
||||||
private let photos: PhotoStore
|
private let photos: PhotoStore
|
||||||
|
|
||||||
@@ -40,15 +43,18 @@ public struct ListingFormController<Context: RequestContext> {
|
|||||||
/// - photos: the store the photos are kept in.
|
/// - 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.
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||||
|
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||||
public init(
|
public init(
|
||||||
catalogue: CatalogueRepository,
|
catalogue: CatalogueRepository,
|
||||||
trade: TradeRepository,
|
trade: TradeRepository,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
photos: PhotoStore,
|
photos: PhotoStore,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil,
|
||||||
|
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
|
self.onCatalogueChange = onCatalogueChange
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.catalogue = catalogue
|
self.catalogue = catalogue
|
||||||
self.photos = photos
|
self.photos = photos
|
||||||
@@ -151,6 +157,8 @@ private extension ListingFormController {
|
|||||||
throw HTTPError(.internalServerError)
|
throw HTTPError(.internalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onCatalogueChange()
|
||||||
|
|
||||||
return .redirect(to: listing.path, type: .normal)
|
return .redirect(to: listing.path, type: .normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import WebAuthn
|
|||||||
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
|
/// 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
|
/// 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
|
/// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's
|
||||||
/// no other proof of who is asking, so that would hand the account over.
|
/// 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.
|
/// - 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<Context: RequestContext> {
|
public struct PasskeyController<Context: RequestContext> {
|
||||||
@@ -134,7 +134,35 @@ private extension PasskeyController {
|
|||||||
|
|
||||||
user = joined
|
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<RegistrationCredential>.self, from: body)
|
||||||
|
let credential = try await manager.finishRegistration(
|
||||||
|
challenge: ceremony.challenge,
|
||||||
|
credentialCreationData: answer.credential,
|
||||||
|
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||||
|
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
case .authentication:
|
||||||
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
||||||
let credentialID = Self.base64URL(answer.credential.id.asString())
|
let credentialID = Self.base64URL(answer.credential.id.asString())
|
||||||
@@ -167,8 +195,23 @@ private extension PasskeyController {
|
|||||||
request: Request,
|
request: Request,
|
||||||
context: Context
|
context: Context
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
|
let start = try? await request.decode(as: Start.self, context: context)
|
||||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
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.
|
// No email: whichever passkey the browser holds for this site.
|
||||||
guard !typed.isEmpty else {
|
guard !typed.isEmpty else {
|
||||||
@@ -299,6 +342,7 @@ private extension PasskeyController {
|
|||||||
/// What the script posts to start a ceremony.
|
/// What the script posts to start a ceremony.
|
||||||
private struct Start: Decodable {
|
private struct Start: Decodable {
|
||||||
let email: String?
|
let email: String?
|
||||||
|
let add: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||||
|
|||||||
@@ -19,17 +19,14 @@ public struct RootController<Context: RequestContext> {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// What a page rendered per request is built from, the same as the cached ones.
|
/// The landing pages. Whatever changes the catalogue refreshes them through it.
|
||||||
private let page: @Sendable (Locale, Viewer?) -> IndexPage
|
public let home: HomePages
|
||||||
|
|
||||||
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
|
|
||||||
private let sessions: SessionStore?
|
|
||||||
|
|
||||||
/// Negotiates the language the bare route answers in.
|
/// Negotiates the language the bare route answers in.
|
||||||
private let negotiate: Negotiate
|
private let negotiate: Negotiate
|
||||||
|
|
||||||
/// The landing page, rendered once per supported language and reused for every request.
|
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
|
||||||
private let responses: LocalizedHTMLCollectionResponse
|
private let sessions: SessionStore?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
@@ -47,28 +44,14 @@ public struct RootController<Context: RequestContext> {
|
|||||||
listings: HomeListings = .init(),
|
listings: HomeListings = .init(),
|
||||||
sessions: SessionStore? = nil
|
sessions: SessionStore? = nil
|
||||||
) {
|
) {
|
||||||
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
|
self.home = .init(
|
||||||
.init(
|
assetVersion: assetVersion,
|
||||||
locale: $0,
|
siteOrigin: siteOrigin,
|
||||||
assetVersion: assetVersion,
|
analytics: analytics,
|
||||||
siteOrigin: siteOrigin,
|
listings: listings
|
||||||
analytics: analytics,
|
)
|
||||||
listings: listings,
|
|
||||||
viewer: $1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.negotiate = .init(bundle: .module)
|
self.negotiate = .init(bundle: .module)
|
||||||
self.page = page
|
|
||||||
self.sessions = sessions
|
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
|
context: Context
|
||||||
) async -> Response {
|
) async -> Response {
|
||||||
guard let viewer = await sessions?.viewer(for: request) else {
|
guard let viewer = await sessions?.viewer(for: request) else {
|
||||||
return responses.response(
|
return await home.response(
|
||||||
for: language,
|
in: language,
|
||||||
request: request
|
request: request
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
|
let page = await home.page(
|
||||||
page(.init(identifier: language), viewer)
|
in: language,
|
||||||
|
for: viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTMLResponse(additionalHeaders: .perViewer) {
|
||||||
|
page
|
||||||
}
|
}
|
||||||
.response(from: request, context: context)
|
.response(from: request, context: context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,8 +194,13 @@ private extension SessionController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = await sessions.start(for: try await accounts.account(email: email))
|
let account = try await accounts.signIn(email: email)
|
||||||
var response = Response.redirect(to: returnPath, type: .normal)
|
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))
|
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
/// The repository listings and shops are read through.
|
/// The repository listings and shops are read through.
|
||||||
private let catalogue: CatalogueRepository
|
private let catalogue: CatalogueRepository
|
||||||
|
|
||||||
|
/// 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.
|
/// The store the signed-in account is read from.
|
||||||
private let sessions: SessionStore
|
private let sessions: SessionStore
|
||||||
|
|
||||||
@@ -35,14 +38,17 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
/// - sessions: the store the signed-in account is read from.
|
/// - 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.
|
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||||
|
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||||
public init(
|
public init(
|
||||||
trade: TradeRepository,
|
trade: TradeRepository,
|
||||||
catalogue: CatalogueRepository,
|
catalogue: CatalogueRepository,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil,
|
||||||
|
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
|
self.onCatalogueChange = onCatalogueChange
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.catalogue = catalogue
|
self.catalogue = catalogue
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
@@ -95,7 +101,11 @@ private extension TradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
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 {
|
} catch let error as TradeError {
|
||||||
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
|
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 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)
|
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
|
||||||
|
|
||||||
|
if number.flatMap(\.self) != nil {
|
||||||
|
await onCatalogueChange()
|
||||||
|
}
|
||||||
|
|
||||||
return .redirect(
|
return .redirect(
|
||||||
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
|
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
|
||||||
type: .normal
|
type: .normal
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ extension PasskeyCeremonies {
|
|||||||
|
|
||||||
/// What a ceremony does.
|
/// What a ceremony does.
|
||||||
enum Kind: Sendable, Equatable {
|
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.
|
/// Signs in with a passkey registered earlier.
|
||||||
case authentication
|
case authentication
|
||||||
/// Creates the account of the given email, with its first passkey.
|
/// Creates the account of the given email, with its first passkey.
|
||||||
@@ -109,7 +111,7 @@ extension PasskeyCeremonies {
|
|||||||
name: cookieName,
|
name: cookieName,
|
||||||
value: token,
|
value: token,
|
||||||
maxAge: token.isEmpty ? 0 : Int(lifetime),
|
maxAge: token.isEmpty ? 0 : Int(lifetime),
|
||||||
path: String.Route.signIn,
|
path: "\(String.Route.signIn)/passkey",
|
||||||
secure: isSecure,
|
secure: isSecure,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: .strict
|
sameSite: .strict
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public actor SessionStore {
|
|||||||
|
|
||||||
// MARK: Properties
|
// 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.
|
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
|
||||||
private let trade: TradeRepository?
|
private let trade: TradeRepository?
|
||||||
|
|
||||||
@@ -19,10 +22,14 @@ public actor SessionStore {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates an empty session store.
|
/// 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(
|
public init(
|
||||||
trade: TradeRepository? = nil
|
trade: TradeRepository? = nil,
|
||||||
|
collecting: CollectingRepository? = nil
|
||||||
) {
|
) {
|
||||||
|
self.collecting = collecting
|
||||||
self.trade = trade
|
self.trade = trade
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +57,15 @@ public actor SessionStore {
|
|||||||
return token
|
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.
|
/// The account the session with the given token belongs to, or `nil` when there is no such session.
|
||||||
public func user(
|
public func user(
|
||||||
for token: String
|
for token: String
|
||||||
@@ -114,6 +130,7 @@ extension SessionStore {
|
|||||||
return .init(
|
return .init(
|
||||||
user,
|
user,
|
||||||
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
|
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
|
||||||
|
newWantMatches: (try? await collecting?.newMatchCount(for: user)) ?? 0,
|
||||||
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
|
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
func `error page to be served for an unknown listing`() async throws {
|
func `error page to be served for an unknown listing`() async throws {
|
||||||
try await app(
|
try await app(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
|
||||||
open_sign_in("/search")
|
open_sign_in("/search")
|
||||||
press("sam.okafor@example.com")
|
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'")
|
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: 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"))
|
check("join: the page is rendered for the new account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||||
held = credentials()
|
held = credentials()
|
||||||
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
|
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("")
|
press("")
|
||||||
landed = chrome.wait_for("location.pathname === '/grading-guide'")
|
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: 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"])
|
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.
|
# 3. By email, the ceremony names the account's credential.
|
||||||
@@ -65,12 +68,27 @@ open_sign_in("/")
|
|||||||
press("Sam.Okafor@Example.com")
|
press("Sam.Okafor@Example.com")
|
||||||
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
|
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.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("/")
|
open_sign_in("/")
|
||||||
press("mara.v@example.com")
|
press("mara.v@example.com")
|
||||||
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
|
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")
|
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
|
||||||
|
|
||||||
sys.exit(0 if all(results) else 1)
|
sys.exit(0 if all(results) else 1)
|
||||||
+2
-2
@@ -40,11 +40,11 @@ struct CatalogueComponentsTests {
|
|||||||
// MARK: ListingCard tests
|
// MARK: ListingCard tests
|
||||||
|
|
||||||
@Test
|
@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()
|
let html = ListingCard(.stub()).render()
|
||||||
|
|
||||||
#expect(html.contains(#"href="/listing/nightward-1">Nightward #1</a>"#))
|
#expect(html.contains(#"href="/listing/nightward-1">Nightward #1</a>"#))
|
||||||
#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("€240"))
|
||||||
#expect(html.contains("CGC 9.8"))
|
#expect(html.contains("CGC 9.8"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct SiteFooterTests {
|
|||||||
#expect(html.contains(">\(title)</h2>"))
|
#expect(html.contains(">\(title)</h2>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.recover, .Route.terms] {
|
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] {
|
||||||
#expect(html.contains(#"href="\#(path)""#))
|
#expect(html.contains(#"href="\#(path)""#))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,23 @@ struct SiteHeaderTests {
|
|||||||
#expect(html.contains(#"href="/sell""#))
|
#expect(html.contains(#"href="/sell""#))
|
||||||
#expect(html.contains(">MV<"))
|
#expect(html.contains(">MV<"))
|
||||||
#expect(html.contains(">Mara<"))
|
#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""#))
|
#expect(!html.contains(#"href="/sign-in""#))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `links the shop of a seller alone`() {
|
func `leads a seller to their shop, and anyone else to opening one`() {
|
||||||
#expect(!SiteHeader(viewer: .init(displayName: "Mara")).render().contains(#"href="/sell""#))
|
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
|
@Test
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ struct CataloguePagesTests {
|
|||||||
#expect(html.contains(action))
|
#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
|
@Test
|
||||||
@@ -134,7 +135,8 @@ struct CataloguePagesTests {
|
|||||||
#expect(html.contains(#"href="/messages/new?listing=nightward-1""#))
|
#expect(html.contains(#"href="/messages/new?listing=nightward-1""#))
|
||||||
#expect(html.contains(#"aria-label="Account of Mara Visser""#))
|
#expect(html.contains(#"aria-label="Account of Mara Visser""#))
|
||||||
#expect(!html.contains("gate-note"))
|
#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
|
// MARK: ShopPage tests
|
||||||
|
|||||||
@@ -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 <copies>"]),
|
||||||
|
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(#"<option value="9.6" selected>"#))
|
||||||
|
#expect(html.contains(#"name="max-price" value="85""#))
|
||||||
|
#expect(!html.contains("Nothing on your list yet"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SeriesPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `series shows every issue of the run, the gaps one tap from an alert`() {
|
||||||
|
let html = SeriesPage(
|
||||||
|
series: Self.nightward,
|
||||||
|
listings: [.stub(keyNotes: "First appearance"), .stub(slug: "cheap", priceCents: 9_000)]
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("All 4 issues"))
|
||||||
|
#expect(html.contains("2 copies listed across CCN right now."))
|
||||||
|
#expect(html.contains(#"href="/series/nightward?issue=1#copies">2 listed · from €90"#))
|
||||||
|
#expect(html.components(separatedBy: "series__issue--gap").count - 1 == 3)
|
||||||
|
// Signed out, an alert leads through the sign-in and back.
|
||||||
|
#expect(html.contains(#"href="/sign-in?return=/series/nightward">None listed · alert me"#))
|
||||||
|
#expect(html.contains("#1 · First appearance"))
|
||||||
|
#expect(!html.contains(#"id="copies""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `series lists an issue's copies cheapest delivered first, ready to compare`() {
|
||||||
|
let html = SeriesPage(
|
||||||
|
series: Self.nightward,
|
||||||
|
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
|
||||||
|
issue: 1,
|
||||||
|
viewer: .init(displayName: "Mara")
|
||||||
|
).render()
|
||||||
|
let cheap = try? #require(html.range(of: #"value="cheap""#))
|
||||||
|
let dear = try? #require(html.range(of: #"value="nightward-1""#))
|
||||||
|
|
||||||
|
#expect(html.contains("Copies of #1 — 2 listed"))
|
||||||
|
#expect(html.contains(#"action="/compare" method="get""#))
|
||||||
|
#expect(html.contains("Foxing / staining"))
|
||||||
|
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
|
||||||
|
|
||||||
|
if let cheap, let dear {
|
||||||
|
#expect(cheap.lowerBound < dear.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `series known only from its listings shows the issues listed, without gaps`() {
|
||||||
|
let html = SeriesPage(
|
||||||
|
series: .init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019", issueCount: 0, blurb: ""),
|
||||||
|
listings: [.stub()]
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("Issues listed"))
|
||||||
|
#expect(!html.contains("series__issue--gap"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ComparePage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `comparison orders copies by what they cost delivered, and flags a customs border`() {
|
||||||
|
let abroad = Shop(
|
||||||
|
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
|
||||||
|
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
|
||||||
|
)
|
||||||
|
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
|
||||||
|
let first = try? #require(html.range(of: "€156"))
|
||||||
|
let second = try? #require(html.range(of: "€246"))
|
||||||
|
|
||||||
|
#expect(html.contains("customs may apply"))
|
||||||
|
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
|
||||||
|
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
|
||||||
|
#expect(html.contains("58 · 4.6 · returns by agreement"))
|
||||||
|
|
||||||
|
if let first, let second {
|
||||||
|
#expect(first.lowerBound < second.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `comparison of nothing says where to pick copies`() {
|
||||||
|
#expect(ComparePage(copies: []).render().contains("Nothing to compare"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Shared tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `no account or collector page renders an inline style`() {
|
||||||
|
let pages = [
|
||||||
|
OpenShopPage().render(),
|
||||||
|
WelcomePage(displayName: "ana", returnPath: "/").render(),
|
||||||
|
AccountPage(user: .mara, passkeys: [], now: .stub).render(),
|
||||||
|
WantsPage(wants: [], draft: .init()).render(),
|
||||||
|
SeriesPage(series: Self.nightward, listings: [.stub()], issue: 1).render(),
|
||||||
|
SeriesIndexPage(entries: [(Self.nightward, [.stub()])]).render(),
|
||||||
|
ComparePage(copies: [.stub()]).render(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for html in pages {
|
||||||
|
#expect(!html.contains("style="))
|
||||||
|
#expect(!html.contains("<style"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"ShopForm type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct ShopFormTests {
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `describes a private seller's shop`() throws {
|
||||||
|
let draft = try Self.form().validated().get()
|
||||||
|
|
||||||
|
#expect(draft.name == "Ana's Longbox")
|
||||||
|
#expect(draft.handle == "anas-longbox")
|
||||||
|
#expect(draft.city == "Leiden")
|
||||||
|
#expect(draft.country == "NL")
|
||||||
|
#expect(draft.kind == .privateSeller)
|
||||||
|
#expect(draft.handlingDays == 2)
|
||||||
|
#expect(draft.returnsNote == "14-day returns")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `asks a business for its registration, as Dutch consumer law does`() {
|
||||||
|
#expect(Self.problems(["kind": "business"]) == [.kvk, .legalName])
|
||||||
|
#expect(Self.problems(["kind": "business", "kvk": "1234", "legal-name": "Ana BV"]) == [.kvk])
|
||||||
|
#expect(Self.problems(["kind": "business", "kvk": "12345678", "legal-name": "Ana BV"]).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `names every field that is missing or malformed`() {
|
||||||
|
#expect(Self.problems(["name": "Ab", "handle": "Bad Handle!", "city": " ", "country": "US", "kind": "charity"]) == [
|
||||||
|
.name, .handle, .city, .country, .kind,
|
||||||
|
])
|
||||||
|
#expect(Self.problems(["handle": "-leading"]) == [.handle])
|
||||||
|
#expect(Self.problems(["handle": "ab"]) == [.handle])
|
||||||
|
#expect(Self.problems(["returns": "Never", "handling": "9"]) == [.returns, .handling])
|
||||||
|
#expect(Self.problems(["declaration": ""]) == [.declaration])
|
||||||
|
#expect(Self.problems(["about": String(repeating: "a", count: 601)]) == [.about])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lowercases the handle it is given`() throws {
|
||||||
|
#expect(try Self.form(["handle": "Anas-Longbox"]).validated().get().handle == "anas-longbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension ShopFormTests {
|
||||||
|
|
||||||
|
/// A valid form for a private seller, with the given fields replaced.
|
||||||
|
static func form(
|
||||||
|
_ overrides: [String: String] = [:]
|
||||||
|
) -> ShopForm {
|
||||||
|
.init(values: [
|
||||||
|
"name": "Ana's Longbox", "handle": "anas-longbox", "city": "Leiden", "country": "NL",
|
||||||
|
"about": "Spare copies.", "kind": "private", "returns": "14-day returns", "handling": "2", "declaration": "1",
|
||||||
|
].merging(overrides) { $1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields a form with the given overrides has problems with.
|
||||||
|
static func problems(
|
||||||
|
_ overrides: [String: String]
|
||||||
|
) -> Set<ShopForm.Field> {
|
||||||
|
guard case .failure(let problems) = form(overrides).validated() else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set(problems.fields.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ private extension StaticFileTests {
|
|||||||
/// Every file paired with the path it is served at, one per extension, in ``StaticFile/all`` order.
|
/// Every file paired with the path it is served at, one per extension, in ``StaticFile/all`` order.
|
||||||
static let paths: [(File, [String])] = [
|
static let paths: [(File, [String])] = [
|
||||||
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
||||||
|
(.collecting, ["css/collecting.css"]),
|
||||||
(.favicon, ["favicon.ico"]),
|
(.favicon, ["favicon.ico"]),
|
||||||
(.fontAnton, ["font/anton-400.woff2"]),
|
(.fontAnton, ["font/anton-400.woff2"]),
|
||||||
(.fontArchivo, ["font/archivo-variable.woff2"]),
|
(.fontArchivo, ["font/archivo-variable.woff2"]),
|
||||||
|
|||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
import Foundation
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Logging
|
||||||
|
import NIOCore
|
||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"AccountController and CollectingController controllers",
|
||||||
|
.tags(.controller)
|
||||||
|
)
|
||||||
|
struct AccountAndCollectingControllerTests {
|
||||||
|
|
||||||
|
// MARK: Story tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `takes a stranger from joining to a shop of their own, with no seeded account involved`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
// Joining leads to the first-sign-in screen, which carries the intent on.
|
||||||
|
let (cookie, welcome) = try await client.execute(
|
||||||
|
uri: "/sign-in",
|
||||||
|
method: .post,
|
||||||
|
headers: Self.form,
|
||||||
|
body: ByteBuffer(string: "email=ana%40example.com&return=/sell/open-a-shop")
|
||||||
|
) { response in
|
||||||
|
(String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }), try #require(response.headers[.location]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(welcome == "/welcome?return=/sell/open-a-shop")
|
||||||
|
|
||||||
|
let tooShort = try await post(client, "/welcome", "name=A&return=/sell/open-a-shop", as: cookie, expecting: .unprocessableContent)
|
||||||
|
let named = try await post(client, "/welcome", "name=Ana+de+Vries&return=/sell/open-a-shop", as: cookie)
|
||||||
|
|
||||||
|
#expect(tooShort == nil)
|
||||||
|
#expect(named == "/sell/open-a-shop")
|
||||||
|
|
||||||
|
try await client.execute(uri: "/sell/open-a-shop", method: .get, headers: [.cookie: cookie]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(body.contains(#"aria-label="Account of Ana de Vries""#))
|
||||||
|
#expect(body.contains(">Open a shop<"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let shop = "name=Ana%27s+Longbox&handle=anas-longbox&city=Leiden&country=NL&kind=private&returns=14-day+returns&handling=2&declaration=1"
|
||||||
|
let taken = try await post(client, "/sell/open-a-shop", shop.replacingOccurrences(of: "anas-longbox", with: "kestrel-row-comics"), as: cookie, expecting: .unprocessableContent)
|
||||||
|
let opened = try await post(client, "/sell/open-a-shop", shop, as: cookie)
|
||||||
|
|
||||||
|
#expect(taken == nil)
|
||||||
|
// Straight on to the first listing.
|
||||||
|
#expect(opened == "/sell/new")
|
||||||
|
|
||||||
|
try await client.execute(uri: "/shop/anas-longbox", method: .get) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body).contains("Ana's Longbox") || String(buffer: response.body).contains("Ana's Longbox"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// One shop per account: the form now leads to the dashboard.
|
||||||
|
try await client.execute(uri: "/sell/open-a-shop", method: .get, headers: [.cookie: cookie]) { response in
|
||||||
|
#expect(response.headers[.location] == "/sell")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Account tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renames the account in the session it is signed in with`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let location = try await post(client, "/account/name", "name=Mara+V.", as: mara)
|
||||||
|
|
||||||
|
#expect(location == "/account")
|
||||||
|
|
||||||
|
try await client.execute(uri: "/account", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"aria-label="Account of Mara V.""#))
|
||||||
|
#expect(body.contains("No passkey yet"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let blank = try await post(client, "/account/name", "name=+", as: mara, expecting: .unprocessableContent)
|
||||||
|
|
||||||
|
#expect(blank == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["/account", "/welcome", "/wants", "/wants/new", "/sell/open-a-shop"])
|
||||||
|
func `keeps an account's screens to a signed-in account`(
|
||||||
|
uri: String
|
||||||
|
) async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
try await client.execute(uri: uri, method: .get) { response in
|
||||||
|
#expect(response.status == .found)
|
||||||
|
#expect(response.headers[.location]?.hasPrefix("/sign-in") == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Wants tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `rings the header's bell for a new match, until the want list is looked at`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
|
||||||
|
_ = try await post(client, "/wants", "title=Nightward+%231&min-grade=9.0&max-price=300", as: mara)
|
||||||
|
|
||||||
|
try await client.execute(uri: "/search", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
// The slabbed 9.8 and the raw NM, within the EU; the 9.4 ships from the UK.
|
||||||
|
#expect(String(buffer: response.body).contains(#"aria-label="Want list, 2 new matches""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"class="wants__new">2 new"#))
|
||||||
|
#expect(body.contains("grade ≥ 9.0 · max €300 · ships from EU only"))
|
||||||
|
|
||||||
|
return String(try #require(body.firstMatch(of: /\/wants\/([0-9a-f-]{36})\/remove/)).output.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/search", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(String(buffer: response.body).contains(#"aria-label="Want list""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another account can neither silence nor remove it.
|
||||||
|
let sam = try await signIn(client, email: "sam@example.com")
|
||||||
|
|
||||||
|
_ = try await post(client, "/wants/\(id)/remove", "", as: sam)
|
||||||
|
|
||||||
|
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("Nightward #1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await post(client, "/wants/\(id)/alerts", "", as: mara)
|
||||||
|
_ = try await post(client, "/wants/\(id)/remove", "", as: mara)
|
||||||
|
|
||||||
|
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("Nothing on your list yet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `fills the want form from a copy being saved, or a gap in a series`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
|
||||||
|
try await client.execute(uri: "/wants/new?listing=nightward-2-cgc-9-6", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"name="title" value="Nightward #2""#))
|
||||||
|
#expect(body.contains(#"name="max-price" value="85""#))
|
||||||
|
#expect(body.contains(#"<option value="9.6" selected>"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/wants/new?title=Nightward%20%2311", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(String(buffer: response.body).contains(#"name="title" value="Nightward #11""#))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Series tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the series index, a series with its gaps, and the comparison of up to three copies`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
try await client.execute(uri: "/series", method: .get) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(body.contains(#"href="/series/nightward""#))
|
||||||
|
#expect(body.contains("24 issues"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/series/nightward?issue=1", method: .get) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("All 24 issues"))
|
||||||
|
// Every copy, wherever it ships from: the series screen is the whole market.
|
||||||
|
#expect(body.contains("Copies of #1 — 4 listed"))
|
||||||
|
#expect(body.components(separatedBy: "series__issue--gap").count - 1 == 22)
|
||||||
|
#expect(body.contains("Nightward: The First Year"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A series sellers list, but the catalogue has no run for.
|
||||||
|
try await client.execute(uri: "/series/hollow-comet", method: .get) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body).contains("Collected editions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/series/no-such-series", method: .get) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
let copies = ["nightward-1-cgc-9-8", "nightward-1-cgc-9-4", "nightward-1-raw-nm", "nightward-1-2nd-print", "missing"]
|
||||||
|
|
||||||
|
try await client.execute(uri: "/compare?" + copies.map { "copy=\($0)" }.joined(separator: "&"), method: .get) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.components(separatedBy: #"compare__copy""#).count - 1 == 3)
|
||||||
|
#expect(body.contains("€246"))
|
||||||
|
#expect(body.contains("customs may apply"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension AccountAndCollectingControllerTests {
|
||||||
|
|
||||||
|
static let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"]
|
||||||
|
|
||||||
|
/// Posts a form as an account, returning where it leads — or `nil` when it answers with a page instead.
|
||||||
|
func post(
|
||||||
|
_ client: any TestClientProtocol,
|
||||||
|
_ uri: String,
|
||||||
|
_ body: String,
|
||||||
|
as cookie: String,
|
||||||
|
expecting status: HTTPResponse.Status = .seeOther
|
||||||
|
) async throws -> String? {
|
||||||
|
var headers = Self.form
|
||||||
|
|
||||||
|
headers[.cookie] = cookie
|
||||||
|
|
||||||
|
return try await client.execute(uri: uri, method: .post, headers: headers, body: ByteBuffer(string: body)) { response in
|
||||||
|
#expect(response.status == status)
|
||||||
|
|
||||||
|
return response.headers[.location]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value.
|
||||||
|
func signIn(
|
||||||
|
_ client: any TestClientProtocol,
|
||||||
|
email: String = ""
|
||||||
|
) async throws -> String {
|
||||||
|
try await client.execute(uri: "/sign-in", method: .post, headers: Self.form, body: ByteBuffer(string: "email=\(email)&return=/")) { response in
|
||||||
|
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the body against the session, catalogue, account, and collecting routes, over a migrated, seeded in-memory database.
|
||||||
|
func withApp(
|
||||||
|
_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let fluent = try Service(
|
||||||
|
driver: .inMemory,
|
||||||
|
logger: Logger(label: "test")
|
||||||
|
)()
|
||||||
|
|
||||||
|
do {
|
||||||
|
await PrepareDB()(for: fluent)
|
||||||
|
|
||||||
|
try await fluent.migrate()
|
||||||
|
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let catalogue = CatalogueRepository(fluent: fluent)
|
||||||
|
let collecting = CollectingRepository(fluent: fluent)
|
||||||
|
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
|
||||||
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
|
|
||||||
|
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
||||||
|
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes)
|
||||||
|
router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes)
|
||||||
|
router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, catalogue: catalogue, sessions: sessions).routes)
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router, body)
|
||||||
|
} catch {
|
||||||
|
try? await fluent.shutdown()
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try await fluent.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user