From ea89ea892a70adfe7f920f29d83097a98897eb04 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 11:16:52 +0200 Subject: [PATCH] Implemented phase 1 for the POC prototype in the Website service. --- Services/Website/README.md | 14 +- .../Website/Resources/Static/css/index.css | 161 +++++- .../Website/Resources/Static/css/listing.css | 217 ++++++++ .../Website/Resources/Static/css/search.css | 142 +++++ .../Website/Resources/Static/css/shared.css | 506 ++++++++++++++++++ .../Website/Resources/Static/css/shop.css | 122 +++++ .../Website/Resources/Static/js/shared.js | 23 + .../Sources/App/Extensions/App+Build.swift | 60 ++- .../Library/Internal/Components/Chip.swift | 62 +++ .../Library/Internal/Components/Cover.swift | 60 +++ .../Library/Internal/Components/Icon.swift | 16 +- .../Internal/Components/ListingCard.swift | 108 ++++ .../Internal/Components/Pagination.swift | 107 ++++ .../Internal/Components/SiteHeader.swift | 7 + .../Internal/Components/SiteLayout.swift | 7 + .../Internal/Components/SortSelect.swift | 63 +++ .../Internal/Enumerations/Format.swift | 20 + .../Internal/Extensions/Int+Euros.swift | 14 + .../Internal/Extensions/Listing+Display.swift | 89 +++ .../Internal/Extensions/String+Routes.swift | 33 ++ .../Library/Internal/Pages/IndexPage.swift | 149 +++++- .../Library/Internal/Pages/ListingPage.swift | 310 +++++++++++ .../Library/Internal/Pages/SearchPage.swift | 352 ++++++++++++ .../Library/Internal/Pages/ShopPage.swift | 325 +++++++++++ .../Internal/Types/SearchParameters.swift | 321 +++++++++++ .../Library/Internal/Types/StaticFile.swift | 12 + .../Controllers/CatalogueController.swift | 172 ++++++ .../Public/Controllers/RootController.swift | 7 +- .../Library/Public/Types/HomeListings.swift | 38 ++ Services/Website/Tests/App/AppTests.swift | 54 ++ .../Components/CatalogueComponentsTests.swift | 99 ++++ .../Extensions/ListingDisplayTests.swift | 82 +++ .../Internal/Pages/CataloguePagesTests.swift | 167 ++++++ .../Types/SearchParametersTests.swift | 118 ++++ .../Internal/Types/StaticFileTests.swift | 3 + .../CatalogueControllerTests.swift | 132 +++++ .../Library/Utils/Stubs/Listing+Stub.swift | 44 ++ 37 files changed, 4173 insertions(+), 43 deletions(-) create mode 100644 Services/Website/Resources/Static/css/listing.css create mode 100644 Services/Website/Resources/Static/css/search.css create mode 100644 Services/Website/Resources/Static/css/shop.css create mode 100644 Services/Website/Sources/Library/Internal/Components/Chip.swift create mode 100644 Services/Website/Sources/Library/Internal/Components/Cover.swift create mode 100644 Services/Website/Sources/Library/Internal/Components/ListingCard.swift create mode 100644 Services/Website/Sources/Library/Internal/Components/Pagination.swift create mode 100644 Services/Website/Sources/Library/Internal/Components/SortSelect.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/Int+Euros.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/Listing+Display.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/ListingPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/SearchPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/ShopPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Types/SearchParameters.swift create mode 100644 Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift create mode 100644 Services/Website/Sources/Library/Public/Types/HomeListings.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Internal/Types/SearchParametersTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Controllers/CatalogueControllerTests.swift create mode 100644 Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift diff --git a/Services/Website/README.md b/Services/Website/README.md index 3f8d700..811abdb 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -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 / → 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: diff --git a/Services/Website/Resources/Static/css/index.css b/Services/Website/Resources/Static/css/index.css index 643ea7c..1c5c9d4 100644 --- a/Services/Website/Resources/Static/css/index.css +++ b/Services/Website/Resources/Static/css/index.css @@ -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; } } diff --git a/Services/Website/Resources/Static/css/listing.css b/Services/Website/Resources/Static/css/listing.css new file mode 100644 index 0000000..29842c9 --- /dev/null +++ b/Services/Website/Resources/Static/css/listing.css @@ -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); + } +} diff --git a/Services/Website/Resources/Static/css/search.css b/Services/Website/Resources/Static/css/search.css new file mode 100644 index 0000000..af39a2f --- /dev/null +++ b/Services/Website/Resources/Static/css/search.css @@ -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; + } +} diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index c3a799e..173b4b4 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -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; diff --git a/Services/Website/Resources/Static/css/shop.css b/Services/Website/Resources/Static/css/shop.css new file mode 100644 index 0000000..e0523eb --- /dev/null +++ b/Services/Website/Resources/Static/css/shop.css @@ -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); + } +} diff --git a/Services/Website/Resources/Static/js/shared.js b/Services/Website/Resources/Static/js/shared.js index e69de29..1c3ffc3 100644 --- a/Services/Website/Resources/Static/js/shared.js +++ b/Services/Website/Resources/Static/js/shared.js @@ -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); +} diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 2d242f1..9a63787 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -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.Configuration, siteOrigin: String?, logLevel: Logger.Level, - probe: Probe + probe: Probe, + repository: CatalogueRepository, + listings: HomeListings ) -> Router { // 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( assetVersion: assetVersion, siteOrigin: siteOrigin, + analytics: analytics, + listings: listings + ) + CatalogueController( + repository: repository, + assetVersion: assetVersion, analytics: analytics ) HealthController( diff --git a/Services/Website/Sources/Library/Internal/Components/Chip.swift b/Services/Website/Sources/Library/Internal/Components/Chip.swift new file mode 100644 index 0000000..547ba1e --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Chip.swift @@ -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)" + } + } +} diff --git a/Services/Website/Sources/Library/Internal/Components/Cover.swift b/Services/Website/Sources/Library/Internal/Components/Cover.swift new file mode 100644 index 0000000..5f25ab0 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Cover.swift @@ -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 + } + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/Icon.swift b/Services/Website/Sources/Library/Internal/Components/Icon.swift index 8882535..5039ce8 100644 --- a/Services/Website/Sources/Library/Internal/Components/Icon.swift +++ b/Services/Website/Sources/Library/Internal/Components/Icon.swift @@ -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( """ + stroke-width="\(weight ?? glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes) """ ) } @@ -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: #""# case .bookmark: #""# case .check: #""# + case .document: #""# case .filter: #""# case .key: #""# + case .lock: #""# case .message: #""# case .search: #""# case .shop: #""# diff --git a/Services/Website/Sources/Library/Internal/Components/ListingCard.swift b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift new file mode 100644 index 0000000..f3ca721 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift @@ -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 + } +} diff --git a/Services/Website/Sources/Library/Internal/Components/Pagination.swift b/Services/Website/Sources/Library/Internal/Components/Pagination.swift new file mode 100644 index 0000000..b9b74df --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Pagination.swift @@ -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( + _ 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 + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift index 46187c0..6abc898 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift @@ -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( diff --git a/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift b/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift index e2707da..0f3fdba 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift @@ -11,6 +11,9 @@ struct SiteLayout: 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: 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: HTML { } SiteHeader( active: active, + query: query, viewer: viewer ) main(.id("main")) { diff --git a/Services/Website/Sources/Library/Internal/Components/SortSelect.swift b/Services/Website/Sources/Library/Internal/Components/SortSelect.swift new file mode 100644 index 0000000..4686f64 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/SortSelect.swift @@ -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" + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Enumerations/Format.swift b/Services/Website/Sources/Library/Internal/Enumerations/Format.swift index b388dc3..6fc9edb 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/Format.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/Format.swift @@ -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)" diff --git a/Services/Website/Sources/Library/Internal/Extensions/Int+Euros.swift b/Services/Website/Sources/Library/Internal/Extensions/Int+Euros.swift new file mode 100644 index 0000000..fd8e9ae --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/Int+Euros.swift @@ -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) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/Listing+Display.swift b/Services/Website/Sources/Library/Internal/Extensions/Listing+Display.swift new file mode 100644 index 0000000..40dc00f --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/Listing+Display.swift @@ -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) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift index 9d79f3b..fa00708 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -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: "&=+?#")) +} diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index 5485dcb..fe1d0a5 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -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) + } + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift new file mode 100644 index 0000000..6654d38 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -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) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift new file mode 100644 index 0000000..aa0ffe5 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift @@ -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() + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift new file mode 100644 index 0000000..b42a336 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift @@ -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 + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift b/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift new file mode 100644 index 0000000..ad72f79 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Types/SearchParameters.swift @@ -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: "&=+?#"))) ?? "" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 3612c37..31b3ef4 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -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) diff --git a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift new file mode 100644 index 0000000..080d07f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift @@ -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 { + + // 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 { + 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" +} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 49bc715..4a78ad0 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -31,10 +31,12 @@ public struct RootController { /// - 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 { locale: $0, assetVersion: assetVersion, siteOrigin: siteOrigin, - analytics: analytics + analytics: analytics, + listings: listings ) } } diff --git a/Services/Website/Sources/Library/Public/Types/HomeListings.swift b/Services/Website/Sources/Library/Public/Types/HomeListings.swift new file mode 100644 index 0000000..7f45501 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/HomeListings.swift @@ -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) + ) + } + +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index b4ba04f..563baa1 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -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( diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift new file mode 100644 index 0000000..83ea130 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Components/CatalogueComponentsTests.swift @@ -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("NIGHTWARD")) + #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(#"Raw · NM"#) + } + + // 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"#)) + #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")) + #expect(ListingCard(.stub(), context: .search).render().contains(">Kestrel Row Comics · NL · ship €6")) + #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"#)) + #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) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift new file mode 100644 index 0000000..fdb8daf --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift @@ -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") + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift new file mode 100644 index 0000000..37281ca --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift @@ -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("“nightward 1” — CCN")) + #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("Nightward #1 · CGC 9.8 — CCN")) + #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("
Condition
")) + } + + // 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("Kestrel Row Comics — CCN")) + #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(" 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(repository: .init(fluent: fluent)).routes) + + try await Application(router: router).test(.router, body) + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + +} diff --git a/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift b/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift new file mode 100644 index 0000000..0f0ce0c --- /dev/null +++ b/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift @@ -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." + ) + } +}