Implemented phase 1 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 11:16:52 +02:00
1 parent 6d7ab4bcba
commit ea89ea892a
37 files changed
+4173 -43

No files matched your search

+12 -2
View File
@@ -1,9 +1,10 @@
# CCN Website
The **CCN** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets.
The **CCN** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders the CCN marketplace prototype and serves the site's static assets.
## Overview
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)).
- 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)).
- 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)).
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
@@ -46,6 +47,7 @@ LogRequestsMiddleware
→ NotFoundMiddleware (negotiates the language, renders the not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
@@ -58,6 +60,14 @@ The screens follow the **CCN marketplace** design canvas:
- **Shared frame**: `SiteHeader`, `SiteFooter`, and the `SiteLayout` a page wraps its `content` in, under `Sources/Library/Internal/Components`.
- **Paths**: `String.Route` lists every screen's path; screens not built yet answer with the not-found page.
### Catalogue
- **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`).
- **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**: buy, offer, message, save, and follow link to the sign-in path with a `return` parameter; the sign-in screen itself is not built yet.
- **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's 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.
### Page metadata
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
@@ -260,7 +270,7 @@ Unlike a direct `swift run`, these targets inherit the exported `.env` values (s
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
### In-memory (default)
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created, **migrated, and seeded with the demo catalogue** on every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
### PostgreSQL
Set `DATABASE_DRIVER=postgres` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, PostgreSQL is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
+157 -4
View File
@@ -1,23 +1,176 @@
/* CCN — landing page. */
.home {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.hero {
display: flex;
align-items: center;
gap: 64px;
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--line);
}
.hero__text {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-3);
max-width: 640px;
padding: var(--space-1) 0 var(--space-2);
max-width: 680px;
}
.hero__text .type-lead {
max-width: 560px;
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding-top: var(--space-3);
padding-top: var(--space-2);
}
/* The fanned covers join the hero on desktop. */
.hero__covers {
display: none;
}
.formats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.formats__tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--ink);
text-decoration: none;
}
.formats__tile:hover {
border-color: var(--ink);
color: var(--ink);
}
.formats__name {
font-family: var(--font-display);
font-size: 20px;
font-weight: 600;
line-height: 1.1;
}
.home__row {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.promises {
display: grid;
gap: var(--space-8);
padding-top: var(--space-10);
border-top: 1px solid var(--line);
}
.promises__item {
display: flex;
flex-direction: column;
gap: 10px;
}
.promises__title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
line-height: 1.1;
}
.promises__text {
color: var(--ink-soft);
}
@media (min-width: 600px) {
.promises {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.formats {
gap: var(--space-5);
}
.formats__tile {
padding: var(--space-5) 22px;
}
.formats__name {
font-size: 22px;
}
}
@media (min-width: 1024px) {
.home {
gap: var(--space-14);
padding-top: 0;
}
.hero {
min-height: 420px;
padding-bottom: 0;
}
.hero__text {
gap: 22px;
}
.hero__covers {
position: relative;
display: block;
flex-shrink: 0;
width: 520px;
height: 340px;
}
.hero__covers .cover {
position: absolute;
box-shadow: var(--shadow-cover-sm);
}
.hero__covers .cover--left {
top: 40px;
left: 0;
width: 200px;
transform: rotate(-6deg);
}
.hero__covers .cover--centre {
top: 0;
left: 160px;
z-index: 1;
width: 220px;
box-shadow: var(--shadow-cover);
}
.hero__covers .cover--right {
top: 56px;
left: 340px;
width: 190px;
transform: rotate(7deg);
}
.formats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.home__row {
gap: var(--space-5);
padding: var(--space-10) 0;
}
}
@@ -0,0 +1,217 @@
/* CCN — listing detail page. */
.listing {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.listing__body {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.listing__media {
width: min(100%, 320px);
margin: 0 auto;
}
.cover--hero {
box-shadow: var(--shadow-cover);
}
.listing__main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
}
.listing__intro {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.listing__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.listing__chips .chip {
padding: var(--space-1) 9px;
font-size: 12px;
}
.listing__byline {
color: var(--ink-soft);
font-size: 15px;
}
.buy-box {
display: flex;
flex-direction: column;
gap: 18px;
}
.buy-box__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.buy-box__price {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 14px;
}
.buy-box__terms {
color: var(--muted);
}
.buy-box__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.buy-box__actions .button--primary {
flex: 1 1 auto;
}
.seller {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
padding: 18px var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.seller__text {
display: flex;
flex: 1 1 220px;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.seller__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: 15px;
font-weight: 600;
}
.seller__name a {
color: var(--ink);
text-decoration: none;
}
.seller__name a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.listing__section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.listing__section .type-eyebrow {
color: var(--ink);
font-size: 13px;
}
.details {
display: grid;
grid-template-columns: minmax(96px, 180px) minmax(0, 1fr);
column-gap: var(--space-6);
border-bottom: 1px solid var(--line);
font-size: 14px;
}
.details dt,
.details dd {
padding: 10px 0;
border-top: 1px solid var(--line);
}
.details dt {
color: var(--muted);
}
.listing__notes {
color: var(--ink-soft);
font-size: 14px;
line-height: 1.55;
text-wrap: pretty;
}
.listing__related {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding-top: var(--space-8);
border-top: 1px solid var(--line);
}
@media (min-width: 600px) {
.listing__body {
flex-direction: row;
align-items: flex-start;
gap: var(--space-8);
}
.listing__media {
position: sticky;
top: var(--space-6);
flex-shrink: 0;
width: 320px;
margin: 0;
}
.buy-box__actions .button--primary {
flex: 0 0 auto;
}
}
@media (min-width: 1024px) {
.listing {
gap: var(--space-10);
}
.listing__body {
gap: 48px;
}
.listing__media {
width: 520px;
}
.listing__main {
gap: var(--space-7);
}
.listing__byline {
font-size: 16px;
}
.buy-box {
padding: var(--space-7);
}
.listing__related {
padding-top: var(--space-10);
}
}
@@ -0,0 +1,142 @@
/* CCN — search results page. */
.search {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.search__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);
}
.search__heading {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.search__heading .type-h1 {
overflow-wrap: anywhere;
}
.search__count {
color: var(--muted);
}
.search__body {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.search__results {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
}
.search__pills {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* The filter rail: a disclosure below desktop, always open on it. */
.filters__toggle {
width: 100%;
list-style: none;
}
.filters__toggle::-webkit-details-marker {
display: none;
}
.filters__form {
display: flex;
flex-direction: column;
gap: var(--space-6);
padding-top: var(--space-5);
}
.filters__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.filters__title {
color: var(--ink);
font-size: 13px;
}
.filters__clear {
color: var(--muted);
font-size: 12px;
}
.filters__group {
display: flex;
flex-direction: column;
gap: 10px;
}
.filters__group legend {
margin-bottom: 10px;
padding: 0;
}
.filters__range {
display: flex;
align-items: center;
gap: var(--space-2);
}
.filters__field {
display: flex;
flex: 1 1 0;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
@media (min-width: 1024px) {
.search {
gap: var(--space-7);
padding-top: var(--space-8);
}
.search__body {
flex-direction: row;
align-items: flex-start;
gap: var(--space-10);
}
.filters {
flex-shrink: 0;
width: 260px;
}
.filters__toggle {
display: none;
}
.filters::details-content {
display: block;
content-visibility: visible;
}
.filters__form {
gap: var(--space-7);
padding-top: 0;
}
}
@@ -80,6 +80,9 @@
--cover-rust: #6b3a1f;
--cover-slate: #26363b;
--cover-plum: #43304f;
--cover-brick: #7a2e24;
--cover-olive: #4a4326;
--cover-ink: #1e3347;
/* Type families */
--font-display: "Oswald", "Arial Narrow", "Helvetica Neue", sans-serif;
@@ -559,6 +562,17 @@ input[type="radio"] {
width: 100%;
}
.button--bare {
border-color: transparent;
background: transparent;
}
.button--bare:hover {
border-color: transparent;
text-decoration: underline;
text-underline-offset: 3px;
}
.button:disabled,
.button[aria-disabled="true"] {
border-color: var(--line);
@@ -639,9 +653,25 @@ input[type="radio"] {
font-weight: 600;
}
/* An applied filter: pressing it removes it. */
.pill--active {
gap: var(--space-1);
border-color: var(--header);
background: var(--header);
color: var(--on-dark);
}
.pill--active:hover {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
@media (min-width: 1024px) {
.pill {
height: var(--control-xs);
padding: 0 var(--space-3);
font-size: 12px;
}
}
@@ -675,6 +705,12 @@ input[type="radio"] {
font-size: 14px;
}
.input--sm {
height: var(--control-sm);
padding: 0 10px;
font-size: 13px;
}
textarea.input {
height: auto;
min-height: 96px;
@@ -749,6 +785,26 @@ textarea.input {
line-height: 1;
}
.monogram--lg {
width: 52px;
height: 52px;
font-size: 20px;
}
.monogram--xl {
width: 72px;
height: 72px;
font-size: 28px;
}
@media (min-width: 1024px) {
.monogram--xl {
width: 112px;
height: 112px;
font-size: 44px;
}
}
.icon {
flex-shrink: 0;
}
@@ -1330,6 +1386,451 @@ textarea.input {
}
}
/* ==========================================================================
Catalogue components
========================================================================== */
/* Placeholder covers. The face scales with the cover's width through container units. */
.cover {
position: relative;
display: block;
overflow: hidden;
aspect-ratio: 2 / 3;
border-radius: var(--radius-sm);
background: var(--cover-ground, var(--cover-navy));
color: var(--cover-lettering, var(--gold));
container-type: inline-size;
}
.cover::before {
content: "";
position: absolute;
inset: 42% -15% auto;
height: 22%;
background: rgba(255, 255, 255, 0.07);
transform: rotate(-8deg);
}
.cover__face {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 6.5cqw;
}
.cover__title {
display: flex;
flex-direction: column;
font-family: var(--font-lettering);
font-size: 16cqw;
line-height: 0.9;
text-transform: uppercase;
}
.cover__foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 4cqw;
}
.cover__imprint {
color: var(--cover-imprint, #c9d3e4);
font-size: max(9px, 4cqw);
font-weight: 600;
letter-spacing: 0.14em;
line-height: 1.2;
text-transform: uppercase;
}
.cover__number {
color: #ffffff;
font-family: var(--font-lettering);
font-size: 16cqw;
line-height: 0.9;
white-space: nowrap;
}
.cover__number--text {
font-size: 12cqw;
}
.cover--navy { --cover-ground: var(--cover-navy); --cover-lettering: var(--gold); --cover-imprint: #c9d3e4; }
.cover--wine { --cover-ground: var(--cover-wine); --cover-lettering: #f0e3cf; --cover-imprint: #e4cfc0; }
.cover--forest { --cover-ground: var(--cover-forest); --cover-lettering: #e4c35c; --cover-imprint: #c6d8ce; }
.cover--rust { --cover-ground: var(--cover-rust); --cover-lettering: #f2d9a8; --cover-imprint: #e8d2b8; }
.cover--slate { --cover-ground: var(--cover-slate); --cover-lettering: #d9e6e4; --cover-imprint: #c5d6d4; }
.cover--plum { --cover-ground: var(--cover-plum); --cover-lettering: #f2c14e; --cover-imprint: #d8cce0; }
.cover--brick { --cover-ground: var(--cover-brick); --cover-lettering: #f5e7d3; --cover-imprint: #ecd3c4; }
.cover--olive { --cover-ground: var(--cover-olive); --cover-lettering: #efe4b8; --cover-imprint: #ddd3a8; }
.cover--ink { --cover-ground: var(--cover-ink); --cover-lettering: #c9d9e8; --cover-imprint: #b5c8da; }
/* Each tone carries its own motif. */
.cover--wine::before {
inset: auto -20% -14% auto;
width: 70%;
height: auto;
aspect-ratio: 1;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
transform: none;
}
.cover--forest::before,
.cover--olive::before {
inset: 0 0 auto;
height: 35%;
background: rgba(255, 255, 255, 0.06);
transform: none;
}
.cover--rust::before,
.cover--brick::before {
inset: auto -15% 12%;
height: 16%;
background: rgba(0, 0, 0, 0.14);
transform: rotate(6deg);
}
.cover--plum::before,
.cover--ink::before {
inset: 0 auto 0 0;
width: 10%;
height: auto;
background: rgba(0, 0, 0, 0.22);
transform: none;
}
.cover--plum .cover__face,
.cover--ink .cover__face {
padding-left: 16cqw;
}
/* Listing cards */
.listing-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.listing-card__cover {
display: block;
text-decoration: none;
}
.listing-card__save {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(245, 243, 238, 0.92);
color: #191713;
}
.listing-card__save:hover {
background: #ffffff;
color: #b3271e;
}
.listing-card__title {
color: var(--ink);
text-decoration: none;
}
.listing-card__title:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.listing-card__meta,
.listing-card__shop {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.listing-card__meta {
margin-top: 2px;
}
.listing-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.listing-card__shop {
text-decoration: none;
}
.listing-card__shop:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
@media (min-width: 1024px) {
.listing-card {
gap: 10px;
}
/* Beside the filter rail, the grid runs four across. */
.card-grid--search {
--grid-columns: 4;
}
}
/* Pagination */
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
padding-top: var(--space-2);
}
.pagination__page,
.pagination__next {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--control-md);
height: var(--control-md);
border-radius: var(--radius-sm);
color: var(--ink);
font-size: 13px;
font-weight: 500;
text-decoration: none;
}
.pagination__page:hover {
background: var(--tint);
color: var(--ink);
}
.pagination__page[aria-current] {
background: var(--header);
color: var(--on-dark);
font-weight: 600;
}
.pagination__next {
margin-left: 10px;
padding: 0 14px;
border: 1px solid var(--ink);
font-weight: 600;
}
.pagination__next:hover {
background: var(--ink);
color: var(--paper);
}
/* Breadcrumb */
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-size: 13px;
}
.breadcrumb a {
color: var(--muted);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.breadcrumb__current {
color: var(--ink);
font-weight: 500;
}
/* Tabs */
.tabs {
display: flex;
gap: var(--space-1);
overflow-x: auto;
border-bottom: 1px solid var(--line);
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs__tab {
display: inline-flex;
flex-shrink: 0;
align-items: center;
height: var(--control-md);
padding: 0 var(--space-4);
border-bottom: 3px solid transparent;
color: var(--muted);
font-size: 14px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.tabs__tab:hover {
color: var(--ink);
}
.tabs__tab[aria-current] {
border-bottom-color: var(--ink);
color: var(--ink);
font-weight: 600;
}
/* Select, and the sort control built on it */
.select {
height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--ink);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--ink);
font-size: 13px;
font-weight: 500;
}
.sort {
display: flex;
align-items: center;
gap: 10px;
}
.sort__label {
color: var(--muted);
font-size: 13px;
}
/* Checkbox rows */
.check {
display: flex;
align-items: center;
gap: 10px;
min-height: 24px;
font-size: 13px;
cursor: pointer;
}
/* Segmented radio control */
.segmented {
display: flex;
overflow: hidden;
border: 1px solid var(--ink);
border-radius: var(--radius-sm);
}
.segmented__option {
position: relative;
display: flex;
flex: 1 1 0;
cursor: pointer;
}
.segmented__option + .segmented__option {
border-left: 1px solid var(--ink);
}
.segmented__option input {
position: absolute;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.segmented__option span {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: var(--control-sm);
padding: 0 var(--space-2);
color: var(--ink);
font-size: 12px;
font-weight: 600;
}
.segmented__option input:checked + span {
background: var(--header);
color: var(--on-dark);
}
.segmented__option input:focus-visible + span {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* Empty states */
.empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-8) var(--space-6);
border: 1px dashed var(--rule);
border-radius: var(--radius-sm);
color: var(--ink-soft);
}
/* The gate note under gated actions */
.gate-note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: var(--space-3) 14px;
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink-soft);
font-size: 13px;
line-height: 1.45;
}
.gate-note .icon {
margin-top: 1px;
}
.gate-note__link {
color: var(--ink);
font-weight: 600;
}
.verified {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--good);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
/* ==========================================================================
Helpers
========================================================================== */
@@ -1339,6 +1840,11 @@ textarea.input {
display: none !important;
}
/* Hidden once scripts run: the submit buttons of forms that then submit themselves. */
.js .js-hidden {
display: none;
}
/* Hidden visually, still read out. */
.visually-hidden {
position: absolute;
@@ -0,0 +1,122 @@
/* CCN — shop page. */
.shop {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.shop__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-4);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--line);
}
.shop__intro {
display: flex;
flex: 1 1 260px;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.shop__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) 14px;
}
.shop__name .chip {
gap: 5px;
padding: 5px 9px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.shop__standing {
color: var(--muted);
}
.shop__about {
max-width: 720px;
color: var(--ink-soft);
text-wrap: pretty;
}
.shop__stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
color: var(--muted);
font-size: 13px;
}
.shop__stats strong {
color: var(--ink);
font-weight: 600;
}
.shop__actions {
display: flex;
flex: 1 1 100%;
flex-direction: column;
gap: 10px;
}
.shop__gate {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.shop__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.shop__quick {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.shop__sort {
display: flex;
align-items: center;
gap: var(--space-2);
}
.shop__sort .select {
height: var(--control-sm);
}
@media (min-width: 600px) {
.shop__head {
gap: var(--space-7);
}
.shop__actions {
flex: 0 0 200px;
}
}
@media (min-width: 1024px) {
.shop {
gap: var(--space-7);
padding-top: var(--space-10);
}
.shop__head {
flex-wrap: nowrap;
padding-bottom: var(--space-8);
}
}
@@ -0,0 +1,23 @@
// CCN — shared script. Every screen works without it; it only removes a click here and there.
document.documentElement.classList.add("js");
// Selects marked `data-autosubmit` submit their form on change.
for (const select of document.querySelectorAll("select[data-autosubmit]")) {
select.addEventListener("change", () => select.form?.requestSubmit());
}
// The filter rail is a disclosure, open on desktop. Browsers without `::details-content` need it opened here.
const filters = document.querySelector("details.filters");
const desktop = window.matchMedia("(min-width: 1024px)");
if (filters) {
const sync = () => {
if (desktop.matches) {
filters.open = true;
}
};
sync();
desktop.addEventListener("change", sync);
}
@@ -11,8 +11,8 @@ import WebsiteLibrary
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a PostgreSQL backend is migrated out of
/// band (so a shared database is never migrated on boot).
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated here, before the landing page reads its listings, while a
/// PostgreSQL backend is migrated out of band (so a shared database is never migrated on boot).
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
@@ -46,6 +46,15 @@ func application(
await prepareDB(for: fluent)
// The in-memory backend is recreated on every launch, so it is migrated — and seeded — before the landing page
// reads its listings. The PostgreSQL backend is left untouched: a shared database is migrated out of band to avoid
// multi-instance races.
if case .inMemory = driver {
try await fluent.migrate()
}
let repository = CatalogueRepository(fluent: fluent)
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
@@ -60,7 +69,12 @@ func application(
// building both against an empty host.
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
probe: Probe(fluent: fluent),
repository: repository,
listings: await homeListings(
from: repository,
logger: logger
)
),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
@@ -70,14 +84,6 @@ func application(
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = driver {
app.beforeServerStarts {
try await fluent.migrate()
}
}
return app
}
@@ -121,6 +127,26 @@ func migration(
/// The request context type the application serves its routes with.
private typealias AppRequestContext = WebsiteRequestContext
/// Reads the listings the landing page shows, once: the page is rendered at startup and cached.
///
/// A catalogue that cannot be read — a PostgreSQL backend not migrated yet — leaves the page without its rows rather than failing the launch.
/// - Parameters:
/// - repository: the repository the listings are read through.
/// - logger: the logger the failure is reported to.
/// - Returns: the landing page's listings, or none when the catalogue cannot be read.
private func homeListings(
from repository: CatalogueRepository,
logger: Logger
) async -> HomeListings {
do {
return .init(from: try await repository.listings())
} catch {
logger.warning("Catalogue is unreadable; the landing page will show no listings: \(error)")
return .init()
}
}
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
@@ -156,6 +182,8 @@ private func logger(
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
/// - listings: the listings the landing page shows in its rows.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
@@ -168,7 +196,9 @@ private func router(
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
siteOrigin: String?,
logLevel: Logger.Level,
probe: Probe
probe: Probe,
repository: CatalogueRepository,
listings: HomeListings
) -> Router<AppRequestContext> {
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
// the page's status and headers instead of a 404.
@@ -204,6 +234,12 @@ private func router(
RootController<AppRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings
)
CatalogueController<AppRequestContext>(
repository: repository,
assetVersion: assetVersion,
analytics: analytics
)
HealthController<AppRequestContext>(
@@ -0,0 +1,62 @@
import Elementary
/// A chip stating a grade, condition, or status.
struct Chip: HTML {
// MARK: Properties
/// Whether the chip leads with a check mark, for a grade a grading company certified.
let isCertified: Bool
/// The chip's text.
let label: String
/// The chip's colouring.
let tone: Tone
// MARK: Initializers
/// Creates a chip.
/// - Parameters:
/// - label: the chip's text.
/// - tone: the chip's colouring; neutral by default.
/// - isCertified: whether the chip leads with a check mark; `false` by default.
init(
_ label: String,
tone: Tone = .neutral,
isCertified: Bool = false
) {
self.isCertified = isCertified
self.label = label
self.tone = tone
}
// MARK: Computed
var body: some HTML {
span(.class(tone.className)) {
if isCertified {
Icon(.check, size: 10)
}
label
}
}
}
// MARK: - Enumerations
extension Chip {
/// A chip's colouring.
enum Tone: String, Sendable {
case bad
case good
case neutral
case warn
/// The chip's classes for the tone.
var className: String {
self == .neutral ? "chip" : "chip chip--\(rawValue)"
}
}
}
@@ -0,0 +1,60 @@
import Elementary
import Persistence
/// A placeholder cover: lettering on a toned ground, scaling with its width. Stands in until listings carry photos.
struct Cover: HTML {
// MARK: Properties
/// The cover's lettering and tone.
let cover: Listing.Cover
/// A modifier class positioning or sizing the cover, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a placeholder cover.
/// - Parameters:
/// - cover: the cover's lettering and tone.
/// - modifier: a modifier class positioning or sizing the cover, or `nil` (the default) for none.
init(
_ cover: Listing.Cover,
modifier: String? = nil
) {
self.cover = cover
self.modifier = modifier
}
// MARK: Computed
/// Decorative: whatever links to the listing names it.
var body: some HTML {
div(
.class(["cover", "cover--\(cover.tone)", modifier].compactMap(\.self).joined(separator: " ")),
.custom(
name: "aria-hidden",
value: "true"
)
) {
div(.class("cover__face")) {
div(.class("cover__title")) {
for line in cover.title.split(separator: "\n") {
span {
String(line)
}
}
}
div(.class("cover__foot")) {
span(.class("cover__imprint")) {
cover.imprint
}
span(.class(cover.number.allSatisfy(\.isNumber) ? "cover__number" : "cover__number cover__number--text")) {
cover.number
}
}
}
}
}
}
@@ -11,18 +11,24 @@ struct Icon: HTML {
/// The icon's rendered width and height, in pixels.
let size: Int
/// The stroke width, or `nil` for the glyph's own.
let weight: String?
// MARK: Initializers
/// Creates an icon.
/// - Parameters:
/// - glyph: the glyph the icon draws.
/// - size: the icon's rendered width and height, in pixels; 20 by default.
/// - weight: the stroke width, or `nil` (the default) for the glyph's own.
init(
_ glyph: Glyph,
size: Int = 20
size: Int = 20,
weight: String? = nil
) {
self.glyph = glyph
self.size = size
self.weight = weight
}
// MARK: Computed
@@ -32,7 +38,7 @@ struct Icon: HTML {
HTMLRaw(
"""
<svg class="icon" width="\(size)" height="\(size)" viewBox="0 0 24 24" fill="none" stroke="currentColor" \
stroke-width="\(glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
stroke-width="\(weight ?? glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
"""
)
}
@@ -50,10 +56,14 @@ extension Icon {
case bookmark
/// A check mark.
case check
/// A page of text.
case document
/// Filter lines.
case filter
/// A key.
case key
/// A padlock.
case lock
/// A speech bubble.
case message
/// A magnifying glass.
@@ -75,8 +85,10 @@ extension Icon.Glyph {
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
case .search: #"<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path>"#
case .shop: #"<path d="M3 9.5 12 4l9 5.5"></path><path d="M5 10v9h14v-9"></path><path d="M10 19v-5h4v5"></path>"#
@@ -0,0 +1,108 @@
import Elementary
import Persistence
/// A listing in a card grid: its cover, title, price, grade chip, and — depending on the ``Context`` — the shop selling it.
struct ListingCard: HTML {
// MARK: Properties
/// Where the card is shown.
let context: Context
/// The listing the card shows.
let listing: Listing
// MARK: Initializers
/// Creates a listing card.
/// - Parameters:
/// - listing: the listing the card shows.
/// - context: where the card is shown; a browsing row by default.
init(
_ listing: Listing,
context: Context = .browse
) {
self.context = context
self.listing = listing
}
// MARK: Computed
var body: some HTML {
article(.class("listing-card")) {
// Saving is a gated action: signed out, it leads to the sign-in screen.
a(
.class("listing-card__save"),
.href(.Route.signIn(returningTo: listing.path)),
.custom(
name: "aria-label",
value: "Save \(listing.title) to watchlist"
)
) {
Icon(.bookmark, size: 18)
}
a(
.class("listing-card__cover"),
.href(listing.path),
.custom(
name: "tabindex",
value: "-1"
),
.custom(
name: "aria-hidden",
value: "true"
)
) {
Cover(listing.cover)
}
div {
h3(.class("type-card-title")) {
a(
.class("listing-card__title"),
.href(listing.path)
) {
listing.title
}
}
p(.class("listing-card__meta")) {
listing.summary
}
}
div(.class("listing-card__row")) {
span(.class("type-price-card")) {
listing.priceCents.euros
}
Chip(
listing.chip.label,
tone: listing.chip.tone,
isCertified: listing.chip.isCertified
)
}
if context != .shop {
a(
.class("listing-card__shop"),
.href(listing.shop.path)
) {
context == .search
? "\(listing.shop.name) · \(listing.shop.country) · ship \(listing.shippingCents.euros)"
: listing.shop.name
}
}
}
}
}
// MARK: - Enumerations
extension ListingCard {
/// Where a listing card is shown.
enum Context: Sendable {
/// A browsing row: the shop is named.
case browse
/// Search results: the shop is named with its country and shipping cost.
case search
/// The shop's own screen: the shop goes unnamed.
case shop
}
}
@@ -0,0 +1,107 @@
import Elementary
/// The page links under a card grid; renders nothing for a single page.
struct Pagination: HTML {
// MARK: Properties
/// The page shown, counted from one.
let current: Int
/// The path of a given page.
let path: @Sendable (Int) -> String
/// The number of pages.
let total: Int
// MARK: Initializers
/// Creates the page links.
/// - Parameters:
/// - current: the page shown, counted from one.
/// - total: the number of pages.
/// - path: the path of a given page.
init(
current: Int,
total: Int,
path: @escaping @Sendable (Int) -> String
) {
self.current = current
self.path = path
self.total = total
}
// MARK: Computed
var body: some HTML {
if total > 1 {
nav(
.class("pagination"),
.custom(
name: "aria-label",
value: "Pagination"
)
) {
for page in 1...total {
if page == current {
a(
.class("pagination__page"),
.href(path(page)),
.custom(
name: "aria-current",
value: "page"
)
) {
"\(page)"
}
} else {
a(
.class("pagination__page"),
.href(path(page))
) {
"\(page)"
}
}
}
if current < total {
a(
.class("pagination__next"),
.href(path(current + 1))
) {
"Next"
}
}
}
}
}
}
// MARK: - Extensions
extension Pagination {
// MARK: Methods
/// Cuts the items of one page out of a list, clamping the page to the list's range.
/// - Parameters:
/// - items: the whole list.
/// - page: the page asked for, counted from one.
/// - size: the number of items on a page.
/// - Returns: the page's items, the page actually shown, and the number of pages.
static func slice<Item>(
_ items: [Item],
page: Int,
size: Int
) -> (items: [Item], current: Int, total: Int) {
let total = max((items.count + size - 1) / size, 1)
let current = min(max(page, 1), total)
return (
Array(items.dropFirst((current - 1) * size).prefix(size)),
current,
total
)
}
}
@@ -10,6 +10,9 @@ struct SiteHeader: HTML {
/// The format marked as current in the navigation, or `nil` for none.
let active: Format?
/// The text the search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out actions.
let viewer: Viewer?
@@ -18,12 +21,15 @@ struct SiteHeader: HTML {
/// Creates a site header.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil
) {
self.active = active
self.query = query
self.viewer = viewer
}
@@ -159,6 +165,7 @@ private extension SiteHeader {
.id("site-search-query"),
.type(.search),
.name("q"),
.value(query ?? ""),
.placeholder("Search title, issue, grade or shop")
)
button(
@@ -11,6 +11,9 @@ struct SiteLayout<Content: HTML>: HTML {
/// The screen's markup, rendered inside the `main` landmark.
let content: Content
/// The text the header's search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out header.
let viewer: Viewer?
@@ -19,15 +22,18 @@ struct SiteLayout<Content: HTML>: HTML {
/// Creates a site layout around a screen's content.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the header's search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header.
/// - content: the screen's markup.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil,
@HTMLBuilder content: () -> Content
) {
self.active = active
self.content = content()
self.query = query
self.viewer = viewer
}
@@ -42,6 +48,7 @@ struct SiteLayout<Content: HTML>: HTML {
}
SiteHeader(
active: active,
query: query,
viewer: viewer
)
main(.id("main")) {
@@ -0,0 +1,63 @@
import Elementary
import Persistence
/// The sort order select. It submits its form on change when scripts run; the form's own button covers the rest.
struct SortSelect: HTML {
// MARK: Properties
/// The identifier of the form the select belongs to.
let form: String
/// The order currently applied.
let selected: ListingFilter.Sort
// MARK: Computed
var body: some HTML {
div(.class("sort")) {
label(
.class("sort__label"),
.for("\(form)-sort")
) {
"Sort"
}
select(
.class("select"),
.id("\(form)-sort"),
.name(SearchParameters.Name.sort.rawValue),
.form(form),
.custom(
name: "data-autosubmit",
value: ""
)
) {
for sort in ListingFilter.Sort.allCases {
option(.value(sort.rawValue)) {
sort.label
}
.attributes(.selected, when: sort == selected)
}
}
}
}
}
// MARK: - Extensions
extension ListingFilter.Sort {
// MARK: Computed
/// The order's name in the select.
var label: String {
switch self {
case .newest: "Newly listed"
case .priceAscending: "Price: low to high"
case .priceDescending: "Price: high to low"
case .gradeDescending: "Grade: high to low"
}
}
}
@@ -26,6 +26,26 @@ extension Format {
}
}
/// The format's name for one item.
var singular: String {
switch self {
case .singleIssues: "Single issue"
case .manga: "Manga"
case .tradePaperbacks: "Trade paperback"
case .hardcovers: "Hardcover"
}
}
/// What the format covers, in a sentence.
var summary: String {
switch self {
case .singleIssues: "Raw and slabbed, all eras. Filter by grade, print and key status."
case .manga: "Tankōbon volumes, box sets and first English printings."
case .tradePaperbacks: "Collected runs and one-shots, with printing noted on every copy."
case .hardcovers: "Omnibuses, deluxe editions and sealed copies."
}
}
/// The path of the search screen filtered to the format.
var path: String {
"\(String.Route.search)?format=\(rawValue)"
@@ -0,0 +1,14 @@
import Foundation
extension Int {
// MARK: Computed
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
var euros: String {
isMultiple(of: 100)
? "€\(self / 100)"
: String(format: "€%d.%02d", self / 100, self % 100)
}
}
@@ -0,0 +1,89 @@
import Foundation
import Persistence
extension Listing {
// MARK: Computed
/// The chip stating the copy's grade or condition.
///
/// A slab reads `CGC 9.8` — good from 9.0 up, a warning below 7.0 — a raw issue `Raw · NM`, and a collected edition its condition.
var chip: (label: String, tone: Chip.Tone, isCertified: Bool) {
guard let grader, let grade else {
return (
kind == .singleIssues ? "Raw · \(condition)" : condition,
.neutral,
false
)
}
return (
"\(grader) \(grade.gradeLabel)",
grade >= 9 ? .good : grade < 7 ? .warn : .neutral,
true
)
}
/// The format the listing is published in, or `nil` when its token names none.
var kind: Format? {
.init(rawValue: format)
}
/// The path of the listing's detail screen.
var path: String {
.Route.listing(slug)
}
/// The line under a card's title: the publisher, year, and printing, led by the format for a collected edition.
var summary: String {
let lead = kind == .singleIssues ? nil : kind?.singular
return [lead, publisher, String(year), printing]
.compactMap(\.self)
.joined(separator: " · ")
}
}
extension Shop {
// MARK: Computed
/// The shop's location, e.g. `Amsterdam, NL`.
var location: String {
"\(city), \(country)"
}
/// The uppercased initials of the first two words of the shop's name that start with a letter.
var monogram: String {
name
.split(separator: " ")
.filter { $0.first?.isLetter == true }
.prefix(2)
.compactMap(\.first)
.map { String($0).uppercased() }
.joined()
}
/// The path of the shop's public screen.
var path: String {
.Route.shop(slug)
}
/// The shop's standing in a line: where it is, since when, and how it is rated.
var standing: String {
"\(location) · selling on CCN since \(sinceYear) · \(String(format: "%.1f", rating)) from \(ratedSales) rated sales"
}
}
extension Double {
// MARK: Computed
/// The grade to one decimal place, e.g. `9.8`.
var gradeLabel: String {
String(format: "%.1f", self)
}
}
@@ -1,3 +1,5 @@
import Foundation
extension String {
/// A namespace for the paths of the site's screens.
///
@@ -35,5 +37,36 @@ extension String {
static let terms = "/terms"
/// The want list.
static let wants = "/wants"
/// The path of a listing's detail screen.
/// - Parameter slug: the listing's URL slug.
static func listing(
_ slug: String
) -> String {
"/listing/\(slug)"
}
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
_ slug: String
) -> String {
"/shop/\(slug)"
}
/// The path of the sign-in screen, returning to the given path afterwards.
/// - Parameter path: the path the visitor's gated action started from.
static func signIn(
returningTo path: String
) -> String {
"\(signIn)?return=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "")"
}
}
}
// MARK: - Constants
private extension CharacterSet {
/// The characters a query value carries unescaped.
static let urlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: "&=+?#"))
}
@@ -2,6 +2,7 @@ import Elementary
import Foundation
import Infrastructure
import Localization
import Persistence
/// The website's landing page, with its text localized to a given locale.
struct IndexPage {
@@ -14,6 +15,9 @@ struct IndexPage {
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The listings the page shows in its rows.
let listings: HomeListings
/// The locale the page content is localized to.
let locale: Locale
@@ -31,14 +35,17 @@ struct IndexPage {
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
/// - listings: the listings the page shows in its rows; none by default.
init(
locale: Locale,
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = listings
self.locale = locale
self.siteOrigin = siteOrigin
self.localize = .init(bundle: .module)
@@ -82,32 +89,85 @@ extension IndexPage: Page {
// MARK: Properties
/// The hero alone, until the listings exist.
var content: some HTML {
SiteLayout {
div(.class("page")) {
div(.class("page home")) {
section(.class("hero")) {
p(.class("type-eyebrow")) {
"Single issues · Manga · Trades · Hardcovers"
}
h1(.class("type-hero")) {
localize("index.heading", locale: locale)
}
p(.class("type-lead")) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div(.class("hero__actions")) {
a(
.class("button button--primary button--lg"),
.href(Format.singleIssues.path)
) {
"Browse single issues"
div(.class("hero__text")) {
p(.class("type-eyebrow")) {
"Single issues · Manga · Trades · Hardcovers"
}
h1(.class("type-hero")) {
localize("index.heading", locale: locale)
}
p(.class("type-lead")) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div(.class("hero__actions")) {
a(
.class("button button--primary button--lg"),
.href(Format.singleIssues.path)
) {
"Browse single issues"
}
a(
.class("button button--outline button--lg"),
.href(.Route.series)
) {
"Browse by series"
}
}
}
if listings.singleIssues.count >= 3 {
div(.class("hero__covers")) {
Cover(listings.singleIssues[1].cover, modifier: "cover--left")
Cover(listings.singleIssues[0].cover, modifier: "cover--centre")
Cover(listings.singleIssues[2].cover, modifier: "cover--right")
}
}
}
nav(
.class("formats"),
.custom(name: "aria-label", value: "Browse by format")
) {
for format in Format.allCases {
a(
.class("button button--outline button--lg"),
.href(.Route.series)
.class("formats__tile"),
.href(format.path)
) {
"Browse by series"
span(.class("formats__name")) {
format.label
}
span(.class("type-caption")) {
format.summary
}
}
}
}
row(
"New in single issues",
more: "All single issues",
path: Format.singleIssues.path,
listings: listings.singleIssues
)
row(
"Manga, trades and hardcovers",
more: "All collected editions",
path: SearchParameters(items: Format.allCases.dropFirst().map {
.init(name: SearchParameters.Name.format.rawValue, value: $0.rawValue)
}).path(.Route.search),
listings: listings.collected
)
section(.class("promises")) {
for promise in Self.promises {
div(.class("promises__item")) {
Icon(promise.glyph, size: 28, weight: "1.8")
h3(.class("promises__title")) {
promise.title
}
p(.class("type-body-sm promises__text")) {
promise.text
}
}
}
}
@@ -128,3 +188,50 @@ extension IndexPage: Page {
}
}
// MARK: - Helpers
private extension IndexPage {
// MARK: Constants
/// What the marketplace promises, under the listing rows.
static let promises: [(glyph: Icon.Glyph, title: String, text: String)] = [
(.key, "Passkeys, not passwords", "Sign up and sign in with Face ID, a fingerprint or a hardware key. Nothing to phish, nothing to reuse, nothing to reset."),
(.document, "Grade stated up front", "Every listing declares slab or raw, grader and grade, printing and defects, with photos of the actual copy front and back."),
(.shop, "Shops are open to everyone", "Anyone can browse a seller's inventory without an account. Buying, offers and messages need a signed-in account on both sides."),
]
// MARK: Methods
/// A titled row of listing cards; renders nothing without listings.
@HTMLBuilder
func row(
_ title: String,
more: String,
path: String,
listings: [Listing]
) -> some HTML {
if !listings.isEmpty {
section(.class("home__row")) {
div(.class("section-head")) {
h2(.class("type-h2")) {
title
}
a(
.class("link-ink"),
.href(path)
) {
more
}
}
div(.class("card-grid")) {
for listing in listings {
ListingCard(listing)
}
}
}
}
}
}
@@ -0,0 +1,310 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The listing detail screen: one copy, its price and gated actions, its seller, and its declared details.
struct ListingPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The listing shown.
let listing: Listing
/// Other listings from the same shop.
let related: [Listing]
// MARK: Initializers
/// Creates the detail screen of a listing.
/// - Parameters:
/// - listing: the listing shown.
/// - related: other listings from the same shop.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
listing: Listing,
related: [Listing],
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.listing = listing
self.locale = .init(identifier: "en")
self.related = related
}
}
// MARK: - Page
extension ListingPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(active: listing.kind) {
div(.class("page listing")) {
breadcrumb
div(.class("listing__body")) {
div(.class("listing__media")) {
Cover(listing.cover, modifier: "cover--hero")
}
div(.class("listing__main")) {
div(.class("listing__intro")) {
div(.class("listing__chips")) {
Chip(
listing.chip.label,
tone: listing.chip.tone,
isCertified: listing.chip.isCertified
)
if let pageQuality = listing.pageQuality {
Chip("\(pageQuality) pages")
}
Chip(listing.printing)
}
h1(.class("type-h1")) {
listing.title
}
p(.class("listing__byline")) {
"\(listing.publisher) · \(listing.year) · \((listing.kind?.singular ?? listing.format).lowercased())"
}
}
buyBox
seller
section(.class("listing__section")) {
h2(.class("type-eyebrow")) {
"Details"
}
dl(.class("details")) {
for detail in details {
dt {
detail.term
}
dd {
detail.value
}
}
}
}
section(.class("listing__section")) {
h2(.class("type-eyebrow")) {
"Seller's notes"
}
p(.class("listing__notes")) {
listing.sellerNotes
}
}
}
}
if !related.isEmpty {
section(.class("listing__related")) {
div(.class("section-head")) {
h2(.class("type-h2")) {
"More from \(listing.shop.name)"
}
a(
.class("link-ink"),
.href(listing.shop.path)
) {
"Visit the shop"
}
}
div(.class("card-grid")) {
for listing in related {
ListingCard(listing, context: .shop)
}
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.listing]
}
var summary: String? {
"\(listing.title), \(listing.chip.label), \(listing.priceCents.euros) from \(listing.shop.name) on CCN."
}
var title: String {
"\(listing.title) · \(listing.chip.label) — CCN"
}
}
// MARK: - Helpers
private extension ListingPage {
// MARK: Computed
var breadcrumb: some HTML {
nav(
.class("breadcrumb"),
.custom(name: "aria-label", value: "Breadcrumb")
) {
a(.href(.Route.home)) {
"Home"
}
if let format = listing.kind {
span(.custom(name: "aria-hidden", value: "true")) {
"/"
}
a(.href(format.path)) {
format.label
}
}
span(.custom(name: "aria-hidden", value: "true")) {
"/"
}
a(.href(
SearchParameters()
.setting(.publisher, to: listing.publisher)
.path(.Route.search)
)) {
listing.publisher
}
span(.custom(name: "aria-hidden", value: "true")) {
"/"
}
span(
.class("breadcrumb__current"),
.custom(name: "aria-current", value: "page")
) {
listing.title
}
}
}
/// The price and the actions. Every action is gated: signed out, each leads to the sign-in screen and back here.
var buyBox: some HTML {
div(.class("card buy-box")) {
div(.class("buy-box__head")) {
div(.class("buy-box__price")) {
span(.class("type-price")) {
listing.priceCents.euros
}
span(.class("type-body-sm buy-box__terms")) {
"+ \(listing.shippingCents.euros) tracked shipping to NL"
listing.acceptsOffers ? " · accepts offers" : ""
}
}
a(
.class("button button--sm"),
.href(signIn)
) {
Icon(.bookmark, size: 16)
"Save"
}
}
div(.class("buy-box__actions")) {
a(
.class("button button--primary button--lg"),
.href(signIn)
) {
"Buy now"
}
if listing.acceptsOffers {
a(
.class("button button--outline button--lg"),
.href(signIn)
) {
"Make an offer"
}
}
a(
.class("button button--bare button--lg"),
.href(signIn)
) {
Icon(.message, size: 16)
"Message seller"
}
}
p(.class("gate-note")) {
Icon(.lock, size: 16)
span {
"Buying, offers, saving and messages need a signed-in account. "
a(
.class("gate-note__link"),
.href(signIn)
) {
"Sign in or join with a passkey"
}
" — browsing stays open to everyone."
}
}
}
}
/// The declared details, leaving out what the listing does not state.
var details: [(term: String, value: String)] {
var grade = listing.chip.label
if let certNumber = listing.certNumber {
grade += " · cert \(certNumber)"
} else if listing.grade != nil {
grade += " · graded by the seller against the public grading guide"
}
return [
("Title", listing.seriesTitle),
("Issue", listing.issueLabel),
("Publisher", listing.publisher),
("Printing", listing.printingDetail),
(listing.grade == nil ? "Condition" : "Grade", grade),
("Page quality", listing.pageQuality),
("Key notes", listing.keyNotes),
("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"),
].compactMap { term, value in
value.map { (term, $0) }
}
}
var seller: some HTML {
div(.class("seller")) {
span(.class("monogram monogram--lg")) {
listing.shop.monogram
}
div(.class("seller__text")) {
div(.class("seller__name")) {
a(.href(listing.shop.path)) {
listing.shop.name
}
if listing.shop.isVerified {
span(.class("verified")) {
Icon(.check, size: 12)
"Verified shop"
}
}
}
span(.class("type-caption")) {
listing.shop.standing
}
}
a(
.class("button button--outline button--sm"),
.href(listing.shop.path)
) {
"Visit shop"
}
}
}
/// The sign-in screen, returning to this listing.
var signIn: String {
.Route.signIn(returningTo: listing.path)
}
}
@@ -0,0 +1,352 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The search results screen: the matching listings beside the filter rail.
struct SearchPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The page of listings shown.
let listings: [Listing]
/// The number of listings matching the search, across every page.
let matchCount: Int
/// The page shown and the number of pages.
let pages: (current: Int, total: Int)
/// The search the screen answers.
let parameters: SearchParameters
/// Every publisher a listing can be filtered by.
let publishers: [String]
/// The number of shops the matching listings come from.
let shopCount: Int
// MARK: Initializers
/// Creates the search results screen for a search and the listings matching it.
/// - Parameters:
/// - parameters: the search the screen answers.
/// - matches: every listing matching the search; the screen shows the page the parameters ask for.
/// - publishers: every publisher a listing can be filtered by.
/// - 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(
parameters: SearchParameters,
matches: [Listing],
publishers: [String],
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
let slice = Pagination.slice(
matches,
page: parameters.page,
size: SearchParameters.pageSize
)
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = slice.items
self.locale = .init(identifier: "en")
self.matchCount = matches.count
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.publishers = publishers
self.shopCount = Set(matches.map(\.shop.slug)).count
}
}
// MARK: - Page
extension SearchPage: Page {
// MARK: Constants
/// The path the page is served at.
static let path = String.Route.search
/// The identifier tying the sort select to the filter form.
private static let formID = "filters"
// MARK: Computed
var content: some HTML {
SiteLayout(
active: formats.count == 1 ? formats.first : nil,
query: parameters.value(.query)
) {
div(.class("page search")) {
div(.class("search__head")) {
div(.class("search__heading")) {
p(.class("type-eyebrow")) {
formats.isEmpty ? "All formats" : formats.map(\.label).joined(separator: " · ")
}
h1(.class("type-h1")) {
heading
}
p(.class("type-body-sm search__count")) {
matchCount == 1 ? "1 listing" : "\(matchCount) listings"
shopCount == 1 ? " from 1 shop" : " from \(shopCount) shops"
}
}
SortSelect(
form: Self.formID,
selected: parameters.filter.sort
)
}
div(.class("search__body")) {
filters
div(.class("search__results")) {
if !parameters.pills.isEmpty {
ul(.class("search__pills")) {
for pill in parameters.pills {
li {
a(
.class("pill pill--active"),
.href(pill.removed.path(Self.path)),
.custom(
name: "aria-label",
value: "Remove filter: \(pill.label)"
)
) {
pill.label
span(.custom(name: "aria-hidden", value: "true")) {
" ×"
}
}
}
}
}
}
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"No listings match"
}
p(.class("type-body-sm")) {
"Try fewer filters, or let listings ship from anywhere."
}
a(
.class("button button--outline"),
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
) {
"Clear all filters"
}
}
} else {
div(.class("card-grid card-grid--search")) {
for listing in listings {
ListingCard(listing, context: .search)
}
}
}
Pagination(
current: pages.current,
total: pages.total
) { [parameters] in
parameters.setting(.page, to: "\($0)").path(Self.path)
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.search]
}
var title: String {
"\(heading) — CCN"
}
}
// MARK: - Helpers
private extension SearchPage {
// MARK: Computed
/// The filter rail: a plain `GET` form inside a disclosure, which the stylesheet keeps open on desktop.
var filters: some HTML {
details(.class("filters")) {
Elementary.summary(.class("filters__toggle button button--outline")) {
Icon(.filter, size: 15)
parameters.pills.isEmpty ? "Filters" : "Filters · \(parameters.pills.count)"
}
form(
.class("filters__form"),
.id(Self.formID),
.action(Self.path),
.method(.get)
) {
div(.class("filters__head")) {
h2(.class("type-eyebrow filters__title")) {
"Filters"
}
a(
.class("filters__clear"),
.href(Self.path)
) {
"Clear all"
}
}
if let query = parameters.value(.query) {
input(
.type(.hidden),
.name(SearchParameters.Name.query.rawValue),
.value(query)
)
}
group("Format") {
ForEach(Format.allCases) { format in
checkbox(.format, value: format.rawValue, label: format.label)
}
}
group("Grading") {
checkbox(.grading, value: SearchParameters.Grading.slabbed.rawValue, label: "Slabbed (CGC, CBCS, PGX)")
checkbox(.grading, value: SearchParameters.Grading.raw.rawValue, label: "Raw, seller-graded")
div(.class("filters__range")) {
field(.gradeMin, label: "Min", placeholder: "0.5", isLabelVisible: true)
field(.gradeMax, label: "Max", placeholder: "10", isLabelVisible: true)
}
}
group("Printing") {
checkbox(.firstPrint, value: "1", label: "1st print only")
}
group("Publisher") {
ForEach(publishers) { publisher in
checkbox(.publisher, value: publisher, label: publisher)
}
}
group("Price") {
div(.class("filters__range")) {
field(.priceMin, label: "Minimum price", placeholder: "€ min")
span(.class("type-small")) {
"to"
}
field(.priceMax, label: "Maximum price", placeholder: "€ max")
}
}
group("Ships from") {
div(.class("segmented")) {
for region in ListingFilter.Region.allCases {
label(.class("segmented__option")) {
input(
.type(.radio),
.name(SearchParameters.Name.shipsFrom.rawValue),
.value(region.rawValue)
)
.attributes(.checked, when: region == parameters.filter.shipsFrom)
span {
region == .anywhere ? "Anywhere" : region.rawValue.uppercased()
}
}
}
}
span(.class("type-small")) {
"Outside the EU, import VAT may apply."
}
}
group("Key issues") {
checkbox(.keys, value: "1", label: "Key issues only")
}
group("Seller") {
checkbox(.offers, value: "1", label: "Accepts offers")
checkbox(.verified, value: "1", label: "Verified shop")
}
button(
.class("button button--dark button--block"),
.type(.submit)
) {
"Apply filters"
}
}
}
}
/// The formats the search is narrowed to.
var formats: [Format] {
parameters
.values(.format)
.compactMap(Format.init)
}
/// The screen's heading: the search text in quotes, else the one format searched, else every listing.
var heading: String {
if let query = parameters.value(.query) {
return "“\(query)”"
}
return formats.count == 1 ? formats[0].label : "All listings"
}
// MARK: Methods
/// A checkbox, ticked when the search carries its pair.
func checkbox(
_ name: SearchParameters.Name,
value: String,
label text: String
) -> some HTML {
label(.class("check")) {
input(
.type(.checkbox),
.name(name.rawValue),
.value(value)
)
.attributes(.checked, when: parameters.has(name, value: value))
text
}
}
/// A short text field, filled with the search's value for its name.
func field(
_ name: SearchParameters.Name,
label text: String,
placeholder: String,
isLabelVisible: Bool = false
) -> some HTML {
label(.class("filters__field")) {
span(.class(isLabelVisible ? "type-small" : "visually-hidden")) {
text
}
input(
.class("input input--sm"),
.type(.text),
.name(name.rawValue),
.value(parameters.value(name) ?? ""),
.placeholder(placeholder),
.custom(name: "inputmode", value: "decimal")
)
}
}
/// A titled group of filter controls.
func group(
_ title: String,
@HTMLBuilder content: () -> some HTML
) -> some HTML {
fieldset(.class("filters__group")) {
legend(.class("type-label")) {
title
}
content()
}
}
}
@@ -0,0 +1,325 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// A seller's public shop: the one screen a signed-out visitor gets in full, with every action leading to the sign-in screen.
struct ShopPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The number of listings the shop has in each format, before the quick filters.
let counts: [Format: Int]
/// The page of listings shown.
let listings: [Listing]
/// The page shown and the number of pages.
let pages: (current: Int, total: Int)
/// The tab, quick filters, and sort order applied.
let parameters: SearchParameters
/// The shop shown.
let shop: Shop
// MARK: Initializers
/// Creates the public screen of a shop.
/// - Parameters:
/// - shop: the shop shown.
/// - inventory: every listing of the shop, counted per format on the tabs.
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
/// - parameters: the tab, quick filters, and sort order applied.
/// - 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(
shop: Shop,
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
let slice = Pagination.slice(
matches,
page: parameters.page,
size: Self.pageSize
)
self.analytics = analytics
self.assetVersion = assetVersion
self.counts = Dictionary(grouping: inventory.compactMap(\.kind), by: \.self).mapValues(\.count)
self.listings = slice.items
self.locale = .init(identifier: "en")
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.shop = shop
}
}
// MARK: - Page
extension ShopPage: Page {
// MARK: Constants
/// The identifier tying the sort select to its form.
private static let formID = "shop-sort"
/// The number of listings on a page: two rows of the desktop grid.
private static let pageSize = 10
// MARK: Computed
var content: some HTML {
SiteLayout {
div(.class("page shop")) {
section(.class("shop__head")) {
span(.class("monogram monogram--xl")) {
shop.monogram
}
div(.class("shop__intro")) {
div(.class("shop__name")) {
h1(.class("type-h1")) {
shop.name
}
if shop.isVerified {
span(.class("chip chip--good")) {
Icon(.check, size: 12)
"Verified shop"
}
}
}
p(.class("type-body-sm shop__standing")) {
"\(shop.standing) · \(shop.shipsNote)"
}
p(.class("shop__about")) {
shop.about
}
ul(.class("shop__stats")) {
for stat in stats {
li {
strong {
stat.value
}
" \(stat.label)"
}
}
}
}
div(.class("shop__actions")) {
a(
.class("button button--primary"),
.href(signIn)
) {
Icon(.message, size: 16)
"Message shop"
}
a(
.class("button button--outline"),
.href(signIn)
) {
"Follow shop"
}
span(.class("type-small shop__gate")) {
Icon(.key, size: 13)
"Both need a passkey sign-in"
}
}
}
nav(
.class("tabs"),
.custom(name: "aria-label", value: "Shop sections")
) {
tab("All", count: counts.values.reduce(0, +), format: nil)
ForEach(Format.allCases.filter { counts[$0] != nil }) { format in
tab(format.label, count: counts[format] ?? 0, format: format)
}
}
div(.class("shop__toolbar")) {
ul(.class("shop__quick")) {
for quick in QuickFilter.allCases {
li {
a(
.class("pill"),
.href(quick.toggled(in: parameters).path(shop.path))
) {
quick.label
}
.attributes(
.custom(name: "aria-pressed", value: "true"),
when: quick.isOn(in: parameters)
)
}
}
}
form(
.class("shop__sort"),
.id(Self.formID),
.action(shop.path),
.method(.get)
) {
for item in parameters.removing(.sort).items {
input(
.type(.hidden),
.name(item.name),
.value(item.value)
)
}
SortSelect(
form: Self.formID,
selected: parameters.filter.sort
)
button(
.class("button button--sm js-hidden"),
.type(.submit)
) {
"Sort"
}
}
}
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing here right now"
}
p(.class("type-body-sm")) {
"No listing in this shop matches those filters."
}
a(
.class("button button--outline"),
.href(shop.path)
) {
"See everything"
}
}
} else {
div(.class("card-grid")) {
for listing in listings {
ListingCard(listing, context: .shop)
}
}
}
Pagination(
current: pages.current,
total: pages.total
) { [parameters, shop] in
parameters.setting(.page, to: "\($0)").path(shop.path)
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.shop]
}
var summary: String? {
"\(shop.name), \(shop.location): \(shop.about)"
}
var title: String {
"\(shop.name) — CCN"
}
}
// MARK: - Enumerations
private extension ShopPage {
/// A one-tap filter above the shop's grid.
enum QuickFilter: CaseIterable {
case slabbed
case underFifty
case offers
case keys
/// The filter's name on its pill.
var label: String {
switch self {
case .slabbed: "Slabbed only"
case .underFifty: "Under €50"
case .offers: "Accepts offers"
case .keys: "Keys"
}
}
/// The parameter pair the filter stands for.
var pair: (name: SearchParameters.Name, value: String) {
switch self {
case .slabbed: (.grading, SearchParameters.Grading.slabbed.rawValue)
case .underFifty: (.priceMax, "50")
case .offers: (.offers, "1")
case .keys: (.keys, "1")
}
}
/// Whether the parameters carry the filter.
func isOn(
in parameters: SearchParameters
) -> Bool {
parameters.has(pair.name, value: pair.value)
}
/// The parameters with the filter switched.
func toggled(
in parameters: SearchParameters
) -> SearchParameters {
parameters.toggling(pair.name, value: pair.value)
}
}
}
// MARK: - Helpers
private extension ShopPage {
// MARK: Computed
/// The sign-in screen, returning to this shop.
var signIn: String {
.Route.signIn(returningTo: shop.path)
}
var stats: [(value: String, label: String)] {
[
("\(counts.values.reduce(0, +))", "listings"),
(shop.soldCount.formatted(.number.locale(.init(identifier: "en"))), "sold"),
(shop.handlingDays == 1 ? "1 day" : "\(shop.handlingDays) days", "handling"),
(shop.returnsNote, ""),
]
}
// MARK: Methods
/// A tab narrowing the grid to one format, or to all of them.
func tab(
_ label: String,
count: Int,
format: Format?
) -> some HTML {
let target = format.map { parameters.setting(.format, to: $0.rawValue) } ?? parameters.removing(.format)
return a(
.class("tabs__tab"),
.href(target.path(shop.path))
) {
"\(label) · \(count)"
}
.attributes(
.custom(name: "aria-current", value: "page"),
when: parameters.value(.format) == format?.rawValue
)
}
}
@@ -0,0 +1,321 @@
import Foundation
import Persistence
/// The query parameters of a listing search: what the filter is read from, and what the links that change it are built with.
struct SearchParameters: Sendable, Equatable {
// MARK: Properties
/// The name-value pairs, in the order given.
let items: [Item]
// MARK: Initializers
/// Creates the parameters from their pairs.
/// - Parameter items: the name-value pairs.
init(
items: [Item] = []
) {
self.items = items
}
/// Parses the parameters out of a URL's raw query, dropping the pairs left empty by an unfilled form field.
/// - Parameter query: the raw query, without the leading question mark, or `nil` for none.
init(
query: String?
) {
self.items = (query ?? "")
.split(separator: "&")
.compactMap { pair in
let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
let decoded = parts.map {
$0.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? ""
}
guard
decoded.count == 2,
!decoded[1].trimmingCharacters(in: .whitespaces).isEmpty
else {
return nil
}
return .init(name: decoded[0], value: decoded[1].trimmingCharacters(in: .whitespaces))
}
}
}
// MARK: - Structs
extension SearchParameters {
/// A query parameter.
struct Item: Sendable, Equatable {
/// The parameter's name.
let name: String
/// The parameter's value.
let value: String
}
/// An active criterion, shown as a pill that links to the search without it.
struct Pill: Sendable, Equatable {
/// The criterion in a few words.
let label: String
/// The parameters of the search without the criterion.
let removed: SearchParameters
}
}
// MARK: - Extensions
extension SearchParameters {
// MARK: Constants
/// The number of listings on a page.
static let pageSize = 12
// MARK: Computed
/// The filter the parameters describe. Listings ship from the EU unless the parameters say otherwise.
var filter: ListingFilter {
.init(
query: value(.query),
formats: values(.format),
isSlabbed: slabbed,
gradeMin: value(.gradeMin).flatMap(Double.init),
gradeMax: value(.gradeMax).flatMap(Double.init),
firstPrintOnly: has(.firstPrint),
keysOnly: has(.keys),
publishers: values(.publisher),
priceMin: value(.priceMin).flatMap(Int.init).map { $0 * 100 },
priceMax: value(.priceMax).flatMap(Int.init).map { $0 * 100 },
shipsFrom: value(.shipsFrom).flatMap(ListingFilter.Region.init) ?? .eu,
acceptsOffers: has(.offers),
verifiedShops: has(.verified),
sort: value(.sort).flatMap(ListingFilter.Sort.init) ?? .newest
)
}
/// The page asked for, counted from one.
var page: Int {
max(value(.page).flatMap(Int.init) ?? 1, 1)
}
/// The active criteria, in the order of the filter rail.
var pills: [Pill] {
var pills = [Pill]()
let filter = filter
for format in values(.format) {
pills.append(.init(
label: Format(rawValue: format)?.label ?? format,
removed: removing(.format, value: format)
))
}
if let isSlabbed = filter.isSlabbed {
pills.append(.init(
label: isSlabbed ? "Slabbed" : "Raw",
removed: removing(.grading)
))
}
if filter.gradeMin != nil || filter.gradeMax != nil {
pills.append(.init(
label: "Grade \((filter.gradeMin ?? 0.5).gradeLabel)–\((filter.gradeMax ?? 10).gradeLabel)",
removed: removing(.gradeMin).removing(.gradeMax)
))
}
if filter.firstPrintOnly {
pills.append(.init(label: "1st print only", removed: removing(.firstPrint)))
}
for publisher in filter.publishers {
pills.append(.init(label: publisher, removed: removing(.publisher, value: publisher)))
}
if filter.priceMin != nil || filter.priceMax != nil {
let label = switch (filter.priceMin, filter.priceMax) {
case (let min?, let max?): "\(min.euros)–\(max.euros)"
case (let min?, nil): "From \(min.euros)"
case (_, let max): "Up to \((max ?? 0).euros)"
}
pills.append(.init(label: label, removed: removing(.priceMin).removing(.priceMax)))
}
if filter.shipsFrom != .anywhere {
pills.append(.init(
label: "Ships from \(filter.shipsFrom.rawValue.uppercased())",
removed: setting(.shipsFrom, to: ListingFilter.Region.anywhere.rawValue)
))
}
if filter.keysOnly {
pills.append(.init(label: "Key issues", removed: removing(.keys)))
}
if filter.acceptsOffers {
pills.append(.init(label: "Accepts offers", removed: removing(.offers)))
}
if filter.verifiedShops {
pills.append(.init(label: "Verified shops", removed: removing(.verified)))
}
return pills
}
/// The parameters as a URL query with its leading question mark, or nothing when there are none.
var queryString: String {
guard !items.isEmpty else {
return ""
}
return "?" + items
.map { "\(Self.escape($0.name))=\(Self.escape($0.value))" }
.joined(separator: "&")
}
// MARK: Methods
/// The filter the parameters describe within one shop, wherever it ships from.
/// - Parameter slug: the shop's URL slug.
func filter(
inShop slug: String
) -> ListingFilter {
var filter = filter
filter.shipsFrom = .anywhere
filter.shop = slug
return filter
}
/// Whether any parameter has the name.
func has(
_ name: Name
) -> Bool {
value(name) != nil
}
/// Whether a parameter has the name and value.
func has(
_ name: Name,
value: String
) -> Bool {
values(name).contains(value)
}
/// The given path followed by the parameters.
func path(
_ path: String
) -> String {
path + queryString
}
/// The parameters without those of the name — or, given a value, without that one pair — and back on the first page.
func removing(
_ name: Name,
value: String? = nil
) -> Self {
.init(items: items.filter {
$0.name != Name.page.rawValue && !($0.name == name.rawValue && (value == nil || $0.value == value))
})
}
/// The parameters with the name set to the one value. Setting anything but the page returns to the first page.
func setting(
_ name: Name,
to value: String
) -> Self {
let kept = name == .page
? items.filter { $0.name != name.rawValue }
: removing(name).items
return .init(items: kept + [.init(name: name.rawValue, value: value)])
}
/// The parameters with the pair removed when present, and set when absent.
func toggling(
_ name: Name,
value: String
) -> Self {
has(name, value: value)
? removing(name, value: value)
: setting(name, to: value)
}
/// The first value of the name, or `nil` when no parameter has it.
func value(
_ name: Name
) -> String? {
values(name).first
}
/// Every value of the name.
func values(
_ name: Name
) -> [String] {
items
.filter { $0.name == name.rawValue }
.map(\.value)
}
}
// MARK: - Enumerations
extension SearchParameters {
/// The names of the parameters a search reads.
enum Name: String, Sendable {
case firstPrint = "first-print"
case format
case gradeMax = "grade-max"
case gradeMin = "grade-min"
case grading
case keys
case offers
case page
case priceMax = "price-max"
case priceMin = "price-min"
case publisher
case query = "q"
case shipsFrom = "ships-from"
case sort
case verified
}
/// The values of the `grading` parameter.
enum Grading: String, Sendable {
case raw
case slabbed
}
}
// MARK: - Helpers
private extension SearchParameters {
// MARK: Computed
/// `true` for slabbed only, `false` for raw only, `nil` when the parameters name both or neither.
var slabbed: Bool? {
let gradings = Set(values(.grading).compactMap(Grading.init))
return gradings.count == 1
? gradings.contains(.slabbed)
: nil
}
// MARK: Methods
/// Percent-escapes a name or value for a URL query.
static func escape(
_ string: String
) -> String {
string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(.init(charactersIn: "&=+?#"))) ?? ""
}
}
@@ -58,9 +58,12 @@ extension StaticFile {
.icon192,
.icon512,
.index,
.listing,
.notFound,
.robots,
.search,
.shared,
.shop,
.site,
.sitemap,
]
@@ -128,15 +131,24 @@ extension StaticFile {
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
static let index = Self("index", as: .css, .js)
/// The `css/listing.css` stylesheet for the listing detail page.
static let listing = Self("listing", as: .css)
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
static let notFound = Self("not-found", as: .css, .js)
/// The `robots.txt` crawler directives.
static let robots = Self("robots", as: .txt)
/// The `css/search.css` stylesheet for the search results page.
static let search = Self("search", as: .css)
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
static let shared = Self("shared", as: .css, .js)
/// The `css/shop.css` stylesheet for the shop page.
static let shop = Self("shop", as: .css)
/// The `site.webmanifest` web application manifest.
static let site = Self("site", as: .webmanifest)
@@ -0,0 +1,172 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the public catalogue: the search results, a listing's detail, and a seller's shop.
///
/// Unlike the landing page, these screens depend on the request, so each is rendered per request rather than cached.
///
/// - 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 CatalogueController<Context: RequestContext> {
// MARK: Properties
/// The analytics tracker the pages embed, or `nil` to omit it.
private let analytics: Analytics?
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
private let assetVersion: String?
/// The repository the shops and listings are read through.
private let repository: CatalogueRepository
// MARK: Initializers
/// Creates a catalogue controller.
/// - Parameters:
/// - repository: the repository the shops and listings are read through.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
repository: CatalogueRepository,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.repository = repository
}
}
// MARK: - RouterController
extension CatalogueController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(
.Catalogue.search,
use: search
)
routes.get(
.Catalogue.listing,
use: listing
)
routes.get(
.Catalogue.shop,
use: shop
)
return routes
}
}
// MARK: - Helpers
private extension CatalogueController {
// MARK: Constants
/// The number of other listings shown under a listing: one line of the desktop grid.
static var relatedCount: Int { 5 }
// MARK: Methods
/// Handles a request for a listing's detail; an unknown slug is not found.
@Sendable
func listing(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let listing = try await repository.listing(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let related = try await repository
.listings(matching: .init(shop: listing.shop.slug))
.filter { $0.slug != listing.slug }
.prefix(Self.relatedCount)
return .init { [analytics, assetVersion] in
ListingPage(
listing: listing,
related: Array(related),
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for the search results, filtered through the query parameters.
@Sendable
func search(
request: Request,
context: Context
) async throws -> HTMLResponse {
let parameters = SearchParameters(query: request.uri.query)
let matches = try await repository.listings(matching: parameters.filter)
let publishers = try await repository.publishers()
return .init { [analytics, assetVersion] in
SearchPage(
parameters: parameters,
matches: matches,
publishers: publishers,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for a seller's shop; an unknown slug is not found.
@Sendable
func shop(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let shop = try await repository.shop(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let parameters = SearchParameters(query: request.uri.query)
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
return .init { [analytics, assetVersion] in
ShopPage(
shop: shop,
inventory: inventory,
matches: matches,
parameters: parameters,
assetVersion: assetVersion,
analytics: analytics
)
}
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``CatalogueController`` route paths.
enum Catalogue {
/// The path of a listing's detail.
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
/// The path of the search results.
static let search: RouterPath = .init(.Route.search)
/// The path of a seller's shop.
static let shop: RouterPath = .init(.Route.shop(":\(String.slug)"))
}
}
private extension String {
/// The name of the route parameter carrying a listing's or a shop's slug.
static let slug = "slug"
}
@@ -31,10 +31,12 @@ public struct RootController<Context: RequestContext> {
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
/// - listings: the listings the landing page shows in its rows; none by default.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
self.negotiate = .init(bundle: .module)
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
@@ -47,7 +49,8 @@ public struct RootController<Context: RequestContext> {
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
analytics: analytics,
listings: listings
)
}
}
@@ -0,0 +1,38 @@
import Persistence
/// The listings the landing page shows, fetched once at startup: the page is rendered once and cached.
public struct HomeListings: Sendable {
// MARK: Constants
/// The number of listings in a row: one line of the desktop grid.
public static let rowSize = 5
// MARK: Properties
/// The newest manga, trade paperbacks, and hardcovers.
public let collected: [Listing]
/// The newest single issues.
public let singleIssues: [Listing]
// MARK: Initializers
/// Picks the landing page's rows out of a list of listings.
/// - Parameter listings: the listings to pick from, newest first; none by default.
public init(
from listings: [Listing] = []
) {
self.collected = Array(
listings
.filter { $0.kind != .singleIssues }
.prefix(Self.rowSize)
)
self.singleIssues = Array(
listings
.filter { $0.kind == .singleIssues }
.prefix(Self.rowSize)
)
}
}
+54
View File
@@ -58,6 +58,60 @@ struct AppTests {
}
}
@Test
func `landing page to list the seeded catalogue`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(body.contains("New in single issues"))
#expect(body.contains(#"href="/listing/nightward-1-cgc-9-8""#))
}
}
}
@Test(arguments: [
"/search?q=nightward",
"/listing/nightward-1-cgc-9-8",
"/shop/kestrel-row-comics",
])
func `catalogue page to be served with the security headers`(
uri: String
) async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: uri,
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
#expect(response.headers[.contentSecurityPolicy] != nil)
}
}
}
@Test
func `error page to be served for an unknown listing`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/listing/missing",
method: .get
) { response in
#expect(response.status == .notFound)
#expect(String(buffer: response.body).contains("Page Not Found"))
}
}
}
@Test
func `landing page to answer a head request`() async throws {
try await app(
@@ -0,0 +1,99 @@
import Elementary
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Catalogue components",
.tags(.component)
)
struct CatalogueComponentsTests {
// MARK: Cover tests
@Test
func `letters a cover line by line on its tone`() {
let html = Cover(Listing.stub().cover, modifier: "cover--hero").render()
#expect(html.contains(#"class="cover cover--navy cover--hero""#))
#expect(html.contains("<span>NIGHT</span><span>WARD</span>"))
#expect(html.contains(#"class="cover__number">1<"#))
#expect(html.contains(#"aria-hidden="true""#))
}
@Test
func `sets a volume marker smaller than an issue number`() {
let html = Cover(.init(tone: "plum", title: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 4")).render()
#expect(html.contains(#"class="cover__number cover__number--text">VOL 4<"#))
}
// MARK: Chip tests
@Test
func `checks a certified chip`() {
#expect(Chip("CGC 9.8", tone: .good, isCertified: true).render().hasPrefix(#"<span class="chip chip--good"><svg"#))
#expect(Chip("Raw · NM").render() == #"<span class="chip">Raw · NM</span>"#)
}
// MARK: ListingCard tests
@Test
func `links a card to its listing and gates saving behind the sign-in`() {
let html = ListingCard(.stub()).render()
#expect(html.contains(#"href="/listing/nightward-1">Nightward #1</a>"#))
#expect(html.contains(#"href="/sign-in?return=/listing/nightward-1""#))
#expect(html.contains("€240"))
#expect(html.contains("CGC 9.8"))
}
@Test
func `names the shop by the card's context`() {
#expect(ListingCard(.stub(), context: .browse).render().contains(">Kestrel Row Comics</a>"))
#expect(ListingCard(.stub(), context: .search).render().contains(">Kestrel Row Comics · NL · ship €6</a>"))
#expect(!ListingCard(.stub(), context: .shop).render().contains("/shop/kestrel-row-comics"))
}
// MARK: Pagination tests
@Test
func `renders no page links for a single page`() {
#expect(Pagination(current: 1, total: 1) { "/?page=\($0)" }.render().isEmpty)
}
@Test
func `marks the current page and offers the next until the last`() {
let middle = Pagination(current: 2, total: 3) { "/?page=\($0)" }.render()
let last = Pagination(current: 3, total: 3) { "/?page=\($0)" }.render()
#expect(middle.contains(#"href="/?page=2" aria-current="page""#))
#expect(middle.contains(#"href="/?page=3">Next</a>"#))
#expect(!last.contains("Next"))
}
@Test(arguments: zip(
[1, 2, 3, 9, -1],
[[1, 2], [3, 4], [5], [5], [1, 2]]
))
func `slices a page out of a list, clamping the page`(
page: Int,
expects items: [Int]
) {
let slice = Pagination.slice([1, 2, 3, 4, 5], page: page, size: 2)
#expect(slice.items == items)
#expect(slice.total == 3)
}
@Test
func `counts one page for an empty list`() {
let slice = Pagination.slice([Int](), page: 1, size: 12)
#expect(slice.items.isEmpty)
#expect(slice.current == 1)
#expect(slice.total == 1)
}
}
@@ -0,0 +1,82 @@
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Listing+Display extension",
.tags(.extensionTests)
)
struct ListingDisplayTests {
// MARK: Computed tests
@Test(arguments: zip(
[9.8, 9.0, 8.5, 7.0, 6.5],
[Chip.Tone.good, .good, .neutral, .neutral, .warn]
))
func `tones a slab's chip by its grade`(
grade: Double,
expects tone: Chip.Tone
) {
let chip = Listing.stub(grade: grade).chip
#expect(chip.label == "CGC \(grade)")
#expect(chip.tone == tone)
#expect(chip.isCertified)
}
@Test
func `marks a raw issue's chip as seller-graded`() {
let chip = Listing.stub(grader: nil, grade: 9.4, condition: "NM").chip
#expect(chip.label == "Raw · NM")
#expect(chip.tone == .neutral)
#expect(!chip.isCertified)
}
@Test
func `states a collected edition's condition alone`() {
#expect(Listing.stub(format: "manga", grader: nil, grade: nil, condition: "Like new").chip.label == "Like new")
}
@Test
func `leads a collected edition's summary with its format`() {
#expect(Listing.stub().summary == "Meridian Comics · 2019 · 1st print")
#expect(Listing.stub(format: "hardcovers").summary == "Hardcover · Meridian Comics · 2019 · 1st print")
}
@Test
func `derives the paths from the slugs`() {
#expect(Listing.stub().path == "/listing/nightward-1")
#expect(Shop.stub().path == "/shop/kestrel-row-comics")
}
@Test(arguments: zip(
["Kestrel Row Comics", "Inkwell & Staple", "Silverfish"],
["KR", "IS", "S"]
))
func `monogram`(
of name: String,
expects monogram: String
) {
#expect(Shop.stub(name: name).monogram == monogram)
}
@Test(arguments: zip(
[24_000, 1_250, 5, 0],
["€240", "€12.50", "€0.05", "€0"]
))
func `euros`(
of cents: Int,
expects price: String
) {
#expect(cents.euros == price)
}
@Test
func `returns to the gated action's path after sign-in`() {
#expect(String.Route.signIn(returningTo: "/shop/a?keys=1&page=2") == "/sign-in?return=/shop/a%3Fkeys%3D1%26page%3D2")
}
}
@@ -0,0 +1,167 @@
import Elementary
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Catalogue pages",
.tags(.page)
)
struct CataloguePagesTests {
// MARK: IndexPage tests
@Test
func `landing page fills its rows and fans its covers from the listings`() {
let singles = (1...6).map { Listing.stub(slug: "single-\($0)", title: "Single \($0)") }
let manga = Listing.stub(slug: "manga-1", title: "Manga 1", format: "manga")
let html = IndexPage(
locale: .init(identifier: "en"),
listings: .init(from: singles + [manga])
).render()
#expect(html.contains("New in single issues"))
#expect(html.contains("Single 5"))
#expect(!html.contains("Single 6"))
#expect(html.contains("Manga 1"))
#expect(html.contains("hero__covers"))
#expect(html.contains(#"href="/search?format=manga&amp;format=trade-paperbacks&amp;format=hardcovers""#))
}
@Test
func `landing page drops its rows without listings`() {
let html = IndexPage(locale: .init(identifier: "en")).render()
#expect(!html.contains("New in single issues"))
#expect(!html.contains("hero__covers"))
#expect(html.contains("Passkeys, not passwords"))
}
// MARK: SearchPage tests
@Test
func `search page heads the results with the query and ticks the applied filters`() {
let html = SearchPage(
parameters: .init(query: "q=nightward+1&format=single-issues&publisher=Meridian+Comics"),
matches: [.stub(), .stub(slug: "b", shop: .stub(slug: "other", name: "Other"))],
publishers: ["Blackline", "Meridian Comics"]
).render()
#expect(html.contains("<title>“nightward 1” — CCN</title>"))
#expect(html.contains("2 listings"))
#expect(html.contains("from 2 shops"))
#expect(html.contains(#"name="q" value="nightward 1""#))
#expect(html.contains(#"value="Meridian Comics" checked"#))
#expect(!html.contains(#"value="Blackline" checked"#))
#expect(html.contains(#"aria-label="Remove filter: Meridian Comics""#))
#expect(html.contains("/css/search.css"))
}
@Test
func `search page pages through more matches than fit`() {
let matches = (1...13).map { Listing.stub(slug: "l-\($0)", title: "Listing \($0).") }
let html = SearchPage(
parameters: .init(query: "page=2"),
matches: matches,
publishers: []
).render()
#expect(html.contains("13 listings"))
#expect(html.contains("Listing 13."))
#expect(!html.contains("Listing 12."))
#expect(html.contains(#"href="/search?page=2" aria-current="page""#))
}
@Test
func `search page offers a way out of an empty result`() {
let html = SearchPage(
parameters: .init(query: "q=nothing"),
matches: [],
publishers: []
).render()
#expect(html.contains("No listings match"))
#expect(html.contains(#"href="/search?ships-from=anywhere""#))
}
// MARK: ListingPage tests
@Test
func `listing page states the copy and gates every action behind the sign-in`() {
let html = ListingPage(
listing: .stub(keyNotes: "First appearance"),
related: [.stub(slug: "nightward-2", title: "Nightward #2")]
).render()
#expect(html.contains("<title>Nightward #1 · CGC 9.8 — CCN</title>"))
#expect(html.contains("+ €6 tracked shipping to NL · accepts offers"))
#expect(html.contains("CGC 9.8 · cert 4312087-002"))
#expect(html.contains("First appearance"))
#expect(html.contains("More from Kestrel Row Comics"))
#expect(html.contains("Nightward #2"))
for action in ["Buy now", "Make an offer", "Message seller", "Save"] {
#expect(html.contains(action))
}
#expect(html.components(separatedBy: #"href="/sign-in?return=/listing/nightward-1""#).count - 1 >= 5)
}
@Test
func `listing page leaves out what the listing does not offer or state`() {
let html = ListingPage(
listing: .stub(grader: nil, grade: nil, condition: "Sealed", acceptsOffers: false),
related: []
).render()
#expect(!html.contains("Make an offer"))
#expect(!html.contains("Key notes"))
#expect(!html.contains("More from"))
#expect(html.contains("<dt>Condition</dt>"))
}
// MARK: ShopPage tests
@Test
func `shop page counts its tabs from the whole inventory and lists the matches`() {
let inventory = [
Listing.stub(),
.stub(slug: "b", title: "Second"),
.stub(slug: "c", title: "A manga", format: "manga"),
]
let html = ShopPage(
shop: .stub(),
inventory: inventory,
matches: [inventory[2]],
parameters: .init(query: "format=manga&keys=1")
).render()
#expect(html.contains("<title>Kestrel Row Comics — CCN</title>"))
#expect(html.contains("All · 3"))
#expect(html.contains("Single issues · 2"))
#expect(html.contains(#"href="/shop/kestrel-row-comics?keys=1&amp;format=manga" aria-current="page""#))
#expect(!html.contains("Hardcovers ·"))
#expect(html.contains("A manga"))
#expect(!html.contains("Second"))
#expect(html.contains(#"href="/shop/kestrel-row-comics?format=manga" aria-pressed="true""#))
#expect(html.contains(#"type="hidden" name="format" value="manga""#))
}
// MARK: Shared tests
@Test
func `no catalogue page renders an inline style`() {
let pages = [
SearchPage(parameters: .init(), matches: [.stub()], publishers: ["Blackline"]).render(),
ListingPage(listing: .stub(), related: [.stub()]).render(),
ShopPage(shop: .stub(), inventory: [.stub()], matches: [.stub()], parameters: .init()).render(),
]
for html in pages {
#expect(!html.contains("style="))
#expect(!html.contains("<style"))
}
}
}
@@ -0,0 +1,118 @@
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"SearchParameters type",
.tags(.type)
)
struct SearchParametersTests {
// MARK: Initializers tests
@Test
func `decodes a form-encoded query and drops its empty fields`() {
let parameters = SearchParameters(query: "q=nightward+1&publisher=Harbour+%26+Vale&price-min=&grade-max=+")
#expect(parameters.items == [
.init(name: "q", value: "nightward 1"),
.init(name: "publisher", value: "Harbour & Vale"),
])
}
@Test
func `has no parameters without a query`() {
#expect(SearchParameters(query: nil).items.isEmpty)
#expect(SearchParameters(query: nil).queryString.isEmpty)
}
// MARK: Computed tests
@Test
func `reads every criterion into the filter`() {
let query = "q=saint&format=manga&format=hardcovers&grading=slabbed&grade-min=6.0&grade-max=9.8&first-print=1"
+ "&keys=1&publisher=Blackline&price-min=10&price-max=50&ships-from=nl&offers=1&verified=1&sort=price-asc"
#expect(SearchParameters(query: query).filter == .init(
query: "saint",
formats: ["manga", "hardcovers"],
isSlabbed: true,
gradeMin: 6,
gradeMax: 9.8,
firstPrintOnly: true,
keysOnly: true,
publishers: ["Blackline"],
priceMin: 1_000,
priceMax: 5_000,
shipsFrom: .nl,
acceptsOffers: true,
verifiedShops: true,
sort: .priceAscending
))
}
@Test
func `ships from the EU, newest first, by default`() {
#expect(SearchParameters().filter == .init(shipsFrom: .eu))
}
@Test
func `matches slabbed and raw alike when both are ticked`() {
#expect(SearchParameters(query: "grading=slabbed&grading=raw").filter.isSlabbed == nil)
#expect(SearchParameters(query: "grading=raw").filter.isSlabbed == false)
}
@Test
func `ignores values it cannot read`() {
let filter = SearchParameters(query: "grade-min=high&price-max=cheap&ships-from=mars&sort=random&page=-3").filter
#expect(filter == .init(shipsFrom: .eu))
#expect(SearchParameters(query: "page=-3").page == 1)
}
@Test
func `offers a pill per active criterion, each linking to the search without it`() {
let parameters = SearchParameters(query: "format=manga&grade-min=6&price-max=50&ships-from=anywhere&page=2")
let pills = parameters.pills
#expect(pills.map(\.label) == ["Manga", "Grade 6.0–10.0", "Up to €50"])
#expect(pills[0].removed.queryString == "?grade-min=6&price-max=50&ships-from=anywhere")
#expect(pills[1].removed.queryString == "?format=manga&price-max=50&ships-from=anywhere")
}
@Test
func `removes the default region by naming every region instead`() {
let pill = SearchParameters().pills.first
#expect(pill?.label == "Ships from EU")
#expect(pill?.removed.queryString == "?ships-from=anywhere")
}
// MARK: Methods tests
@Test
func `keeps the other parameters when turning the page, and returns to the first when changing them`() {
let parameters = SearchParameters(query: "q=saint&page=2")
#expect(parameters.setting(.page, to: "3").queryString == "?q=saint&page=3")
#expect(parameters.setting(.sort, to: "price-asc").queryString == "?q=saint&sort=price-asc")
#expect(parameters.toggling(.keys, value: "1").toggling(.keys, value: "1").queryString == "?q=saint")
}
@Test
func `escapes what it writes back into a path`() {
let parameters = SearchParameters().setting(.publisher, to: "Harbour & Vale")
#expect(parameters.path("/search") == "/search?publisher=Harbour%20%26%20Vale")
#expect(SearchParameters(query: String(parameters.queryString.dropFirst())) == parameters)
}
@Test
func `narrows a shop's filter to the shop, wherever it ships from`() {
let filter = SearchParameters(query: "keys=1").filter(inShop: "longbox-atlantic")
#expect(filter == .init(keysOnly: true, shipsFrom: .anywhere, shop: "longbox-atlantic"))
}
}
@@ -102,9 +102,12 @@ private extension StaticFileTests {
(.icon192, ["icon-192.png"]),
(.icon512, ["icon-512.png"]),
(.index, ["css/index.css", "js/index.js"]),
(.listing, ["css/listing.css"]),
(.notFound, ["css/not-found.css", "js/not-found.js"]),
(.robots, ["robots.txt"]),
(.search, ["css/search.css"]),
(.shared, ["css/shared.css", "js/shared.js"]),
(.shop, ["css/shop.css"]),
(.site, ["site.webmanifest"]),
(.sitemap, ["sitemap.xml"]),
]
@@ -0,0 +1,132 @@
import Hummingbird
import HummingbirdTesting
import Logging
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"CatalogueController controller",
.tags(.controller)
)
struct CatalogueControllerTests {
// MARK: Functional tests
@Test
func `serves the search results narrowed by the query parameters`() async throws {
try await withApp { client in
try await client.execute(
uri: "/search?q=nightward+1&ships-from=anywhere&sort=price-asc",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
#expect(body.contains("4 listings"))
#expect(body.contains("from 4 shops"))
#expect(!body.contains("Ferrous Saint"))
}
}
}
@Test
func `keeps listings from outside the EU out of the default search`() async throws {
try await withApp { client in
try await client.execute(
uri: "/search",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(body.contains("17 listings"))
#expect(!body.contains("Longbox Atlantic"))
}
}
}
@Test
func `serves a listing with more from its shop`() async throws {
try await withApp { client in
try await client.execute(
uri: "/listing/nightward-1-cgc-9-8",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(body.contains("cert 4312087-002"))
#expect(body.contains("More from Kestrel Row Comics"))
#expect(body.contains("Echo Country #3"))
}
}
}
@Test
func `serves a shop wherever it ships from, narrowed by its quick filters`() async throws {
try await withApp { client in
try await client.execute(
uri: "/shop/longbox-atlantic?grading=slabbed",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(body.contains("All · 3"))
#expect(body.contains("Vermilion Lake #7"))
#expect(!body.contains("Solar Divide, Vol. 1"))
}
}
}
@Test(arguments: ["/listing/missing", "/shop/missing"])
func `does not find an unknown slug`(
uri: String
) async throws {
try await withApp { client in
try await client.execute(
uri: uri,
method: .get
) { response in
#expect(response.status == .notFound)
}
}
}
}
// MARK: - Helpers
private extension CatalogueControllerTests {
/// Runs the body against the controller's routes over a migrated, seeded in-memory catalogue, shutting the pool down after.
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 router = Router(context: WebsiteRequestContext.self)
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: .init(fluent: fluent)).routes)
try await Application(router: router).test(.router, body)
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
@@ -0,0 +1,44 @@
import Foundation
import Persistence
extension Listing {
/// A listing for tests: a slabbed single issue unless told otherwise.
static func stub(
slug: String = "nightward-1",
shop: Shop = .stub(),
title: String = "Nightward #1",
format: String = "single-issues",
grader: String? = "CGC",
grade: Double? = 9.8,
condition: String = "NM/M",
keyNotes: String? = nil,
priceCents: Int = 24_000,
acceptsOffers: Bool = true
) -> Self {
.init(
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
publisher: "Meridian Comics", year: 2019, format: format, printing: "1st print",
printingDetail: "First printing", isFirstPrint: true, grader: grader, grade: grade,
condition: condition, certNumber: grader == nil ? nil : "4312087-002", pageQuality: "White",
keyNotes: keyNotes, sellerNotes: "Clean case.", priceCents: priceCents, shippingCents: 600,
acceptsOffers: acceptsOffers,
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
listedAt: .init(timeIntervalSince1970: 0)
)
}
}
extension Shop {
/// A shop for tests: a verified Amsterdam seller unless told otherwise.
static func stub(
slug: String = "kestrel-row-comics",
name: String = "Kestrel Row Comics",
isVerified: Bool = true
) -> Self {
.init(
slug: slug, name: name, city: "Amsterdam", country: "NL", sinceYear: 2023, isVerified: isVerified,
rating: 4.9, ratedSales: 312, soldCount: 1108, handlingDays: 2, shipsNote: "ships worldwide, tracked",
returnsNote: "14-day returns", about: "Modern keys."
)
}
}