Implemented phase 1 for the POC prototype in the Website service.
This commit is contained in:
1 parent
6d7ab4bcba
commit
ea89ea892a
37 files changed
+4173
-43
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+99
@@ -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&format=trade-paperbacks&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&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"]),
|
||||
]
|
||||
|
||||
+132
@@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user