Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -5,6 +5,7 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin
|
|||||||
The service:
|
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 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)).
|
- Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)).
|
||||||
|
- Serves the path to a sale, for signed-in accounts: the offer sheet, the messages, the sale records, and the seller dashboard (see [Path to a sale](#path-to-a-sale)).
|
||||||
- Serves the gate: the sign-in screen at `GET /sign-in`, the sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)).
|
- Serves the gate: the sign-in screen at `GET /sign-in`, the sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)).
|
||||||
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
||||||
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
|
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
|
||||||
@@ -50,6 +51,7 @@ LogRequestsMiddleware
|
|||||||
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
|
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
|
||||||
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request)
|
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request)
|
||||||
SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
|
SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
|
||||||
|
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
|
||||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,9 +66,9 @@ The screens follow the **CCN marketplace** design canvas:
|
|||||||
|
|
||||||
### Catalogue
|
### Catalogue
|
||||||
- **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
|
- **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
|
||||||
- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`).
|
- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`) — a copy sold since still shows there until the next launch.
|
||||||
- **Search and shop**: `SearchParameters` turns the query string into a `ListingFilter` and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain `GET` forms and links. Searches default to listings shipping from the EU.
|
- **Search and shop**: `SearchParameters` turns the query string into a `ListingFilter` and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain `GET` forms and links. Searches default to listings shipping from the EU.
|
||||||
- **Gated actions**: signed out, buy, offer, message, save, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message link to their own paths (not built yet), while save and follow render disabled.
|
- **Gated actions**: signed out, buy, offer, message, save, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message lead on (see [Path to a sale](#path-to-a-sale)), while save and follow render disabled. A sold copy, and a seller's own listing, show no actions.
|
||||||
- **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's lettering and tone.
|
- **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.
|
- **Scripts**: `js/shared.js` only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it.
|
||||||
|
|
||||||
@@ -79,6 +81,20 @@ The screens follow the **CCN marketplace** design canvas:
|
|||||||
- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
|
- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
|
||||||
- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out.
|
- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out.
|
||||||
|
|
||||||
|
### Path to a sale
|
||||||
|
| Route | Screen |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET`/`POST /listing/{slug}/offer` | The offer sheet: an amount, one-tap picks (plain links, so they work without scripts), and a note. |
|
||||||
|
| `GET`/`POST /listing/{slug}/buy` | The same sheet, confirming a purchase at the asking price. |
|
||||||
|
| `GET /messages`, `GET`/`POST /messages/{id}`, `GET /messages/new` | The conversations rail and the open conversation: messages, offer cards, and the events of the sale. |
|
||||||
|
| `GET`/`POST /sales/{number}` | The sale record: what was agreed, frozen, and the five-step handshake. |
|
||||||
|
| `GET /sell`, `POST /sell/offers/{id}` | The seller dashboard and the answer to an offer. |
|
||||||
|
|
||||||
|
- **The rules live in `Persistence`**: `TradeRepository` decides who may offer, answer, or take a step; `TradeController` only maps a refusal to the sheet re-rendered with a `422`, or to a redirect that changes nothing.
|
||||||
|
- **Privacy**: a conversation or sale the account is no party to is not found, rather than forbidden.
|
||||||
|
- **Demo accounts**: every seeded shop has an owner; sign in as `joost@kestrelrow.example` to sell for Kestrel Row Comics.
|
||||||
|
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, vacation mode, and new listings.
|
||||||
|
|
||||||
### Page metadata
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -133,24 +133,6 @@
|
|||||||
font-size: 13px;
|
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 {
|
.listing__notes {
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1818,6 +1818,25 @@ textarea.input {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Definition lists: a listing's details, a sale's agreement */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty states */
|
/* Empty states */
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,784 @@
|
|||||||
|
/* CCN — the path to a sale: offer sheet, messages, sale record, seller dashboard. */
|
||||||
|
|
||||||
|
/* Small covers */
|
||||||
|
.cover--thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Too small for its imprint and number. */
|
||||||
|
.cover--thumb .cover__foot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover--agreed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Offer sheet
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
max-width: calc(520px + 2 * var(--gutter));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__card,
|
||||||
|
.sheet__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__optional {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 1.5px solid var(--ink);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount:focus-within {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount__sign {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount__picks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount__picks li {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount__picks .pill {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__terms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--tint);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__terms strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__sum {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__sum dd {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__total {
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__cancel {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Sale record
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.sale,
|
||||||
|
.sale__column,
|
||||||
|
.sale__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale {
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__head .chip {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__summary {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__handshake {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper__step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper__dot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper__step--done .stepper__dot {
|
||||||
|
background: var(--good);
|
||||||
|
color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper__step--current .stepper__dot {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__next {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__rule,
|
||||||
|
.sale__waiting {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__waiting {
|
||||||
|
align-self: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__actions,
|
||||||
|
.sale__step {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__note {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating__option {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating__option input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating__option span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--control-md);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--card);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating__option input:checked + span {
|
||||||
|
border-color: var(--header);
|
||||||
|
background: var(--header);
|
||||||
|
color: var(--on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating__option input:focus-visible + span {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__body {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__column {
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__card {
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__title {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__agreed {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__agreed .details,
|
||||||
|
.sale__card > .details {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__text {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__party {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__party-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sale__next {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__rule {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__actions,
|
||||||
|
.sale__step {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__note {
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sale__body {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover--agreed {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Messages
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below desktop, the rail and the conversation take turns. */
|
||||||
|
.messages--open .messages__rail,
|
||||||
|
.messages:not(.messages--open) .messages__thread {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__rail,
|
||||||
|
.messages__thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__rail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__boxes {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item:hover,
|
||||||
|
.messages__item[aria-current] {
|
||||||
|
background: var(--tint);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item-text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item-about {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__item-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__unread {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__back {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__pinned {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__pinned-text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 200px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__pinned-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__pinned-title {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__pinned-top a.chip {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry--own {
|
||||||
|
align-items: flex-end;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__bubble {
|
||||||
|
padding: var(--space-3) 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--card);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry--own .entry__bubble {
|
||||||
|
border-color: var(--header);
|
||||||
|
background: var(--header);
|
||||||
|
color: var(--on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry--event {
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__event {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__sale {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-card__note {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__replies {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__safety {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.messages {
|
||||||
|
grid-template-columns: 380px minmax(0, 1fr);
|
||||||
|
gap: var(--space-10);
|
||||||
|
padding-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages--open .messages__rail,
|
||||||
|
.messages:not(.messages--open) .messages__thread {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages__thread--none {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 240px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Seller dashboard
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.sell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell__heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell__summary,
|
||||||
|
.sell__none {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell__none {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles__tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__row {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.rows__row:hover {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__title {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handshake {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handshake__step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handshake__step::before {
|
||||||
|
content: "";
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handshake__step--done {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handshake__step--done::before {
|
||||||
|
background: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.tiles {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__row--listing {
|
||||||
|
grid-template-columns: minmax(0, 2fr) 90px 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sell {
|
||||||
|
gap: var(--space-7);
|
||||||
|
padding-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles__tile {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__row--offer {
|
||||||
|
grid-template-columns: minmax(0, 2fr) 90px 90px 110px minmax(0, 1.2fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows__row--sale {
|
||||||
|
grid-template-columns: 100px minmax(0, 2fr) 70px minmax(0, 1fr) 340px 130px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ func application(
|
|||||||
logLevel: reader.logLevel,
|
logLevel: reader.logLevel,
|
||||||
probe: Probe(fluent: fluent),
|
probe: Probe(fluent: fluent),
|
||||||
accounts: AccountRepository(fluent: fluent),
|
accounts: AccountRepository(fluent: fluent),
|
||||||
|
trade: TradeRepository(fluent: fluent),
|
||||||
repository: repository,
|
repository: repository,
|
||||||
listings: await homeListings(
|
listings: await homeListings(
|
||||||
from: repository,
|
from: repository,
|
||||||
@@ -184,6 +185,7 @@ private func logger(
|
|||||||
/// - logLevel: the level the request-logging middleware logs at.
|
/// - logLevel: the level the request-logging middleware logs at.
|
||||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||||
/// - accounts: the repository the `SessionController` finds and creates accounts through.
|
/// - accounts: the repository the `SessionController` finds and creates accounts through.
|
||||||
|
/// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through.
|
||||||
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
|
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
|
||||||
/// - listings: the listings the landing page shows in its rows.
|
/// - listings: the listings the landing page shows in its rows.
|
||||||
/// - Returns: the configured router.
|
/// - Returns: the configured router.
|
||||||
@@ -200,6 +202,7 @@ private func router(
|
|||||||
logLevel: Logger.Level,
|
logLevel: Logger.Level,
|
||||||
probe: Probe,
|
probe: Probe,
|
||||||
accounts: AccountRepository,
|
accounts: AccountRepository,
|
||||||
|
trade: TradeRepository,
|
||||||
repository: CatalogueRepository,
|
repository: CatalogueRepository,
|
||||||
listings: HomeListings
|
listings: HomeListings
|
||||||
) -> Router<AppRequestContext> {
|
) -> Router<AppRequestContext> {
|
||||||
@@ -209,7 +212,7 @@ private func router(
|
|||||||
context: AppRequestContext.self,
|
context: AppRequestContext.self,
|
||||||
options: .autoGenerateHeadEndpoints
|
options: .autoGenerateHeadEndpoints
|
||||||
)
|
)
|
||||||
let sessions = SessionStore()
|
let sessions = SessionStore(trade: trade)
|
||||||
|
|
||||||
router.addMiddleware {
|
router.addMiddleware {
|
||||||
LogRequestsMiddleware(logLevel)
|
LogRequestsMiddleware(logLevel)
|
||||||
@@ -257,6 +260,13 @@ private func router(
|
|||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
)
|
)
|
||||||
|
TradeController<AppRequestContext>(
|
||||||
|
trade: trade,
|
||||||
|
catalogue: repository,
|
||||||
|
sessions: sessions,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
HealthController<AppRequestContext>(
|
HealthController<AppRequestContext>(
|
||||||
probe: probe
|
probe: probe
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Elementary
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The seller's answer to an open offer: accept or decline, each a form.
|
||||||
|
struct AnswerOffer: HTML {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The offer answered.
|
||||||
|
let offer: Offer
|
||||||
|
|
||||||
|
/// The path to return to afterwards; accepting leads to the sale record instead.
|
||||||
|
let returnPath: String
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
form(
|
||||||
|
.class("answer"),
|
||||||
|
.action(.Route.answer(offer: offer.id)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
input(
|
||||||
|
.type(.hidden),
|
||||||
|
.name("return"),
|
||||||
|
.value(returnPath)
|
||||||
|
)
|
||||||
|
button(
|
||||||
|
.class("button button--primary button--sm"),
|
||||||
|
.type(.submit),
|
||||||
|
.name("answer"),
|
||||||
|
.custom(name: "value", value: "accept")
|
||||||
|
) {
|
||||||
|
"Accept"
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
.class("button button--sm"),
|
||||||
|
.type(.submit),
|
||||||
|
.name("answer"),
|
||||||
|
.custom(name: "value", value: "decline")
|
||||||
|
) {
|
||||||
|
"Decline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension Offer.Status {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The status in a word.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .open: "Open"
|
||||||
|
case .accepted: "Accepted"
|
||||||
|
case .declined: "Declined"
|
||||||
|
case .expired: "Expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chip colouring for the status.
|
||||||
|
var tone: Chip.Tone {
|
||||||
|
switch self {
|
||||||
|
case .open: .warn
|
||||||
|
case .accepted: .good
|
||||||
|
case .declined: .bad
|
||||||
|
case .expired: .neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Elementary
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The five steps of a sale's handshake, as dots: done, current, or still to come.
|
||||||
|
struct Handshake: HTML {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The last step taken.
|
||||||
|
let completed: Sale.Step
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
ol(
|
||||||
|
.class("handshake"),
|
||||||
|
.custom(name: "aria-label", value: "Handshake: \(completed.label) done")
|
||||||
|
) {
|
||||||
|
for step in Sale.Step.allCases {
|
||||||
|
li(.class(step <= completed ? "handshake__step handshake__step--done" : "handshake__step")) {
|
||||||
|
step.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension Sale.Step {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The step's name.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .agreed: "Agreed"
|
||||||
|
case .paid: "Paid"
|
||||||
|
case .sent: "Sent"
|
||||||
|
case .received: "Received"
|
||||||
|
case .rated: "Rated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Sale {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The record's number as shown, e.g. `CCN-48213`.
|
||||||
|
var reference: String {
|
||||||
|
"CCN-\(number)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the sale stands, in a few words, and whether that is done.
|
||||||
|
var status: (label: String, tone: Chip.Tone) {
|
||||||
|
switch next {
|
||||||
|
case .paid: ("Awaiting payment", .warn)
|
||||||
|
case .sent: ("Awaiting dispatch", .warn)
|
||||||
|
case .received: ("On its way", .warn)
|
||||||
|
case .rated: ("Awaiting ratings", .neutral)
|
||||||
|
default: ("Complete", .good)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ extension Icon {
|
|||||||
case document
|
case document
|
||||||
/// Filter lines.
|
/// Filter lines.
|
||||||
case filter
|
case filter
|
||||||
|
/// A circled "i".
|
||||||
|
case info
|
||||||
/// A key.
|
/// A key.
|
||||||
case key
|
case key
|
||||||
/// A padlock.
|
/// A padlock.
|
||||||
@@ -90,6 +92,7 @@ extension Icon.Glyph {
|
|||||||
case .close: #"<path d="M6 6l12 12M18 6 6 18"></path>"#
|
case .close: #"<path d="M6 6l12 12M18 6 6 18"></path>"#
|
||||||
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
|
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
|
||||||
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
|
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
|
||||||
|
case .info: #"<circle cx="12" cy="12" r="9"></circle><path d="M12 8v5M12 16h.01"></path>"#
|
||||||
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
|
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
|
||||||
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
|
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
|
||||||
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
|
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
|
||||||
|
|||||||
@@ -203,11 +203,13 @@ private extension SiteHeader {
|
|||||||
unit: "unread",
|
unit: "unread",
|
||||||
path: .Route.messages
|
path: .Route.messages
|
||||||
)
|
)
|
||||||
a(
|
if viewer.hasShop {
|
||||||
.class("site-header__text-link site-header__text-link--strong from-desktop"),
|
a(
|
||||||
.href(.Route.sellerDashboard)
|
.class("site-header__text-link site-header__text-link--strong from-tablet"),
|
||||||
) {
|
.href(.Route.sellerDashboard)
|
||||||
"My shop"
|
) {
|
||||||
|
"My shop"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
a(
|
a(
|
||||||
.class("site-header__account"),
|
.class("site-header__account"),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// How long until the date, in a few words: `47 h left`, `2 days left`, `under an hour left`.
|
||||||
|
/// - Parameter now: the moment counted from.
|
||||||
|
func countdown(
|
||||||
|
from now: Date
|
||||||
|
) -> String {
|
||||||
|
let hours = Int(timeIntervalSince(now) / 3_600)
|
||||||
|
|
||||||
|
switch hours {
|
||||||
|
case ..<1: return "under an hour left"
|
||||||
|
case ..<48: return "\(hours) h left"
|
||||||
|
default: return "\(hours / 24) days left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The date as a stamp on a record: `today, 09:14`, `yesterday, 21:38`, `20 Sep, 14:05`, or with the year once it differs.
|
||||||
|
///
|
||||||
|
/// Read on Amsterdam's clock: the marketplace is NL-first.
|
||||||
|
/// - Parameter now: the moment "today" is judged from.
|
||||||
|
func stamp(
|
||||||
|
relativeTo now: Date
|
||||||
|
) -> String {
|
||||||
|
let time = formatted(Self.style.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))
|
||||||
|
|
||||||
|
if Self.calendar.isDate(self, inSameDayAs: now) {
|
||||||
|
return "today, \(time)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if
|
||||||
|
let yesterday = Self.calendar.date(byAdding: .day, value: -1, to: now),
|
||||||
|
Self.calendar.isDate(self, inSameDayAs: yesterday)
|
||||||
|
{
|
||||||
|
return "yesterday, \(time)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSameYear = Self.calendar.isDate(self, equalTo: now, toGranularity: .year)
|
||||||
|
let day = formatted(isSameYear ? Self.style.day().month(.abbreviated) : Self.style.day().month(.abbreviated).year())
|
||||||
|
|
||||||
|
return "\(day), \(time)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension Date {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The calendar days are told apart by, on Amsterdam's clock.
|
||||||
|
static let calendar: Calendar = {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
|
||||||
|
calendar.timeZone = timeZone
|
||||||
|
|
||||||
|
return calendar
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The base format style: British English, on Amsterdam's clock.
|
||||||
|
static let style = Date.FormatStyle(
|
||||||
|
locale: .init(identifier: "en_GB"),
|
||||||
|
calendar: calendar,
|
||||||
|
timeZone: timeZone
|
||||||
|
)
|
||||||
|
|
||||||
|
static let timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
|
||||||
|
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ extension String {
|
|||||||
/// The want list.
|
/// The want list.
|
||||||
static let wants = "/wants"
|
static let wants = "/wants"
|
||||||
|
|
||||||
|
/// The path an offer is answered at.
|
||||||
|
/// - Parameter id: the offer's identifier.
|
||||||
|
static func answer(
|
||||||
|
offer id: UUID
|
||||||
|
) -> String {
|
||||||
|
"\(sellerDashboard)/offers/\(id.uuidString.lowercased())"
|
||||||
|
}
|
||||||
|
|
||||||
/// The path buying a listing outright starts at.
|
/// The path buying a listing outright starts at.
|
||||||
/// - Parameter slug: the listing's URL slug.
|
/// - Parameter slug: the listing's URL slug.
|
||||||
static func buy(
|
static func buy(
|
||||||
@@ -48,6 +56,14 @@ extension String {
|
|||||||
"\(listing(slug))/buy"
|
"\(listing(slug))/buy"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path of a conversation.
|
||||||
|
/// - Parameter id: the conversation's identifier.
|
||||||
|
static func conversation(
|
||||||
|
_ id: UUID
|
||||||
|
) -> String {
|
||||||
|
"\(messages)/\(id.uuidString.lowercased())"
|
||||||
|
}
|
||||||
|
|
||||||
/// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
|
/// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - path: the action's own path.
|
/// - path: the action's own path.
|
||||||
@@ -93,6 +109,14 @@ extension String {
|
|||||||
"\(listing(slug))/offer"
|
"\(listing(slug))/offer"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path of a sale record.
|
||||||
|
/// - Parameter number: the record's number.
|
||||||
|
static func sale(
|
||||||
|
_ number: Int
|
||||||
|
) -> String {
|
||||||
|
"/sales/\(number)"
|
||||||
|
}
|
||||||
|
|
||||||
/// The path of a shop's public screen.
|
/// The path of a shop's public screen.
|
||||||
/// - Parameter slug: the shop's URL slug.
|
/// - Parameter slug: the shop's URL slug.
|
||||||
static func shop(
|
static func shop(
|
||||||
|
|||||||
@@ -222,42 +222,62 @@ private extension ListingPage {
|
|||||||
Unavailable("Save", modifier: "button--sm")
|
Unavailable("Save", modifier: "button--sm")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(.class("buy-box__actions")) {
|
if !listing.isAvailable {
|
||||||
a(
|
p(.class("notice")) {
|
||||||
.class("button button--primary button--lg"),
|
"This copy is sold. A sale was agreed on CCN."
|
||||||
.href(gated(.Route.buy(listing.slug)))
|
|
||||||
) {
|
|
||||||
"Buy now"
|
|
||||||
}
|
}
|
||||||
if listing.acceptsOffers {
|
} else if viewer?.shopSlug == listing.shop.slug {
|
||||||
a(
|
p(.class("notice")) {
|
||||||
.class("button button--outline button--lg"),
|
"This is your listing. Offers on it arrive in your "
|
||||||
.href(gated(.Route.offer(listing.slug)))
|
a(.href(.Route.sellerDashboard)) {
|
||||||
) {
|
"seller dashboard"
|
||||||
"Make an offer"
|
|
||||||
}
|
}
|
||||||
|
"."
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gated actions: buy, offer, message — and, signed out, why they lead to the sign-in.
|
||||||
|
@HTMLBuilder
|
||||||
|
var actions: some HTML {
|
||||||
|
div(.class("buy-box__actions")) {
|
||||||
|
a(
|
||||||
|
.class("button button--primary button--lg"),
|
||||||
|
.href(gated(.Route.buy(listing.slug)))
|
||||||
|
) {
|
||||||
|
"Buy now"
|
||||||
|
}
|
||||||
|
if listing.acceptsOffers {
|
||||||
a(
|
a(
|
||||||
.class("button button--bare button--lg"),
|
.class("button button--outline button--lg"),
|
||||||
.href(gated(.Route.message(listing: listing.slug)))
|
.href(gated(.Route.offer(listing.slug)))
|
||||||
) {
|
) {
|
||||||
Icon(.message, size: 16)
|
"Make an offer"
|
||||||
"Message seller"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewer == nil {
|
a(
|
||||||
p(.class("gate-note")) {
|
.class("button button--bare button--lg"),
|
||||||
Icon(.lock, size: 16)
|
.href(gated(.Route.message(listing: listing.slug)))
|
||||||
span {
|
) {
|
||||||
"Buying, offers, saving and messages need a signed-in account. "
|
Icon(.message, size: 16)
|
||||||
a(
|
"Message seller"
|
||||||
.class("gate-note__link"),
|
}
|
||||||
.href(.Route.signIn(returningTo: listing.path))
|
}
|
||||||
) {
|
if viewer == nil {
|
||||||
"Sign in or join with a passkey"
|
p(.class("gate-note")) {
|
||||||
}
|
Icon(.lock, size: 16)
|
||||||
" — browsing stays open to everyone."
|
span {
|
||||||
|
"Buying, offers, saving and messages need a signed-in account. "
|
||||||
|
a(
|
||||||
|
.class("gate-note__link"),
|
||||||
|
.href(.Route.signIn(returningTo: listing.path))
|
||||||
|
) {
|
||||||
|
"Sign in or join with a passkey"
|
||||||
}
|
}
|
||||||
|
" — browsing stays open to everyone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The messages: the viewer's conversations in a rail, and the open one beside it — what was said, the offers made, and the sale agreed.
|
||||||
|
///
|
||||||
|
/// Below desktop the two take turns: the rail alone at the messages root, the conversation alone once one is open.
|
||||||
|
struct MessagesPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The side of the viewer's conversations the rail lists.
|
||||||
|
let box: Conversation.Side
|
||||||
|
|
||||||
|
/// Every conversation the viewer is a party to, the latest spoken in first.
|
||||||
|
let conversations: [Conversation]
|
||||||
|
|
||||||
|
/// The moment stamps are read relative to.
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
/// The conversation open beside the rail, or `nil` for none.
|
||||||
|
let open: Conversation?
|
||||||
|
|
||||||
|
/// The signed-in viewer.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the messages screen.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - conversations: every conversation the viewer is a party to, the latest spoken in first.
|
||||||
|
/// - open: the conversation open beside the rail, or `nil` (the default) for none.
|
||||||
|
/// - box: the side the rail lists, or `nil` (the default) for the open conversation's — buying, without one.
|
||||||
|
/// - viewer: the signed-in viewer.
|
||||||
|
/// - now: the moment stamps are read relative to; the present by default.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
|
init(
|
||||||
|
conversations: [Conversation],
|
||||||
|
open: Conversation? = nil,
|
||||||
|
box: Conversation.Side? = nil,
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
now: Date = .init(),
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.box = box ?? open?.side ?? (conversations.contains { $0.side == .buying } || conversations.isEmpty ? .buying : .selling)
|
||||||
|
self.conversations = conversations
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.now = now
|
||||||
|
self.open = open
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension MessagesPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(viewer: viewer) {
|
||||||
|
div(.class(open == nil ? "page messages" : "page messages messages--open")) {
|
||||||
|
rail
|
||||||
|
if let open {
|
||||||
|
thread(open)
|
||||||
|
} else {
|
||||||
|
div(.class("messages__thread messages__thread--none")) {
|
||||||
|
p(.class("type-body-sm")) {
|
||||||
|
conversations.isEmpty
|
||||||
|
? "No conversations yet. Message a seller from a listing, or make an offer."
|
||||||
|
: "Pick a conversation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("robots"),
|
||||||
|
.content("noindex")
|
||||||
|
)
|
||||||
|
siteMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.trade]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"\(open.map { "\(name(in: $0)) · " } ?? "")Messages — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rail
|
||||||
|
|
||||||
|
private extension MessagesPage {
|
||||||
|
|
||||||
|
var rail: some HTML {
|
||||||
|
aside(.class("messages__rail")) {
|
||||||
|
div(.class("messages__rail-head")) {
|
||||||
|
h1(.class("type-panel")) {
|
||||||
|
"Messages"
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(conversations.count { $0.unreadCount > 0 }) unread"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("messages__boxes")) {
|
||||||
|
boxLink(.buying, title: "Buying")
|
||||||
|
boxLink(.selling, title: "Selling")
|
||||||
|
}
|
||||||
|
ul(.class("messages__list")) {
|
||||||
|
ForEach(conversations.filter { $0.side == box }) { conversation in
|
||||||
|
li {
|
||||||
|
summary(of: conversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab switching the rail between the viewer's sides, counting what is unread on it.
|
||||||
|
func boxLink(
|
||||||
|
_ side: Conversation.Side,
|
||||||
|
title: String
|
||||||
|
) -> some HTML {
|
||||||
|
let unread = conversations.count { $0.side == side && $0.unreadCount > 0 }
|
||||||
|
|
||||||
|
return a(
|
||||||
|
.class("pill"),
|
||||||
|
.href("\(String.Route.messages)?box=\(side.rawValue)")
|
||||||
|
) {
|
||||||
|
unread == 0 ? title : "\(title) · \(unread)"
|
||||||
|
}
|
||||||
|
.attributes(
|
||||||
|
.custom(name: "aria-current", value: "true"),
|
||||||
|
when: side == box
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conversation in the rail: who it is with, what about, and the latest said.
|
||||||
|
func summary(
|
||||||
|
of conversation: Conversation
|
||||||
|
) -> some HTML {
|
||||||
|
a(
|
||||||
|
.class("messages__item"),
|
||||||
|
.href(.Route.conversation(conversation.id))
|
||||||
|
) {
|
||||||
|
span(.class("monogram")) {
|
||||||
|
monogram(in: conversation)
|
||||||
|
}
|
||||||
|
span(.class("messages__item-text")) {
|
||||||
|
span(.class("messages__item-top")) {
|
||||||
|
strong {
|
||||||
|
name(in: conversation)
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
conversation.latest?.createdAt.stamp(relativeTo: now) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span(.class("messages__item-about")) {
|
||||||
|
conversation.listing.map { "\($0.title) · \($0.chip.label) · \($0.priceCents.euros)" } ?? "About the shop"
|
||||||
|
}
|
||||||
|
span(.class("messages__item-preview")) {
|
||||||
|
preview(of: conversation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if conversation.unreadCount > 0 {
|
||||||
|
span(.class("messages__unread")) {
|
||||||
|
"\(conversation.unreadCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attributes(
|
||||||
|
.custom(name: "aria-current", value: "page"),
|
||||||
|
when: conversation.id == open?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Thread
|
||||||
|
|
||||||
|
private extension MessagesPage {
|
||||||
|
|
||||||
|
/// The open conversation: the pinned listing, the entries, and the composer.
|
||||||
|
func thread(
|
||||||
|
_ conversation: Conversation
|
||||||
|
) -> some HTML {
|
||||||
|
section(.class("messages__thread")) {
|
||||||
|
a(
|
||||||
|
.class("messages__back until-desktop"),
|
||||||
|
.href("\(String.Route.messages)?box=\(conversation.side.rawValue)")
|
||||||
|
) {
|
||||||
|
"← All messages"
|
||||||
|
}
|
||||||
|
pinned(conversation)
|
||||||
|
ol(.class("messages__entries")) {
|
||||||
|
ForEach(Array(conversation.entries.enumerated())) { index, entry in
|
||||||
|
li(.class("entry entry--\(entry.kind.rawValue) \(isOwn(entry, in: conversation) ? "entry--own" : "entry--theirs")")) {
|
||||||
|
self.entry(entry, in: conversation, opensSale: index == conversation.entries.firstIndex { $0.saleNumber != nil })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if conversation.side == .buying {
|
||||||
|
replies(["Can you combine shipping?", "Pickup at Dutch Comic Con instead?"], in: conversation)
|
||||||
|
} else {
|
||||||
|
replies(["Thanks — I'll dispatch within two business days.", "Happy to send more photos."], in: conversation)
|
||||||
|
}
|
||||||
|
form(
|
||||||
|
.class("composer"),
|
||||||
|
.action(.Route.conversation(conversation.id)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
label(
|
||||||
|
.class("visually-hidden"),
|
||||||
|
.for("body")
|
||||||
|
) {
|
||||||
|
"Message"
|
||||||
|
}
|
||||||
|
textarea(
|
||||||
|
.class("input composer__input"),
|
||||||
|
.id("body"),
|
||||||
|
.name("body"),
|
||||||
|
.required,
|
||||||
|
.custom(name: "rows", value: "2"),
|
||||||
|
.custom(name: "maxlength", value: "2000"),
|
||||||
|
.placeholder("Write to \(name(in: conversation))…")
|
||||||
|
) {}
|
||||||
|
button(
|
||||||
|
.class("button button--primary"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Send"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("type-small messages__safety")) {
|
||||||
|
"Keep the conversation on CCN — it's the record if anything goes wrong. Never share bank details in the open; use the sale record's payment note."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry: a bubble, an offer card, or an event line — the event that opened the sale carrying the way to its record.
|
||||||
|
@HTMLBuilder
|
||||||
|
func entry(
|
||||||
|
_ entry: Conversation.Entry,
|
||||||
|
in conversation: Conversation,
|
||||||
|
opensSale: Bool
|
||||||
|
) -> some HTML {
|
||||||
|
let who = isOwn(entry, in: conversation) ? "You" : name(in: conversation)
|
||||||
|
|
||||||
|
switch entry.kind {
|
||||||
|
case .text:
|
||||||
|
p(.class("entry__bubble")) {
|
||||||
|
entry.body
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(who) · \(entry.createdAt.stamp(relativeTo: now))"
|
||||||
|
}
|
||||||
|
case .offer:
|
||||||
|
if let offer = entry.offer {
|
||||||
|
offerCard(offer, note: entry.body, in: conversation)
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(who) · \(entry.createdAt.stamp(relativeTo: now))"
|
||||||
|
}
|
||||||
|
case .event:
|
||||||
|
p(.class("entry__event")) {
|
||||||
|
"\(who) \(entry.body) · \(entry.createdAt.stamp(relativeTo: now))"
|
||||||
|
}
|
||||||
|
if opensSale, let number = entry.saleNumber {
|
||||||
|
div(.class("notice notice--good entry__sale")) {
|
||||||
|
p {
|
||||||
|
strong {
|
||||||
|
"Sale record CCN-\(number) created"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
"Payment is settled between you off-platform. The buyer marks it paid on the sale record so the seller can dispatch."
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--dark button--sm"),
|
||||||
|
.href(.Route.sale(number))
|
||||||
|
) {
|
||||||
|
"Open sale record"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer in the conversation. The seller answers an open one right here.
|
||||||
|
func offerCard(
|
||||||
|
_ offer: Offer,
|
||||||
|
note: String,
|
||||||
|
in conversation: Conversation
|
||||||
|
) -> some HTML {
|
||||||
|
div(.class("card offer-card")) {
|
||||||
|
div(.class("offer-card__head")) {
|
||||||
|
span(.class("type-eyebrow")) {
|
||||||
|
conversation.side == .buying ? "Your offer" : "Their offer"
|
||||||
|
}
|
||||||
|
Chip(offer.status.label, tone: offer.status.tone)
|
||||||
|
}
|
||||||
|
p(.class("type-stat")) {
|
||||||
|
offer.amountCents.euros
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
"asked \(offer.listing.priceCents.euros) · + \(offer.listing.shippingCents.euros) shipping to NL"
|
||||||
|
offer.status == .open ? " · \(offer.expiresAt.countdown(from: now))" : ""
|
||||||
|
}
|
||||||
|
if !note.isEmpty {
|
||||||
|
p(.class("offer-card__note")) {
|
||||||
|
"“\(note)”"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if offer.status == .open, conversation.side == .selling {
|
||||||
|
AnswerOffer(
|
||||||
|
offer: offer,
|
||||||
|
returnPath: .Route.conversation(conversation.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing pinned over the conversation, with where things stand.
|
||||||
|
func pinned(
|
||||||
|
_ conversation: Conversation
|
||||||
|
) -> some HTML {
|
||||||
|
header(.class("messages__pinned")) {
|
||||||
|
if let listing = conversation.listing {
|
||||||
|
Cover(listing.cover, modifier: "cover--thumb")
|
||||||
|
} else {
|
||||||
|
span(.class("monogram monogram--lg")) {
|
||||||
|
conversation.shop.monogram
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("messages__pinned-text")) {
|
||||||
|
div(.class("messages__pinned-top")) {
|
||||||
|
if let listing = conversation.listing {
|
||||||
|
a(
|
||||||
|
.class("messages__pinned-title"),
|
||||||
|
.href(listing.path)
|
||||||
|
) {
|
||||||
|
listing.title
|
||||||
|
}
|
||||||
|
Chip(listing.chip.label, tone: listing.chip.tone, isCertified: listing.chip.isCertified)
|
||||||
|
} else {
|
||||||
|
a(
|
||||||
|
.class("messages__pinned-title"),
|
||||||
|
.href(conversation.shop.path)
|
||||||
|
) {
|
||||||
|
conversation.shop.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let number = conversation.saleNumber {
|
||||||
|
a(
|
||||||
|
.class("chip chip--good"),
|
||||||
|
.href(.Route.sale(number))
|
||||||
|
) {
|
||||||
|
"Agreed · CCN-\(number)"
|
||||||
|
}
|
||||||
|
} else if let offer = conversation.openOffer {
|
||||||
|
Chip("Offer open · \(offer.amountCents.euros)", tone: .warn)
|
||||||
|
} else if conversation.listing?.isAvailable == false {
|
||||||
|
Chip("Sold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
conversation.side == .buying
|
||||||
|
? "\(conversation.shop.name) · \(conversation.shop.standing)"
|
||||||
|
: "\(conversation.buyer.displayName) · buyer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if
|
||||||
|
conversation.side == .buying,
|
||||||
|
conversation.openOffer == nil,
|
||||||
|
let listing = conversation.listing,
|
||||||
|
listing.isAvailable,
|
||||||
|
listing.acceptsOffers
|
||||||
|
{
|
||||||
|
a(
|
||||||
|
.class("button button--outline button--sm"),
|
||||||
|
.href(.Route.offer(listing.slug))
|
||||||
|
) {
|
||||||
|
"Make an offer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-tap replies: each a form posting its text.
|
||||||
|
func replies(
|
||||||
|
_ texts: [String],
|
||||||
|
in conversation: Conversation
|
||||||
|
) -> some HTML {
|
||||||
|
ul(.class("messages__replies")) {
|
||||||
|
ForEach(texts) { text in
|
||||||
|
li {
|
||||||
|
form(
|
||||||
|
.action(.Route.conversation(conversation.id)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
input(
|
||||||
|
.type(.hidden),
|
||||||
|
.name("body"),
|
||||||
|
.value(text)
|
||||||
|
)
|
||||||
|
button(
|
||||||
|
.class("pill"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension MessagesPage {
|
||||||
|
|
||||||
|
/// Whether the viewer made the entry.
|
||||||
|
func isOwn(
|
||||||
|
_ entry: Conversation.Entry,
|
||||||
|
in conversation: Conversation
|
||||||
|
) -> Bool {
|
||||||
|
(entry.sender.id == conversation.buyer.id) == (conversation.side == .buying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other party's monogram.
|
||||||
|
func monogram(
|
||||||
|
in conversation: Conversation
|
||||||
|
) -> String {
|
||||||
|
conversation.side == .buying
|
||||||
|
? conversation.shop.monogram
|
||||||
|
: Viewer(conversation.buyer).initials
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other party's name.
|
||||||
|
func name(
|
||||||
|
in conversation: Conversation
|
||||||
|
) -> String {
|
||||||
|
conversation.side == .buying
|
||||||
|
? conversation.shop.name
|
||||||
|
: conversation.buyer.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest entry in a line.
|
||||||
|
func preview(
|
||||||
|
of conversation: Conversation
|
||||||
|
) -> String {
|
||||||
|
guard let latest = conversation.latest else {
|
||||||
|
return "Nothing said yet."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch latest.kind {
|
||||||
|
case .text: return latest.body
|
||||||
|
case .offer: return "Offer of \(latest.offer?.amountCents.euros ?? "")"
|
||||||
|
case .event: return "\(isOwn(latest, in: conversation) ? "You" : name(in: conversation)) \(latest.body)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The offer sheet: an amount, a note, and what sending it commits the buyer to. In its buy mode, the confirmation of a purchase at the asking price.
|
||||||
|
struct OfferPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The amount the field is filled with, in euro cents.
|
||||||
|
let amountCents: Int
|
||||||
|
|
||||||
|
/// What went wrong with the last attempt, or `nil` when nothing did.
|
||||||
|
let error: String?
|
||||||
|
|
||||||
|
/// The listing offered on.
|
||||||
|
let listing: Listing
|
||||||
|
|
||||||
|
/// Whether the sheet makes an offer or buys outright.
|
||||||
|
let mode: Mode
|
||||||
|
|
||||||
|
/// The note the field is filled with.
|
||||||
|
let note: String
|
||||||
|
|
||||||
|
/// The signed-in viewer.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the offer sheet for a listing.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - listing: the listing offered on.
|
||||||
|
/// - mode: whether the sheet makes an offer or buys outright.
|
||||||
|
/// - amountCents: the amount the field is filled with, or `nil` (the default) for a tenth under the asking price.
|
||||||
|
/// - note: the note the field is filled with; empty by default.
|
||||||
|
/// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did.
|
||||||
|
/// - viewer: the signed-in viewer.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
|
init(
|
||||||
|
listing: Listing,
|
||||||
|
mode: Mode,
|
||||||
|
amountCents: Int? = nil,
|
||||||
|
note: String = "",
|
||||||
|
error: String? = nil,
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.amountCents = amountCents ?? Self.pick(10, under: listing.priceCents)
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.error = error
|
||||||
|
self.listing = listing
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.mode = mode
|
||||||
|
self.note = note
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
extension OfferPage {
|
||||||
|
/// What the sheet does.
|
||||||
|
enum Mode: Sendable {
|
||||||
|
/// Buys at the asking price.
|
||||||
|
case buy
|
||||||
|
/// Makes an offer under it.
|
||||||
|
case offer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension OfferPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(
|
||||||
|
active: listing.kind,
|
||||||
|
viewer: viewer
|
||||||
|
) {
|
||||||
|
div(.class("page sheet")) {
|
||||||
|
div(.class("card sheet__card")) {
|
||||||
|
div(.class("sheet__head")) {
|
||||||
|
Cover(listing.cover, modifier: "cover--thumb")
|
||||||
|
div {
|
||||||
|
h1(.class("sheet__title")) {
|
||||||
|
mode == .buy ? "Buy now" : "Make an offer"
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
"\(listing.title) · \(listing.chip.label) · asking \(listing.priceCents.euros) · \(listing.shop.name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
p(
|
||||||
|
.class("notice notice--bad"),
|
||||||
|
.custom(name: "role", value: "alert")
|
||||||
|
) {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == .buy {
|
||||||
|
purchase
|
||||||
|
} else {
|
||||||
|
offer
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("sheet__cancel"),
|
||||||
|
.href(listing.path)
|
||||||
|
) {
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("robots"),
|
||||||
|
.content("noindex")
|
||||||
|
)
|
||||||
|
siteMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.trade]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"\(mode == .buy ? "Buy" : "Make an offer on") \(listing.title) — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension OfferPage {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The shares under the asking price offered as one-tap picks, in percent.
|
||||||
|
static let picks = [5, 10, 15, 20]
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var offer: some HTML {
|
||||||
|
form(
|
||||||
|
.class("sheet__form"),
|
||||||
|
.action(.Route.offer(listing.slug)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
div(.class("field")) {
|
||||||
|
label(
|
||||||
|
.class("field__label"),
|
||||||
|
.for("amount")
|
||||||
|
) {
|
||||||
|
"Your offer"
|
||||||
|
}
|
||||||
|
div(.class("amount")) {
|
||||||
|
span(
|
||||||
|
.class("amount__sign"),
|
||||||
|
.custom(name: "aria-hidden", value: "true")
|
||||||
|
) {
|
||||||
|
"€"
|
||||||
|
}
|
||||||
|
input(
|
||||||
|
.class("amount__input"),
|
||||||
|
.id("amount"),
|
||||||
|
.type(.text),
|
||||||
|
.name("amount"),
|
||||||
|
.value("\(amountCents / 100)"),
|
||||||
|
.required,
|
||||||
|
.custom(name: "inputmode", value: "decimal"),
|
||||||
|
.custom(name: "aria-describedby", value: "amount-hint")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Links, not buttons: a pick reloads the sheet with its amount, so it works without scripts.
|
||||||
|
// A pick the seller would auto-decline is left out.
|
||||||
|
ul(.class("amount__picks")) {
|
||||||
|
for pick in Self.picks where Self.pick(pick, under: listing.priceCents) >= Offer.floor(forAsking: listing.priceCents) {
|
||||||
|
li {
|
||||||
|
a(
|
||||||
|
.class("pill"),
|
||||||
|
.href("\(String.Route.offer(listing.slug))?amount=\(Self.pick(pick, under: listing.priceCents) / 100)")
|
||||||
|
) {
|
||||||
|
"\(Self.pick(pick, under: listing.priceCents).euros) · −\(pick)%"
|
||||||
|
}
|
||||||
|
.attributes(
|
||||||
|
.custom(name: "aria-current", value: "true"),
|
||||||
|
when: Self.pick(pick, under: listing.priceCents) == amountCents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(
|
||||||
|
.class("field__hint"),
|
||||||
|
.id("amount-hint")
|
||||||
|
) {
|
||||||
|
"Whole euros, up to the asking price. The seller auto-declines under "
|
||||||
|
strong {
|
||||||
|
Offer.floor(forAsking: listing.priceCents).euros
|
||||||
|
}
|
||||||
|
"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("field")) {
|
||||||
|
label(
|
||||||
|
.class("field__label"),
|
||||||
|
.for("note")
|
||||||
|
) {
|
||||||
|
"Message "
|
||||||
|
span(.class("field__optional")) {
|
||||||
|
"(optional)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textarea(
|
||||||
|
.class("input"),
|
||||||
|
.id("note"),
|
||||||
|
.name("note"),
|
||||||
|
.custom(name: "rows", value: "2"),
|
||||||
|
.custom(name: "maxlength", value: "500"),
|
||||||
|
.placeholder("Anything the seller should know — bundle, pickup, timing")
|
||||||
|
) {
|
||||||
|
note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sheet__terms")) {
|
||||||
|
p {
|
||||||
|
strong {
|
||||||
|
"Total if accepted:"
|
||||||
|
}
|
||||||
|
" your offer + \(listing.shippingCents.euros) shipping to NL."
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
"The offer is binding for "
|
||||||
|
strong {
|
||||||
|
"48 hours"
|
||||||
|
}
|
||||||
|
". If accepted, a sale record opens and you pay the seller directly. You can have one open offer per listing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
.class("button button--primary button--xl button--block"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Send offer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var purchase: some HTML {
|
||||||
|
form(
|
||||||
|
.class("sheet__form"),
|
||||||
|
.action(.Route.buy(listing.slug)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
dl(.class("sheet__sum")) {
|
||||||
|
dt {
|
||||||
|
"Asking price"
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
listing.priceCents.euros
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
"Tracked shipping to NL"
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
listing.shippingCents.euros
|
||||||
|
}
|
||||||
|
dt(.class("sheet__total")) {
|
||||||
|
"Total"
|
||||||
|
}
|
||||||
|
dd(.class("sheet__total")) {
|
||||||
|
(listing.priceCents + listing.shippingCents).euros
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sheet__terms")) {
|
||||||
|
p {
|
||||||
|
"Agreeing opens a "
|
||||||
|
strong {
|
||||||
|
"sale record"
|
||||||
|
}
|
||||||
|
" and takes the copy off sale. The declared condition is frozen at that moment."
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
"You pay \(listing.shop.name) directly — CCN handles no money. Mark the sale paid on the record so the seller can dispatch."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
.class("button button--primary button--xl button--block"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Agree to buy for \((listing.priceCents + listing.shippingCents).euros)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The amount a share under an asking price, rounded to a whole euro, in euro cents.
|
||||||
|
static func pick(
|
||||||
|
_ percent: Int,
|
||||||
|
under askingCents: Int
|
||||||
|
) -> Int {
|
||||||
|
Int((Double(askingCents) * Double(100 - percent) / 10_000).rounded()) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// A sale record, as one of its parties sees it: what was agreed, the five-step handshake, and the step that is theirs to take.
|
||||||
|
struct SalePage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The moment stamps are read relative to.
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
/// The party the viewer is to the sale.
|
||||||
|
let role: Sale.Role
|
||||||
|
|
||||||
|
/// The sale shown.
|
||||||
|
let sale: Sale
|
||||||
|
|
||||||
|
/// The signed-in viewer.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the sale record's screen.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - sale: the sale shown.
|
||||||
|
/// - role: the party the viewer is to the sale.
|
||||||
|
/// - viewer: the signed-in viewer.
|
||||||
|
/// - now: the moment stamps are read relative to; the present by default.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
|
init(
|
||||||
|
sale: Sale,
|
||||||
|
role: Sale.Role,
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
now: Date = .init(),
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.now = now
|
||||||
|
self.role = role
|
||||||
|
self.sale = sale
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension SalePage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(viewer: viewer) {
|
||||||
|
div(.class("page sale")) {
|
||||||
|
nav(
|
||||||
|
.class("breadcrumb"),
|
||||||
|
.custom(name: "aria-label", value: "Breadcrumb")
|
||||||
|
) {
|
||||||
|
a(.href(role == .buyer ? .Route.messages : .Route.sellerDashboard)) {
|
||||||
|
role == .buyer ? "Messages" : "Seller dashboard"
|
||||||
|
}
|
||||||
|
span(.custom(name: "aria-hidden", value: "true")) {
|
||||||
|
"/"
|
||||||
|
}
|
||||||
|
span(
|
||||||
|
.class("breadcrumb__current"),
|
||||||
|
.custom(name: "aria-current", value: "page")
|
||||||
|
) {
|
||||||
|
sale.reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sale__head")) {
|
||||||
|
div(.class("sale__heading")) {
|
||||||
|
p(.class("type-eyebrow")) {
|
||||||
|
"Sale record · \(sale.reference) · you are the \(role == .buyer ? "buyer" : "seller")"
|
||||||
|
}
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"\(sale.listing.title), \(sale.listing.chip.label)"
|
||||||
|
}
|
||||||
|
p(.class("type-body-sm sale__summary")) {
|
||||||
|
"Agreed \(sale.agreedAt.stamp(relativeTo: now)) with \(counterparty) · \(sale.priceCents.euros) + \(sale.shippingCents.euros) tracked shipping · paid off-platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Chip(sale.status.label, tone: sale.status.tone)
|
||||||
|
}
|
||||||
|
div(.class("card sale__handshake")) {
|
||||||
|
ol(.class("stepper")) {
|
||||||
|
for step in Sale.Step.allCases {
|
||||||
|
li(.class("stepper__step stepper__step--\(state(of: step))")) {
|
||||||
|
span(.class("stepper__dot")) {
|
||||||
|
if step <= sale.completed {
|
||||||
|
Icon(.check, size: 14)
|
||||||
|
} else {
|
||||||
|
"\(step.rawValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span(.class("stepper__name")) {
|
||||||
|
step.label
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
caption(of: step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sale__next")) {
|
||||||
|
p(.class("sale__rule")) {
|
||||||
|
"Each step is stamped by the person who did it. Ratings unlock only after "
|
||||||
|
strong {
|
||||||
|
"Received"
|
||||||
|
}
|
||||||
|
", so every rating on CCN maps to a finished sale."
|
||||||
|
}
|
||||||
|
div(.class("sale__actions")) {
|
||||||
|
action
|
||||||
|
a(
|
||||||
|
.class("button button--outline"),
|
||||||
|
.href(.Route.conversation(sale.conversationID))
|
||||||
|
) {
|
||||||
|
Icon(.message, size: 15)
|
||||||
|
"Message \(role == .buyer ? "seller" : "buyer")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sale__body")) {
|
||||||
|
div(.class("sale__column")) {
|
||||||
|
section(.class("card sale__card")) {
|
||||||
|
h2(.class("type-eyebrow sale__title")) {
|
||||||
|
role == .buyer ? "What you agreed to" : "What was agreed"
|
||||||
|
}
|
||||||
|
div(.class("sale__agreed")) {
|
||||||
|
Cover(sale.listing.cover, modifier: "cover--agreed")
|
||||||
|
dl(.class("details")) {
|
||||||
|
for detail in details {
|
||||||
|
dt {
|
||||||
|
detail.term
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
detail.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("gate-note")) {
|
||||||
|
Icon(.info, size: 16)
|
||||||
|
span {
|
||||||
|
"The declared condition above is frozen at the moment of agreement. If the copy arrives with a defect not listed here, that's the basis for a problem report."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section(.class("card sale__card")) {
|
||||||
|
h2(.class("type-eyebrow sale__title")) {
|
||||||
|
"Payment · off-platform"
|
||||||
|
}
|
||||||
|
p(.class("type-body-sm sale__text")) {
|
||||||
|
payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("sale__column")) {
|
||||||
|
section(.class("card sale__card")) {
|
||||||
|
h2(.class("type-eyebrow sale__title")) {
|
||||||
|
role == .buyer ? "Seller" : "Buyer"
|
||||||
|
}
|
||||||
|
if role == .buyer {
|
||||||
|
div(.class("sale__party")) {
|
||||||
|
span(.class("monogram")) {
|
||||||
|
sale.listing.shop.monogram
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
p(.class("sale__party-name")) {
|
||||||
|
sale.listing.shop.name
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
sale.listing.shop.standing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--outline"),
|
||||||
|
.href(sale.listing.shop.path)
|
||||||
|
) {
|
||||||
|
"Visit shop"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
div(.class("sale__party")) {
|
||||||
|
span(.class("monogram")) {
|
||||||
|
Viewer(sale.buyer).initials
|
||||||
|
}
|
||||||
|
p(.class("sale__party-name")) {
|
||||||
|
sale.buyer.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sale.buyerRating != nil || sale.sellerRating != nil {
|
||||||
|
section(.class("card sale__card")) {
|
||||||
|
h2(.class("type-eyebrow sale__title")) {
|
||||||
|
"Ratings"
|
||||||
|
}
|
||||||
|
dl(.class("details")) {
|
||||||
|
dt {
|
||||||
|
"Buyer rated seller"
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
Self.stars(sale.buyerRating)
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
"Seller rated buyer"
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
Self.stars(sale.sellerRating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("robots"),
|
||||||
|
.content("noindex")
|
||||||
|
)
|
||||||
|
siteMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.trade]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Sale record \(sale.reference) — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension SalePage {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The step that is the viewer's to take now, as a form; or what is waited on.
|
||||||
|
@HTMLBuilder
|
||||||
|
var action: some HTML {
|
||||||
|
if sale.allows(.markPaid(note: ""), by: role) {
|
||||||
|
step("paid", button: "Mark as paid") {
|
||||||
|
noteField("How you paid", placeholder: "Bank transfer (iDEAL)")
|
||||||
|
}
|
||||||
|
} else if sale.allows(.markSent(note: ""), by: role) {
|
||||||
|
step("sent", button: "Mark as sent") {
|
||||||
|
noteField("Carrier and tracking", placeholder: "DHL · 3SABCD1234567")
|
||||||
|
}
|
||||||
|
} else if sale.allows(.markReceived, by: role) {
|
||||||
|
step("received", button: "Mark as received") {}
|
||||||
|
} else if sale.allows(.rate(stars: 5), by: role) {
|
||||||
|
step("rated", button: "Leave rating") {
|
||||||
|
fieldset(.class("rating")) {
|
||||||
|
legend(.class("visually-hidden")) {
|
||||||
|
"Your rating, out of five"
|
||||||
|
}
|
||||||
|
for stars in (1...5).reversed() {
|
||||||
|
label(.class("rating__option")) {
|
||||||
|
input(
|
||||||
|
.type(.radio),
|
||||||
|
.name("stars"),
|
||||||
|
.value("\(stars)"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
.attributes(.checked, when: stars == 5)
|
||||||
|
span {
|
||||||
|
"\(stars) ★"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p(.class("sale__waiting")) {
|
||||||
|
waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other party's name.
|
||||||
|
var counterparty: String {
|
||||||
|
role == .buyer ? sale.listing.shop.name : sale.buyer.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What was agreed, leaving out what the declaration does not state.
|
||||||
|
var details: [(term: String, value: String)] {
|
||||||
|
let price = sale.priceCents == sale.askedCents
|
||||||
|
? "\(sale.priceCents.euros) · bought at the asking price"
|
||||||
|
: "\(sale.priceCents.euros) · offer accepted (asked \(sale.askedCents.euros))"
|
||||||
|
|
||||||
|
return [
|
||||||
|
("Grade", sale.declared.grade),
|
||||||
|
("Page quality", sale.declared.pageQuality),
|
||||||
|
("Printing", sale.declared.printing),
|
||||||
|
("Declared by seller", sale.declared.notes),
|
||||||
|
("Price", price),
|
||||||
|
("Shipping", "\(sale.shippingCents.euros) · tracked to NL · \(sale.listing.shop.handlingDays) business days handling"),
|
||||||
|
].compactMap { term, value in
|
||||||
|
value.map { (term, $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the payment stands, in a sentence or two.
|
||||||
|
var payment: String {
|
||||||
|
let parties = role == .buyer ? "You and the seller settle" : "You and the buyer settle"
|
||||||
|
|
||||||
|
guard let paidAt = sale.paidAt else {
|
||||||
|
return "\(parties) payment directly — CCN handles no money. The buyer marks the sale paid here once the transfer is made, so the seller can dispatch."
|
||||||
|
}
|
||||||
|
|
||||||
|
let note = sale.paymentNote.map { " by \($0)" } ?? ""
|
||||||
|
|
||||||
|
return "\(parties) payment directly. The buyer marked this as paid\(note) \(paidAt.stamp(relativeTo: now)). CCN keeps the agreed amounts and the stamps as the record of the sale."
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is waited on when the next step is not the viewer's.
|
||||||
|
var waiting: String {
|
||||||
|
switch sale.next {
|
||||||
|
case .paid: "Waiting for the buyer to pay and mark the sale paid."
|
||||||
|
case .sent: "Waiting for the seller to dispatch."
|
||||||
|
case .received: "Waiting for the buyer to confirm the copy arrived."
|
||||||
|
case .rated: "Thanks for rating. Waiting for the other side's rating."
|
||||||
|
default: "The handshake is complete."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The line under a step: its stamp once taken, else who takes it.
|
||||||
|
func caption(
|
||||||
|
of step: Sale.Step
|
||||||
|
) -> String {
|
||||||
|
switch step {
|
||||||
|
case .agreed:
|
||||||
|
return sale.agreedAt.stamp(relativeTo: now)
|
||||||
|
case .paid:
|
||||||
|
return sale.paidAt.map { "Buyer marked paid · \($0.stamp(relativeTo: now))" } ?? "Buyer confirms"
|
||||||
|
case .sent:
|
||||||
|
return sale.sentAt.map { "\(sale.shippingNote ?? "Seller marked sent") · \($0.stamp(relativeTo: now))" } ?? "Seller confirms"
|
||||||
|
case .received:
|
||||||
|
return sale.receivedAt.map { "Buyer confirmed · \($0.stamp(relativeTo: now))" } ?? "Buyer confirms"
|
||||||
|
case .rated:
|
||||||
|
let count = [sale.buyerRating, sale.sellerRating].compactMap(\.self).count
|
||||||
|
|
||||||
|
return count == 0 ? "Both sides" : "\(count) of 2 rated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short text field for a step's note.
|
||||||
|
func noteField(
|
||||||
|
_ title: String,
|
||||||
|
placeholder: String
|
||||||
|
) -> some HTML {
|
||||||
|
label(.class("sale__note")) {
|
||||||
|
span(.class("visually-hidden")) {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
input(
|
||||||
|
.class("input"),
|
||||||
|
.type(.text),
|
||||||
|
.name("note"),
|
||||||
|
.placeholder(placeholder),
|
||||||
|
.custom(name: "maxlength", value: "200")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rating as stars, or that it is still to come.
|
||||||
|
static func stars(
|
||||||
|
_ rating: Int?
|
||||||
|
) -> String {
|
||||||
|
rating.map { String(repeating: "★", count: $0) + String(repeating: "☆", count: 5 - $0) } ?? "Not yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a step is done, the one waited on, or still to come.
|
||||||
|
func state(
|
||||||
|
of step: Sale.Step
|
||||||
|
) -> String {
|
||||||
|
if step <= sale.completed {
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
return step == sale.next ? "current" : "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The form taking a step of the handshake.
|
||||||
|
func step(
|
||||||
|
_ name: String,
|
||||||
|
button title: String,
|
||||||
|
@HTMLBuilder fields: () -> some HTML
|
||||||
|
) -> some HTML {
|
||||||
|
form(
|
||||||
|
.class("sale__step"),
|
||||||
|
.action(.Route.sale(sale.number)),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
input(
|
||||||
|
.type(.hidden),
|
||||||
|
.name("step"),
|
||||||
|
.value(name)
|
||||||
|
)
|
||||||
|
fields()
|
||||||
|
button(
|
||||||
|
.class("button button--primary"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The seller dashboard: the offers waiting for an answer, the sales and their handshakes, and what is listed.
|
||||||
|
struct SellerDashboardPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The shop's listings still for sale, newest first.
|
||||||
|
let listings: [Listing]
|
||||||
|
|
||||||
|
/// The moment stamps are read relative to.
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
/// The offers waiting for the seller's answer, the soonest to expire first.
|
||||||
|
let offers: [Offer]
|
||||||
|
|
||||||
|
/// The shop's sales, the latest agreed first.
|
||||||
|
let sales: [Sale]
|
||||||
|
|
||||||
|
/// The viewer's shop.
|
||||||
|
let shop: Shop
|
||||||
|
|
||||||
|
/// The signed-in viewer.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the seller dashboard of a shop.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - shop: the viewer's shop.
|
||||||
|
/// - offers: the offers waiting for the seller's answer, the soonest to expire first.
|
||||||
|
/// - sales: the shop's sales, the latest agreed first.
|
||||||
|
/// - listings: the shop's listings still for sale, newest first.
|
||||||
|
/// - viewer: the signed-in viewer.
|
||||||
|
/// - now: the moment stamps are read relative to; the present by default.
|
||||||
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
|
init(
|
||||||
|
shop: Shop,
|
||||||
|
offers: [Offer],
|
||||||
|
sales: [Sale],
|
||||||
|
listings: [Listing],
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
now: Date = .init(),
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.listings = listings
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.now = now
|
||||||
|
self.offers = offers
|
||||||
|
self.sales = sales
|
||||||
|
self.shop = shop
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension SellerDashboardPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(viewer: viewer) {
|
||||||
|
div(.class("page sell")) {
|
||||||
|
div(.class("sell__head")) {
|
||||||
|
div(.class("sell__heading")) {
|
||||||
|
p(.class("type-eyebrow")) {
|
||||||
|
"\(shop.name) · seller dashboard"
|
||||||
|
}
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Hello, \(viewer?.firstName ?? shop.name)"
|
||||||
|
}
|
||||||
|
p(.class("type-body-sm sell__summary")) {
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--outline"),
|
||||||
|
.href(shop.path)
|
||||||
|
) {
|
||||||
|
"View public shop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ul(.class("tiles")) {
|
||||||
|
tile("\(listings.count)", "Active listings", nil)
|
||||||
|
tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" })
|
||||||
|
tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil)
|
||||||
|
tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch")
|
||||||
|
tile(String(format: "%.1f", shop.rating), "Rating", "\(shop.ratedSales) rated sales")
|
||||||
|
}
|
||||||
|
section(.class("card sell__panel")) {
|
||||||
|
div(.class("section-head")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Offers"
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"Offers under 80% of asking are auto-declined"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if offers.isEmpty {
|
||||||
|
p(.class("sell__none")) {
|
||||||
|
"No offers waiting."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ul(.class("rows")) {
|
||||||
|
ForEach(offers) { offer in
|
||||||
|
li(.class("rows__row rows__row--offer")) {
|
||||||
|
span {
|
||||||
|
a(
|
||||||
|
.class("rows__title"),
|
||||||
|
.href(.Route.conversation(offer.conversationID))
|
||||||
|
) {
|
||||||
|
offer.listing.title
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
" · \(offer.listing.chip.label)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"asks \(offer.listing.priceCents.euros)"
|
||||||
|
}
|
||||||
|
strong(.class("type-price-card")) {
|
||||||
|
offer.amountCents.euros
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
offer.expiresAt.countdown(from: now)
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"from \(offer.buyer.displayName)"
|
||||||
|
}
|
||||||
|
AnswerOffer(
|
||||||
|
offer: offer,
|
||||||
|
returnPath: .Route.sellerDashboard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section(.class("card sell__panel")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Recent sales"
|
||||||
|
}
|
||||||
|
if sales.isEmpty {
|
||||||
|
p(.class("sell__none")) {
|
||||||
|
"No sales yet. Accepting an offer opens the first sale record."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ul(.class("rows")) {
|
||||||
|
ForEach(sales) { sale in
|
||||||
|
li {
|
||||||
|
a(
|
||||||
|
.class("rows__row rows__row--sale"),
|
||||||
|
.href(.Route.sale(sale.number))
|
||||||
|
) {
|
||||||
|
strong {
|
||||||
|
sale.reference
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
"\(sale.listing.title) · \(sale.listing.chip.label)"
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
sale.priceCents.euros
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
sale.buyer.displayName
|
||||||
|
}
|
||||||
|
Handshake(completed: sale.completed)
|
||||||
|
span(.class("type-small")) {
|
||||||
|
sale.agreedAt.stamp(relativeTo: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section(.class("card sell__panel")) {
|
||||||
|
div(.class("section-head")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Active listings"
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("link-ink"),
|
||||||
|
.href(shop.path)
|
||||||
|
) {
|
||||||
|
"All \(listings.count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ul(.class("rows")) {
|
||||||
|
ForEach(listings) { listing in
|
||||||
|
li(.class("rows__row rows__row--listing")) {
|
||||||
|
span {
|
||||||
|
a(
|
||||||
|
.class("rows__title"),
|
||||||
|
.href(listing.path)
|
||||||
|
) {
|
||||||
|
listing.title
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
" · \(listing.chip.label)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
listing.priceCents.euros
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
listing.acceptsOffers ? "accepts offers" : "fixed price"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("robots"),
|
||||||
|
.content("noindex")
|
||||||
|
)
|
||||||
|
siteMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.trade]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Seller dashboard · \(shop.name) — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension SellerDashboardPage {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// What needs the seller's attention, in a line.
|
||||||
|
var summary: String {
|
||||||
|
let dispatch = sales.count { $0.next == .sent }
|
||||||
|
|
||||||
|
return [
|
||||||
|
offers.isEmpty ? "No offers waiting" : "\(offers.count) \(offers.count == 1 ? "offer" : "offers") waiting",
|
||||||
|
dispatch == 0 ? nil : "\(dispatch) \(dispatch == 1 ? "sale" : "sales") to dispatch",
|
||||||
|
(viewer?.unreadMessages ?? 0) == 0 ? nil : "\(viewer?.unreadMessages ?? 0) unread",
|
||||||
|
]
|
||||||
|
.compactMap(\.self)
|
||||||
|
.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// A figure, what it counts, and an optional note under it.
|
||||||
|
func tile(
|
||||||
|
_ figure: String,
|
||||||
|
_ label: String,
|
||||||
|
_ note: String?
|
||||||
|
) -> some HTML {
|
||||||
|
li(.class("card tiles__tile")) {
|
||||||
|
span(.class("type-stat")) {
|
||||||
|
figure
|
||||||
|
}
|
||||||
|
span(.class("type-label")) {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
if let note {
|
||||||
|
span(.class("type-small")) {
|
||||||
|
note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ extension SignInPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p(.class("notice notice--warn")) {
|
p(.class("notice notice--warn")) {
|
||||||
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it."
|
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
|
||||||
}
|
}
|
||||||
p(.class("gate__recover")) {
|
p(.class("gate__recover")) {
|
||||||
a(.href(.Route.recover)) {
|
a(.href(.Route.recover)) {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ extension StaticFile {
|
|||||||
.signIn,
|
.signIn,
|
||||||
.site,
|
.site,
|
||||||
.sitemap,
|
.sitemap,
|
||||||
|
.trade,
|
||||||
]
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -158,4 +159,7 @@ extension StaticFile {
|
|||||||
|
|
||||||
/// The `sitemap.xml` crawler sitemap.
|
/// The `sitemap.xml` crawler sitemap.
|
||||||
static let sitemap = Self("sitemap", as: .xml)
|
static let sitemap = Self("sitemap", as: .xml)
|
||||||
|
|
||||||
|
/// The `css/trade.css` stylesheet shared by the offer sheet, the messages, the sale record, and the seller dashboard.
|
||||||
|
static let trade = Self("trade", as: .css)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ struct Viewer: Sendable {
|
|||||||
/// The viewer's name.
|
/// The viewer's name.
|
||||||
let displayName: String
|
let displayName: String
|
||||||
|
|
||||||
|
/// The slug of the shop the viewer owns, or `nil` when they own none.
|
||||||
|
let shopSlug: String?
|
||||||
|
|
||||||
/// The number of new listings matching the viewer's want list.
|
/// The number of new listings matching the viewer's want list.
|
||||||
let newWantMatches: Int
|
let newWantMatches: Int
|
||||||
|
|
||||||
@@ -19,14 +22,17 @@ struct Viewer: Sendable {
|
|||||||
/// Creates a viewer.
|
/// Creates a viewer.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - displayName: the viewer's name.
|
/// - displayName: the viewer's name.
|
||||||
|
/// - shopSlug: the slug of the shop the viewer owns, or `nil` (the default) when they own none.
|
||||||
/// - newWantMatches: the number of new listings matching the viewer's want list; none by default.
|
/// - newWantMatches: the number of new listings matching the viewer's want list; none by default.
|
||||||
/// - unreadMessages: the number of messages the viewer has not read yet; none by default.
|
/// - unreadMessages: the number of messages the viewer has not read yet; none by default.
|
||||||
init(
|
init(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
shopSlug: String? = nil,
|
||||||
newWantMatches: Int = 0,
|
newWantMatches: Int = 0,
|
||||||
unreadMessages: Int = 0
|
unreadMessages: Int = 0
|
||||||
) {
|
) {
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.shopSlug = shopSlug
|
||||||
self.newWantMatches = newWantMatches
|
self.newWantMatches = newWantMatches
|
||||||
self.unreadMessages = unreadMessages
|
self.unreadMessages = unreadMessages
|
||||||
}
|
}
|
||||||
@@ -40,15 +46,29 @@ extension Viewer {
|
|||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates the viewer an account signs in as.
|
/// Creates the viewer an account signs in as.
|
||||||
/// - Parameter user: the signed-in account.
|
/// - Parameters:
|
||||||
|
/// - user: the signed-in account.
|
||||||
|
/// - shopSlug: the slug of the shop the account owns, or `nil` (the default) when it owns none.
|
||||||
|
/// - unreadMessages: the number of conversations holding something the account has not seen; none by default.
|
||||||
init(
|
init(
|
||||||
_ user: User
|
_ user: User,
|
||||||
|
shopSlug: String? = nil,
|
||||||
|
unreadMessages: Int = 0
|
||||||
) {
|
) {
|
||||||
self.init(displayName: user.displayName)
|
self.init(
|
||||||
|
displayName: user.displayName,
|
||||||
|
shopSlug: shopSlug,
|
||||||
|
unreadMessages: unreadMessages
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// Whether the viewer owns a shop.
|
||||||
|
var hasShop: Bool {
|
||||||
|
shopSlug != nil
|
||||||
|
}
|
||||||
|
|
||||||
/// The first word of the display name, shown beside the avatar.
|
/// The first word of the display name, shown beside the avatar.
|
||||||
var firstName: String {
|
var firstName: String {
|
||||||
displayName
|
displayName
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import Foundation
|
||||||
|
import Hummingbird
|
||||||
|
import HummingbirdElementary
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// Serves the path to a sale: the offer sheet, the messages, the sale records, and the seller dashboard.
|
||||||
|
///
|
||||||
|
/// Every route is for a signed-in account; signed out, a `GET` leads to the sign-in screen and back, and a `POST` to the sign-in screen alone.
|
||||||
|
/// The rules themselves — who may offer, answer, or take a step — live in `Persistence`'s `TradeRepository`.
|
||||||
|
///
|
||||||
|
/// - 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 TradeController<Context: RequestContext> {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let analytics: Analytics?
|
||||||
|
private let assetVersion: String?
|
||||||
|
|
||||||
|
/// The repository listings and shops are read through.
|
||||||
|
private let catalogue: CatalogueRepository
|
||||||
|
|
||||||
|
/// The store the signed-in account is read from.
|
||||||
|
private let sessions: SessionStore
|
||||||
|
|
||||||
|
/// The repository conversations, offers, and sales are read and written through.
|
||||||
|
private let trade: TradeRepository
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a trade controller.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - trade: the repository conversations, offers, and sales are read and written through.
|
||||||
|
/// - catalogue: the repository listings and shops are read through.
|
||||||
|
/// - sessions: the store the signed-in account is read from.
|
||||||
|
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
|
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||||
|
public init(
|
||||||
|
trade: TradeRepository,
|
||||||
|
catalogue: CatalogueRepository,
|
||||||
|
sessions: SessionStore,
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.catalogue = catalogue
|
||||||
|
self.sessions = sessions
|
||||||
|
self.trade = trade
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RouterController
|
||||||
|
|
||||||
|
extension TradeController: RouterController {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
public var routes: RouteCollection<Context> {
|
||||||
|
let routes = RouteCollection(context: Context.self)
|
||||||
|
|
||||||
|
routes.get(.Trade.offer, use: showSheet(.offer))
|
||||||
|
routes.post(.Trade.offer, use: makeOffer)
|
||||||
|
routes.get(.Trade.buy, use: showSheet(.buy))
|
||||||
|
routes.post(.Trade.buy, use: buy)
|
||||||
|
routes.get(.Trade.messages, use: messages)
|
||||||
|
routes.get(.Trade.newConversation, use: startConversation)
|
||||||
|
routes.get(.Trade.conversation, use: conversation)
|
||||||
|
routes.post(.Trade.conversation, use: post)
|
||||||
|
routes.get(.Trade.sale, use: sale)
|
||||||
|
routes.post(.Trade.sale, use: takeStep)
|
||||||
|
routes.get(.Trade.dashboard, use: dashboard)
|
||||||
|
routes.post(.Trade.answer, use: answer)
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offers
|
||||||
|
|
||||||
|
private extension TradeController {
|
||||||
|
|
||||||
|
/// Handles buying a listing at its asking price: opens the sale record and leads to it.
|
||||||
|
@Sendable
|
||||||
|
func buy(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let slug = try context.parameters.require(.slug)
|
||||||
|
|
||||||
|
guard let (user, viewer) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal)
|
||||||
|
} catch let error as TradeError {
|
||||||
|
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles an offer: makes it, and leads to the conversation it was made in. One that breaks a rule gets the sheet back, saying why.
|
||||||
|
@Sendable
|
||||||
|
func makeOffer(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let slug = try context.parameters.require(.slug)
|
||||||
|
|
||||||
|
guard let (user, viewer) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(OfferForm.self, from: request, context: context)
|
||||||
|
let amountCents = Self.cents(form.amount)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let id = try await trade.makeOffer(
|
||||||
|
onListing: slug,
|
||||||
|
buyer: user,
|
||||||
|
amountCents: amountCents ?? 0,
|
||||||
|
note: form.note ?? ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return .redirect(to: .Route.conversation(id), type: .normal)
|
||||||
|
} catch let error as TradeError {
|
||||||
|
return try await sheet(
|
||||||
|
.offer,
|
||||||
|
slug: slug,
|
||||||
|
amountCents: amountCents,
|
||||||
|
note: form.note ?? "",
|
||||||
|
error: error,
|
||||||
|
viewer: viewer,
|
||||||
|
request: request,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the handler showing the sheet in one of its modes. A listing that cannot be offered on, or bought, leads back to itself.
|
||||||
|
func showSheet(
|
||||||
|
_ mode: OfferPage.Mode
|
||||||
|
) -> @Sendable (Request, Context) async throws -> Response {
|
||||||
|
{ request, context in
|
||||||
|
let slug = try context.parameters.require(.slug)
|
||||||
|
|
||||||
|
guard let (_, viewer) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await sheet(
|
||||||
|
mode,
|
||||||
|
slug: slug,
|
||||||
|
amountCents: request.uri.queryParameters.get("amount").flatMap(Self.cents),
|
||||||
|
viewer: viewer,
|
||||||
|
request: request,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the sheet — with a `422` when it reports an error — or leads back to a listing the sheet is not for.
|
||||||
|
func sheet(
|
||||||
|
_ mode: OfferPage.Mode,
|
||||||
|
slug: String,
|
||||||
|
amountCents: Int? = nil,
|
||||||
|
note: String = "",
|
||||||
|
error: TradeError? = nil,
|
||||||
|
viewer: Viewer,
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let listing = try await catalogue.listing(slug: slug) else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
listing.isAvailable,
|
||||||
|
viewer.shopSlug != listing.shop.slug,
|
||||||
|
mode == .buy || listing.acceptsOffers
|
||||||
|
else {
|
||||||
|
return .redirect(to: listing.path, type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HTMLResponse(
|
||||||
|
status: error == nil ? .ok : .unprocessableContent,
|
||||||
|
additionalHeaders: .perViewer
|
||||||
|
) { [analytics, assetVersion] in
|
||||||
|
OfferPage(
|
||||||
|
listing: listing,
|
||||||
|
mode: mode,
|
||||||
|
amountCents: amountCents,
|
||||||
|
note: note,
|
||||||
|
error: error?.explanation,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Messages
|
||||||
|
|
||||||
|
private extension TradeController {
|
||||||
|
|
||||||
|
/// Handles a request for a conversation, marking it read.
|
||||||
|
@Sendable
|
||||||
|
func conversation(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let id = context.parameters.get(.id, as: UUID.self),
|
||||||
|
let open = try await trade.conversation(id: id, for: user)
|
||||||
|
else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await messagesPage(open: open, user: user, request: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for the messages, with no conversation open.
|
||||||
|
@Sendable
|
||||||
|
func messages(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await messagesPage(open: nil, user: user, request: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the messages. The viewer is read after the open conversation was marked read, so the header's count is current.
|
||||||
|
func messagesPage(
|
||||||
|
open: Conversation?,
|
||||||
|
user: User,
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let conversations = try await trade.conversations(for: user)
|
||||||
|
let viewer = await sessions.viewer(for: request)
|
||||||
|
let box = request.uri.queryParameters.get("box").flatMap(Conversation.Side.init)
|
||||||
|
|
||||||
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
|
MessagesPage(
|
||||||
|
conversations: conversations,
|
||||||
|
open: open,
|
||||||
|
box: box,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a message posted to a conversation.
|
||||||
|
@Sendable
|
||||||
|
func post(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let id = context.parameters.get(.id, as: UUID.self) else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(MessageForm.self, from: request, context: context)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await trade.post(form.body, to: id, from: user)
|
||||||
|
} catch is TradeError {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .redirect(to: .Route.conversation(id), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the start of a conversation about a listing, or with a shop at large: finds or starts it, and leads to it.
|
||||||
|
@Sendable
|
||||||
|
func startConversation(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing = request.uri.queryParameters.get("listing")
|
||||||
|
let shop = request.uri.queryParameters.get("shop")
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let listing {
|
||||||
|
return .redirect(to: .Route.conversation(try await trade.conversation(aboutListing: listing, buyer: user)), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shop {
|
||||||
|
return .redirect(to: .Route.conversation(try await trade.conversation(withShop: shop, buyer: user)), type: .found)
|
||||||
|
}
|
||||||
|
} catch TradeError.ownListing {
|
||||||
|
return .redirect(to: .Route.sellerDashboard, type: .found)
|
||||||
|
} catch is TradeError {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .redirect(to: .Route.messages, type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sales
|
||||||
|
|
||||||
|
private extension TradeController {
|
||||||
|
|
||||||
|
/// Handles a request for a sale record; one the account is no party to is not found.
|
||||||
|
@Sendable
|
||||||
|
func sale(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, viewer) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let number = context.parameters.get(.number, as: Int.self),
|
||||||
|
let sale = try await trade.sale(number: number, for: user),
|
||||||
|
let role = sale.role(of: user.id)
|
||||||
|
else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
|
SalePage(
|
||||||
|
sale: sale,
|
||||||
|
role: role,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a step of a sale's handshake. One that is not the account's to take now changes nothing, and leads back to the record.
|
||||||
|
@Sendable
|
||||||
|
func takeStep(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let number = context.parameters.get(.number, as: Int.self) else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(StepForm.self, from: request, context: context)
|
||||||
|
let action: Sale.Action? = switch form.step {
|
||||||
|
case "paid": .markPaid(note: form.note ?? "")
|
||||||
|
case "sent": .markSent(note: form.note ?? "")
|
||||||
|
case "received": .markReceived
|
||||||
|
case "rated": .rate(stars: form.stars ?? 0)
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let action {
|
||||||
|
_ = try? await trade.perform(action, onSale: number, by: user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .redirect(to: .Route.sale(number), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selling
|
||||||
|
|
||||||
|
private extension TradeController {
|
||||||
|
|
||||||
|
/// Handles a seller's answer to an offer: accepting leads to the sale record opened, declining back to where the answer was given.
|
||||||
|
@Sendable
|
||||||
|
func answer(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let id = context.parameters.get(.id, as: UUID.self) else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context)
|
||||||
|
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
|
||||||
|
|
||||||
|
return .redirect(
|
||||||
|
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
|
||||||
|
type: .normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
|
||||||
|
@Sendable
|
||||||
|
func dashboard(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, viewer) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let shop = try await trade.shop(ownedBy: user) else {
|
||||||
|
return .redirect(to: .Route.openShop, type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
let offers = try await trade.openOffers(for: user)
|
||||||
|
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
|
||||||
|
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
|
||||||
|
|
||||||
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
|
SellerDashboardPage(
|
||||||
|
shop: shop,
|
||||||
|
offers: offers,
|
||||||
|
sales: sales,
|
||||||
|
listings: listings,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension TradeController {
|
||||||
|
|
||||||
|
/// The signed-in account a request comes from, and the viewer the pages show of it; or `nil` signed out.
|
||||||
|
func account(
|
||||||
|
of request: Request
|
||||||
|
) async -> (User, Viewer)? {
|
||||||
|
guard
|
||||||
|
let user = await sessions.user(for: request),
|
||||||
|
let viewer = await sessions.viewer(for: request)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (user, viewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An amount typed in euros — `215`, `€215`, `215,50` — in euro cents, or `nil` when it is not one.
|
||||||
|
static func cents(
|
||||||
|
_ amount: String
|
||||||
|
) -> Int? {
|
||||||
|
let cleaned = amount
|
||||||
|
.replacingOccurrences(of: "€", with: "")
|
||||||
|
.replacingOccurrences(of: ",", with: ".")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
guard
|
||||||
|
let euros = Double(cleaned),
|
||||||
|
euros.isFinite,
|
||||||
|
euros >= 0,
|
||||||
|
euros < 1_000_000
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Int((euros * 100).rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
/// The fields the offer sheet posts.
|
||||||
|
private struct OfferForm: Decodable {
|
||||||
|
let amount: String
|
||||||
|
let note: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field the composer posts.
|
||||||
|
private struct MessageForm: Decodable {
|
||||||
|
let body: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields a step of the handshake posts.
|
||||||
|
private struct StepForm: Decodable {
|
||||||
|
let step: String
|
||||||
|
let note: String?
|
||||||
|
let stars: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields an answer to an offer posts.
|
||||||
|
private struct AnswerForm: Decodable {
|
||||||
|
let answer: String
|
||||||
|
let `return`: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension TradeError {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// Why the step was turned down, as the person taking it reads it.
|
||||||
|
var explanation: String {
|
||||||
|
switch self {
|
||||||
|
case .invalidAmount: "Enter an amount above zero, up to the asking price. To pay the asking price, use Buy now."
|
||||||
|
case .listingUnavailable: "This copy is no longer for sale."
|
||||||
|
case .notAllowed: "That step isn't yours to take right now."
|
||||||
|
case .offerAlreadyOpen: "You already have an open offer on this listing. Wait for the seller's answer, or for it to expire."
|
||||||
|
case .offerTooLow(let floorCents): "The seller auto-declines offers under \(floorCents.euros)."
|
||||||
|
case .offersNotAccepted: "This seller doesn't take offers on this copy."
|
||||||
|
case .ownListing: "This is your own listing."
|
||||||
|
case .shopUnattended: "This shop can't take orders right now."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private extension RouterPath {
|
||||||
|
/// A namespace for the ``TradeController`` route paths.
|
||||||
|
enum Trade {
|
||||||
|
static let answer: RouterPath = .init("\(String.Route.sellerDashboard)/offers/:\(String.id)")
|
||||||
|
static let buy: RouterPath = .init(.Route.buy(":\(String.slug)"))
|
||||||
|
static let conversation: RouterPath = .init("\(String.Route.messages)/:\(String.id)")
|
||||||
|
static let dashboard: RouterPath = .init(.Route.sellerDashboard)
|
||||||
|
static let messages: RouterPath = .init(.Route.messages)
|
||||||
|
static let newConversation: RouterPath = .init("\(String.Route.messages)/new")
|
||||||
|
static let offer: RouterPath = .init(.Route.offer(":\(String.slug)"))
|
||||||
|
static let sale: RouterPath = .init(.Route.sale(0).replacingOccurrences(of: "0", with: ":\(String.number)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
/// The name of the route parameter carrying a conversation's or an offer's identifier.
|
||||||
|
static let id = "id"
|
||||||
|
/// The name of the route parameter carrying a sale's number.
|
||||||
|
static let number = "number"
|
||||||
|
/// The name of the route parameter carrying a listing's slug.
|
||||||
|
static let slug = "slug"
|
||||||
|
}
|
||||||
@@ -10,13 +10,21 @@ public actor SessionStore {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
|
||||||
|
private let trade: TradeRepository?
|
||||||
|
|
||||||
/// The accounts signed in, by session token.
|
/// The accounts signed in, by session token.
|
||||||
private var users: [String: User] = [:]
|
private var users: [String: User] = [:]
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates an empty session store.
|
/// Creates an empty session store.
|
||||||
public init() {}
|
/// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
|
||||||
|
public init(
|
||||||
|
trade: TradeRepository? = nil
|
||||||
|
) {
|
||||||
|
self.trade = trade
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
@@ -88,13 +96,26 @@ extension SessionStore {
|
|||||||
request.cookies[cookieName]?.value
|
request.cookies[cookieName]?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The signed-in viewer the request comes from, or `nil` when it carries no live session.
|
/// The signed-in account the request comes from, or `nil` when it carries no live session.
|
||||||
|
func user(
|
||||||
|
for request: Request
|
||||||
|
) -> User? {
|
||||||
|
Self.token(in: request).flatMap(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in viewer the request comes from, with what the header shows of them, or `nil` when it carries no live session.
|
||||||
func viewer(
|
func viewer(
|
||||||
for request: Request
|
for request: Request
|
||||||
) -> Viewer? {
|
) async -> Viewer? {
|
||||||
Self.token(in: request)
|
guard let user = user(for: request) else {
|
||||||
.flatMap(user)
|
return nil
|
||||||
.map(Viewer.init)
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
user,
|
||||||
|
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
|
||||||
|
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,52 @@ struct AppTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `buying to open a sale record for its buyer alone`() async throws {
|
||||||
|
try await app(
|
||||||
|
staticFilesPath: staticFilesPath
|
||||||
|
).test(.router) { client in
|
||||||
|
let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"]
|
||||||
|
let cookie = try await client.execute(
|
||||||
|
uri: "/sign-in",
|
||||||
|
method: .post,
|
||||||
|
headers: form,
|
||||||
|
body: ByteBuffer(string: "return=/")
|
||||||
|
) { response in
|
||||||
|
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||||
|
}
|
||||||
|
var signedIn = form
|
||||||
|
|
||||||
|
signedIn[.cookie] = cookie
|
||||||
|
|
||||||
|
let sale = try await client.execute(
|
||||||
|
uri: "/listing/echo-country-3/buy",
|
||||||
|
method: .post,
|
||||||
|
headers: signedIn
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .seeOther)
|
||||||
|
|
||||||
|
return try #require(response.headers[.location])
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: sale,
|
||||||
|
method: .get,
|
||||||
|
headers: [.cookie: cookie]
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body).contains("Awaiting payment"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: sale,
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `error page to be served for an unknown listing`() async throws {
|
func `error page to be served for an unknown listing`() async throws {
|
||||||
try await app(
|
try await app(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ struct SiteHeaderTests {
|
|||||||
func `renders the signed-in actions for a viewer`() {
|
func `renders the signed-in actions for a viewer`() {
|
||||||
let html = SiteHeader(viewer: .init(
|
let html = SiteHeader(viewer: .init(
|
||||||
displayName: "Mara Visser",
|
displayName: "Mara Visser",
|
||||||
|
shopSlug: "kestrel-row-comics",
|
||||||
newWantMatches: 2,
|
newWantMatches: 2,
|
||||||
unreadMessages: 3
|
unreadMessages: 3
|
||||||
)).render()
|
)).render()
|
||||||
@@ -38,6 +39,11 @@ struct SiteHeaderTests {
|
|||||||
#expect(!html.contains(#"href="/sign-in""#))
|
#expect(!html.contains(#"href="/sign-in""#))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `links the shop of a seller alone`() {
|
||||||
|
#expect(!SiteHeader(viewer: .init(displayName: "Mara")).render().contains(#"href="/sell""#))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `omits a badge that counts nothing`() {
|
func `omits a badge that counts nothing`() {
|
||||||
let html = SiteHeader(viewer: .init(displayName: "Mara")).render()
|
let html = SiteHeader(viewer: .init(displayName: "Mara")).render()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Date+Stamps extension",
|
||||||
|
.tags(.extensionTests)
|
||||||
|
)
|
||||||
|
struct DateStampsTests {
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
[0, -86_400, -172_800, -25_920_000] as [TimeInterval],
|
||||||
|
["today, 09:14", "yesterday, 09:14", "21 Sep, 09:14", "27 Nov 2025, 08:14"]
|
||||||
|
))
|
||||||
|
func `stamps a date relative to today, on Amsterdam's clock`(
|
||||||
|
offset: TimeInterval,
|
||||||
|
expects stamp: String
|
||||||
|
) {
|
||||||
|
#expect(Date.stub.addingTimeInterval(offset).stamp(relativeTo: .stub) == stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `tells days apart at Amsterdam's midnight, not UTC's`() {
|
||||||
|
// 23:30 and 01:00 in Amsterdam are 21:30 and 23:00 UTC: one day to UTC, two to Amsterdam.
|
||||||
|
let lateEvening = Date.stub.addingTimeInterval(-35_040)
|
||||||
|
let smallHours = Date.stub.addingTimeInterval(-29_640)
|
||||||
|
|
||||||
|
#expect(lateEvening.stamp(relativeTo: smallHours) == "yesterday, 23:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
[171_000, 1_800, 180_000, 259_260] as [TimeInterval],
|
||||||
|
["47 h left", "under an hour left", "2 days left", "3 days left"]
|
||||||
|
))
|
||||||
|
func `counts down in hours, then days`(
|
||||||
|
interval: TimeInterval,
|
||||||
|
expects countdown: String
|
||||||
|
) {
|
||||||
|
#expect(Date.stub.addingTimeInterval(interval).countdown(from: .stub) == countdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Trade pages",
|
||||||
|
.tags(.page)
|
||||||
|
)
|
||||||
|
struct TradePagesTests {
|
||||||
|
|
||||||
|
// MARK: OfferPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `offer sheet fills in a tenth under asking and offers the picks the seller would hear`() {
|
||||||
|
let html = OfferPage(listing: .stub(), mode: .offer).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"action="/listing/nightward-1/offer" method="post""#))
|
||||||
|
#expect(html.contains(#"name="amount" value="216""#))
|
||||||
|
#expect(html.contains(#"href="/listing/nightward-1/offer?amount=216" aria-current="true">€216 · −10%"#))
|
||||||
|
#expect(html.contains("€192 · −20%"))
|
||||||
|
#expect(html.contains("auto-declines under <strong>€192</strong>"))
|
||||||
|
#expect(html.contains("+ €6 shipping to NL"))
|
||||||
|
#expect(html.contains(#"content="noindex""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `offer sheet leaves out a pick under the seller's floor`() {
|
||||||
|
// €88 asking: the floor rounds up to €71, over the −20% pick of €70.
|
||||||
|
let html = OfferPage(listing: .stub(priceCents: 8_800), mode: .offer).render()
|
||||||
|
|
||||||
|
#expect(html.contains("€75 · −15%"))
|
||||||
|
#expect(!html.contains("−20%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `offer sheet gives back what was typed, saying what went wrong`() {
|
||||||
|
let html = OfferPage(
|
||||||
|
listing: .stub(),
|
||||||
|
mode: .offer,
|
||||||
|
amountCents: 15_000,
|
||||||
|
note: "Bundle?",
|
||||||
|
error: TradeError.offerTooLow(floorCents: 19_200).explanation
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"value="150""#))
|
||||||
|
#expect(html.contains(">Bundle?</textarea>"))
|
||||||
|
#expect(html.contains(#"role="alert">The seller auto-declines offers under €192."#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `buy sheet sums the asking price and the shipping`() {
|
||||||
|
let html = OfferPage(listing: .stub(), mode: .buy).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"action="/listing/nightward-1/buy" method="post""#))
|
||||||
|
#expect(html.contains("Agree to buy for €246"))
|
||||||
|
#expect(html.contains("CCN handles no money"))
|
||||||
|
#expect(!html.contains(#"name="amount""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SalePage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `sale record hands each party the step that is theirs`() {
|
||||||
|
func page(_ sale: Sale, _ role: Sale.Role) -> String {
|
||||||
|
SalePage(sale: sale, role: role, now: .stub).render()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(page(.stub(), .buyer).contains(#"name="step" value="paid""#))
|
||||||
|
#expect(page(.stub(), .seller).contains("Waiting for the buyer to pay"))
|
||||||
|
#expect(page(.stub(paid: true), .seller).contains(#"name="step" value="sent""#))
|
||||||
|
#expect(page(.stub(paid: true), .buyer).contains("Waiting for the seller to dispatch"))
|
||||||
|
#expect(page(.stub(paid: true, sent: true), .buyer).contains(#"name="step" value="received""#))
|
||||||
|
#expect(page(.stub(paid: true, sent: true, received: true), .seller).contains(#"name="stars" value="5""#))
|
||||||
|
#expect(page(.stub(paid: true, sent: true, received: true, buyerRating: 5), .buyer).contains("Waiting for the other side"))
|
||||||
|
#expect(!page(.stub(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4), .buyer).contains(#"name="step""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `sale record states what was agreed, frozen, and where the handshake stands`() {
|
||||||
|
let html = SalePage(sale: .stub(paid: true), role: .buyer, now: .stub).render()
|
||||||
|
|
||||||
|
#expect(html.contains("Sale record · CCN-48213 · you are the buyer"))
|
||||||
|
#expect(html.contains("Agreed today, 09:14 with Kestrel Row Comics · €215 + €6 tracked shipping"))
|
||||||
|
#expect(html.contains("Awaiting dispatch"))
|
||||||
|
#expect(html.contains("CGC 9.8 · cert 4312087-002"))
|
||||||
|
#expect(html.contains("€215 · offer accepted (asked €240)"))
|
||||||
|
#expect(html.contains("marked this as paid by iDEAL today, 09:20"))
|
||||||
|
#expect(html.contains("stepper__step--done"))
|
||||||
|
#expect(html.components(separatedBy: "stepper__step--current").count - 1 == 1)
|
||||||
|
#expect(html.contains(#"href="/messages/00000000-0000-0000-0000-0000000000cc""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `sale record shows the seller their buyer, and both the ratings once given`() {
|
||||||
|
let html = SalePage(
|
||||||
|
sale: .stub(priceCents: 24_000, paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4),
|
||||||
|
role: .seller,
|
||||||
|
now: .stub
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("you are the seller"))
|
||||||
|
#expect(html.contains("Mara Visser"))
|
||||||
|
#expect(html.contains("bought at the asking price"))
|
||||||
|
#expect(html.contains("Complete"))
|
||||||
|
#expect(html.contains("★★★★★"))
|
||||||
|
#expect(html.contains("★★★★☆"))
|
||||||
|
#expect(!html.contains("Visit shop"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: MessagesPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `messages tell the viewer's words from the other side's`() {
|
||||||
|
let html = MessagesPage(
|
||||||
|
conversations: [],
|
||||||
|
open: .stub(entries: [
|
||||||
|
.text("Is the case clean?", from: .mara),
|
||||||
|
.text("Clean all round.", from: .joost),
|
||||||
|
]),
|
||||||
|
now: .stub
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"entry--text entry--own"><p class="entry__bubble">Is the case clean?"#))
|
||||||
|
#expect(html.contains(#"entry--text entry--theirs"><p class="entry__bubble">Clean all round."#))
|
||||||
|
#expect(html.contains("You · today, 09:14"))
|
||||||
|
#expect(html.contains("Kestrel Row Comics · today, 09:14"))
|
||||||
|
#expect(html.contains(#"action="/messages/00000000-0000-0000-0000-0000000000cc" method="post""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `messages let the seller alone answer an open offer`() {
|
||||||
|
let entries = [Conversation.Entry(kind: .offer, sender: .mara, body: "Would you take €215?", offer: .stub(), createdAt: .stub)]
|
||||||
|
let selling = MessagesPage(conversations: [], open: .stub(side: .selling, entries: entries), now: .stub).render()
|
||||||
|
let buying = MessagesPage(conversations: [], open: .stub(side: .buying, entries: entries), now: .stub).render()
|
||||||
|
|
||||||
|
#expect(selling.contains("Their offer"))
|
||||||
|
#expect(selling.contains(#"action="/sell/offers/00000000-0000-0000-0000-0000000000aa""#))
|
||||||
|
#expect(selling.contains("47 h left"))
|
||||||
|
#expect(selling.contains("“Would you take €215?”"))
|
||||||
|
#expect(buying.contains("Your offer"))
|
||||||
|
#expect(buying.contains("Offer open · €215"))
|
||||||
|
#expect(!buying.contains("/sell/offers/"))
|
||||||
|
// One open offer per listing: no way to a second while it waits.
|
||||||
|
#expect(!buying.contains(#"href="/listing/nightward-1/offer""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `messages lead to the sale record from the event that opened it, once`() {
|
||||||
|
let html = MessagesPage(
|
||||||
|
conversations: [],
|
||||||
|
open: .stub(
|
||||||
|
entries: [
|
||||||
|
.init(kind: .offer, sender: .mara, body: "", offer: .stub(status: .accepted), createdAt: .stub),
|
||||||
|
.init(kind: .event, sender: .joost, body: "accepted the offer", saleNumber: 48_213, createdAt: .stub),
|
||||||
|
.init(kind: .event, sender: .mara, body: "marked the sale paid", saleNumber: 48_213, createdAt: .stub),
|
||||||
|
],
|
||||||
|
saleNumber: 48_213
|
||||||
|
),
|
||||||
|
now: .stub
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("Kestrel Row Comics accepted the offer · today, 09:14"))
|
||||||
|
#expect(html.contains("You marked the sale paid"))
|
||||||
|
#expect(html.components(separatedBy: "Sale record CCN-48213 created").count - 1 == 1)
|
||||||
|
#expect(html.contains("Agreed · CCN-48213"))
|
||||||
|
#expect(html.contains(">Accepted<"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `messages list the side asked for, counting what is unread on each`() {
|
||||||
|
let conversations: [Conversation] = [
|
||||||
|
.stub(side: .buying, entries: [.text("Shipping Thursday.", from: .joost)], unreadCount: 2),
|
||||||
|
.stub(side: .selling, entries: [.text("Still available?", from: .mara)], unreadCount: 1),
|
||||||
|
]
|
||||||
|
let buying = MessagesPage(conversations: conversations, now: .stub).render()
|
||||||
|
let selling = MessagesPage(conversations: conversations, box: .selling, now: .stub).render()
|
||||||
|
|
||||||
|
#expect(buying.contains("2 unread"))
|
||||||
|
#expect(buying.contains(#"href="/messages?box=buying" aria-current="true">Buying · 1"#))
|
||||||
|
#expect(buying.contains("Shipping Thursday."))
|
||||||
|
#expect(!buying.contains("Still available?"))
|
||||||
|
#expect(selling.contains("Still available?"))
|
||||||
|
#expect(selling.contains(">MV<"))
|
||||||
|
#expect(buying.contains("Pick a conversation."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SellerDashboardPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `seller dashboard lists what waits for the seller`() {
|
||||||
|
let html = SellerDashboardPage(
|
||||||
|
shop: .stub(),
|
||||||
|
offers: [.stub()],
|
||||||
|
sales: [.stub(paid: true), .stub(paid: true, sent: true)],
|
||||||
|
listings: [.stub()],
|
||||||
|
viewer: .init(displayName: "Joost de Wit", shopSlug: "kestrel-row-comics", unreadMessages: 2),
|
||||||
|
now: .stub
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("Hello, Joost"))
|
||||||
|
#expect(html.contains("1 offer waiting · 1 sale to dispatch · 2 unread"))
|
||||||
|
#expect(html.contains("from Mara Visser"))
|
||||||
|
#expect(html.contains(#"name="return" value="/sell""#))
|
||||||
|
#expect(html.contains(#"href="/sales/48213""#))
|
||||||
|
#expect(html.contains("1 awaiting your dispatch"))
|
||||||
|
#expect(html.contains(#"handshake__step handshake__step--done">Paid"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `seller dashboard says so when nothing waits`() {
|
||||||
|
let html = SellerDashboardPage(shop: .stub(), offers: [], sales: [], listings: [], now: .stub).render()
|
||||||
|
|
||||||
|
#expect(html.contains("No offers waiting."))
|
||||||
|
#expect(html.contains("No sales yet."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ListingPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `listing page closes its actions once sold, and to its own seller`() {
|
||||||
|
let sold = ListingPage(listing: .stub(soldAt: .stub), related: []).render()
|
||||||
|
let own = ListingPage(
|
||||||
|
listing: .stub(),
|
||||||
|
related: [],
|
||||||
|
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(sold.contains("This copy is sold."))
|
||||||
|
#expect(!sold.contains("Buy now"))
|
||||||
|
#expect(own.contains("This is your listing."))
|
||||||
|
#expect(!own.contains("Make an offer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Shared tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `no trade page renders an inline style`() {
|
||||||
|
let pages = [
|
||||||
|
OfferPage(listing: .stub(), mode: .offer).render(),
|
||||||
|
SalePage(sale: .stub(), role: .buyer, now: .stub).render(),
|
||||||
|
MessagesPage(conversations: [.stub()], open: .stub(), now: .stub).render(),
|
||||||
|
SellerDashboardPage(shop: .stub(), offers: [.stub()], sales: [.stub()], listings: [.stub()], now: .stub).render(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for html in pages {
|
||||||
|
#expect(!html.contains("style="))
|
||||||
|
#expect(!html.contains("<style"))
|
||||||
|
#expect(html.contains("/css/trade.css"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ private extension StaticFileTests {
|
|||||||
(.signIn, ["css/sign-in.css"]),
|
(.signIn, ["css/sign-in.css"]),
|
||||||
(.site, ["site.webmanifest"]),
|
(.site, ["site.webmanifest"]),
|
||||||
(.sitemap, ["sitemap.xml"]),
|
(.sitemap, ["sitemap.xml"]),
|
||||||
|
(.trade, ["css/trade.css"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import Hummingbird
|
||||||
|
import HummingbirdTesting
|
||||||
|
import Logging
|
||||||
|
import NIOCore
|
||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"TradeController controller",
|
||||||
|
.tags(.controller)
|
||||||
|
)
|
||||||
|
struct TradeControllerTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
private let listing = "/listing/nightward-1-cgc-9-8"
|
||||||
|
private let seller = "joost@kestrelrow.example"
|
||||||
|
|
||||||
|
// MARK: Gate tests
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
"/listing/nightward-1-cgc-9-8/offer", "/listing/nightward-1-cgc-9-8/buy", "/messages", "/sell", "/sales/48213",
|
||||||
|
])
|
||||||
|
func `leads a signed-out visitor to the sign-in screen, and back`(
|
||||||
|
uri: String
|
||||||
|
) async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
try await client.execute(uri: uri, method: .get) { response in
|
||||||
|
#expect(response.status == .found)
|
||||||
|
#expect(response.headers[.location] == "/sign-in?return=\(uri)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `takes no step for a signed-out visitor`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let location = try await post(client, "\(listing)/buy", "", as: nil)
|
||||||
|
|
||||||
|
#expect(location == "/sign-in?return=\(listing)")
|
||||||
|
|
||||||
|
try await client.execute(uri: listing, method: .get) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("Buy now"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Offers tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `shows the sheet to a buyer, and leads the listing's own seller back to it`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let joost = try await signIn(client, email: seller)
|
||||||
|
|
||||||
|
try await client.execute(uri: "\(listing)/offer?amount=204", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.cacheControl] == "private, no-cache")
|
||||||
|
#expect(body.contains(#"name="amount" value="204""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "\(listing)/offer", method: .get, headers: [.cookie: joost]) { response in
|
||||||
|
#expect(response.status == .found)
|
||||||
|
#expect(response.headers[.location] == listing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listing that takes no offers can still be bought.
|
||||||
|
try await client.execute(uri: "/listing/echo-country-3/offer", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(response.headers[.location] == "/listing/echo-country-3")
|
||||||
|
}
|
||||||
|
try await client.execute(uri: "/listing/echo-country-3/buy", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
["amount=150", "amount=abc", "amount=241"],
|
||||||
|
["auto-declines offers under €192", "Enter an amount above zero", "Enter an amount above zero"]
|
||||||
|
))
|
||||||
|
func `gives the sheet back with a 422 for an offer that breaks a rule`(
|
||||||
|
form: String,
|
||||||
|
expects explanation: String
|
||||||
|
) async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: "\(listing)/offer",
|
||||||
|
method: .post,
|
||||||
|
headers: [.cookie: mara, .contentType: "application/x-www-form-urlencoded"],
|
||||||
|
body: ByteBuffer(string: "\(form)¬e=Hello")
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .unprocessableContent)
|
||||||
|
#expect(body.contains(explanation))
|
||||||
|
#expect(body.contains(">Hello</textarea>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Flow tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `walks from an offer to a rated sale, between two signed-in accounts`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let joost = try await signIn(client, email: seller)
|
||||||
|
let conversation = try await post(client, "\(listing)/offer", "amount=215,00¬e=Would+you+take+215%3F", as: mara)
|
||||||
|
|
||||||
|
#expect(conversation.hasPrefix("/messages/"))
|
||||||
|
|
||||||
|
_ = try await post(client, conversation, "body=Is+the+case+clean%3F", as: mara)
|
||||||
|
|
||||||
|
// The seller hears of it in the header and the dashboard, and answers from the conversation.
|
||||||
|
let offer = try await client.execute(uri: "/sell", method: .get, headers: [.cookie: joost]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(#"aria-label="Messages, 1 unread""#))
|
||||||
|
#expect(body.contains("from Mara Visser"))
|
||||||
|
|
||||||
|
return try #require(body.firstMatch(of: /\/sell\/offers\/[0-9a-f-]+/)).output
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: conversation, method: .get, headers: [.cookie: joost]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("Their offer"))
|
||||||
|
#expect(body.contains("“Would you take 215?”"))
|
||||||
|
#expect(body.contains("Is the case clean?"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let sale = try await post(client, String(offer), "answer=accept&return=/sell", as: joost)
|
||||||
|
|
||||||
|
#expect(sale == "/sales/48213")
|
||||||
|
|
||||||
|
// The copy is off sale, for everyone.
|
||||||
|
try await client.execute(uri: listing, method: .get) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("This copy is sold."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A step out of turn changes nothing.
|
||||||
|
_ = try await post(client, sale, "step=sent", as: joost)
|
||||||
|
_ = try await post(client, sale, "step=paid", as: joost)
|
||||||
|
|
||||||
|
try await client.execute(uri: sale, method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("Awaiting payment"))
|
||||||
|
#expect(body.contains("you are the buyer"))
|
||||||
|
#expect(body.contains("€215 · offer accepted (asked €240)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await post(client, sale, "step=paid¬e=iDEAL", as: mara)
|
||||||
|
_ = try await post(client, sale, "step=sent¬e=DHL+3S123", as: joost)
|
||||||
|
_ = try await post(client, sale, "step=received", as: mara)
|
||||||
|
_ = try await post(client, sale, "step=rated&stars=5", as: mara)
|
||||||
|
_ = try await post(client, sale, "step=rated&stars=4", as: joost)
|
||||||
|
|
||||||
|
try await client.execute(uri: sale, method: .get, headers: [.cookie: joost]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("Complete"))
|
||||||
|
#expect(body.contains("DHL 3S123"))
|
||||||
|
#expect(body.contains("★★★★★"))
|
||||||
|
#expect(body.contains("★★★★☆"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `buys at the asking price, straight to the sale record`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let sale = try await post(client, "\(listing)/buy", "", as: mara)
|
||||||
|
|
||||||
|
#expect(sale == "/sales/48213")
|
||||||
|
|
||||||
|
// Sold: a second buyer gets the sheet's way out, not a second sale.
|
||||||
|
let sam = try await signIn(client, email: "sam@example.com")
|
||||||
|
|
||||||
|
try await client.execute(uri: "\(listing)/buy", method: .get, headers: [.cookie: sam]) { response in
|
||||||
|
#expect(response.headers[.location] == listing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `declining leads back to where the answer was given`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let joost = try await signIn(client, email: seller)
|
||||||
|
let conversation = try await post(client, "\(listing)/offer", "amount=215", as: mara)
|
||||||
|
let offer = try await client.execute(uri: conversation, method: .get, headers: [.cookie: joost]) { response in
|
||||||
|
String(try #require(String(buffer: response.body).firstMatch(of: /\/sell\/offers\/[0-9a-f-]+/)).output)
|
||||||
|
}
|
||||||
|
let location = try await post(client, offer, "answer=decline&return=\(conversation)", as: joost)
|
||||||
|
|
||||||
|
#expect(location == conversation)
|
||||||
|
|
||||||
|
try await client.execute(uri: conversation, method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains(">Declined<"))
|
||||||
|
#expect(body.contains("Kestrel Row Comics declined the offer"))
|
||||||
|
// Free to offer again.
|
||||||
|
#expect(body.contains(#"href="\#(listing)/offer""#))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Privacy tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps conversations and sales to their parties`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let sam = try await signIn(client, email: "sam@example.com")
|
||||||
|
let conversation = try await post(client, "\(listing)/offer", "amount=215", as: mara)
|
||||||
|
let sale = try await post(client, "/listing/echo-country-3/buy", "", as: mara)
|
||||||
|
|
||||||
|
for uri in [conversation, sale, "/messages/not-an-id", "/sales/1"] {
|
||||||
|
try await client.execute(uri: uri, method: .get, headers: [.cookie: sam]) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(
|
||||||
|
uri: conversation,
|
||||||
|
method: .post,
|
||||||
|
headers: [.cookie: sam, .contentType: "application/x-www-form-urlencoded"],
|
||||||
|
body: ByteBuffer(string: "body=Hello")
|
||||||
|
) { response in
|
||||||
|
#expect(response.status == .notFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `starts one conversation per listing, and leads an account without a shop to where one is opened`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
let first = try await get(client, "/messages/new?listing=nightward-1-cgc-9-8", as: mara)
|
||||||
|
let again = try await get(client, "/messages/new?listing=nightward-1-cgc-9-8", as: mara)
|
||||||
|
let shop = try await get(client, "/messages/new?shop=kestrel-row-comics", as: mara)
|
||||||
|
let dashboard = try await get(client, "/sell", as: mara)
|
||||||
|
|
||||||
|
#expect(first.hasPrefix("/messages/"))
|
||||||
|
#expect(first == again)
|
||||||
|
#expect(shop != first)
|
||||||
|
#expect(dashboard == "/sell/open-a-shop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension TradeControllerTests {
|
||||||
|
|
||||||
|
/// Requests a path as an account, returning where it leads.
|
||||||
|
func get(
|
||||||
|
_ client: any TestClientProtocol,
|
||||||
|
_ uri: String,
|
||||||
|
as cookie: String
|
||||||
|
) async throws -> String {
|
||||||
|
try await client.execute(uri: uri, method: .get, headers: [.cookie: cookie]) { response in
|
||||||
|
try #require(response.headers[.location])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts a form as an account — or signed out — returning where it leads.
|
||||||
|
func post(
|
||||||
|
_ client: any TestClientProtocol,
|
||||||
|
_ uri: String,
|
||||||
|
_ form: String,
|
||||||
|
as cookie: String?
|
||||||
|
) async throws -> String {
|
||||||
|
var headers: HTTPFields = [.contentType: "application/x-www-form-urlencoded"]
|
||||||
|
|
||||||
|
headers[.cookie] = cookie
|
||||||
|
|
||||||
|
return try await client.execute(uri: uri, method: .post, headers: headers, body: ByteBuffer(string: form)) { response in
|
||||||
|
#expect(response.status == .seeOther)
|
||||||
|
|
||||||
|
return try #require(response.headers[.location])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value.
|
||||||
|
func signIn(
|
||||||
|
_ client: any TestClientProtocol,
|
||||||
|
email: String = ""
|
||||||
|
) async throws -> String {
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/sign-in",
|
||||||
|
method: .post,
|
||||||
|
headers: [.contentType: "application/x-www-form-urlencoded"],
|
||||||
|
body: ByteBuffer(string: "email=\(email)&return=/")
|
||||||
|
) { response in
|
||||||
|
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the body against the session, catalogue, and trade routes sharing one session store, over a migrated, seeded in-memory database.
|
||||||
|
func withApp(
|
||||||
|
_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let fluent = try Service(
|
||||||
|
driver: .inMemory,
|
||||||
|
logger: Logger(label: "test")
|
||||||
|
)()
|
||||||
|
|
||||||
|
do {
|
||||||
|
await PrepareDB()(for: fluent)
|
||||||
|
|
||||||
|
try await fluent.migrate()
|
||||||
|
|
||||||
|
let catalogue = CatalogueRepository(fluent: fluent)
|
||||||
|
let trade = TradeRepository(fluent: fluent)
|
||||||
|
let sessions = SessionStore(trade: trade)
|
||||||
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
|
|
||||||
|
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
||||||
|
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
|
||||||
|
router.addRoutes(TradeController<WebsiteRequestContext>(trade: trade, catalogue: catalogue, sessions: sessions).routes)
|
||||||
|
|
||||||
|
try await Application(router: router).test(.router, body)
|
||||||
|
} catch {
|
||||||
|
try? await fluent.shutdown()
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try await fluent.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ extension Listing {
|
|||||||
condition: String = "NM/M",
|
condition: String = "NM/M",
|
||||||
keyNotes: String? = nil,
|
keyNotes: String? = nil,
|
||||||
priceCents: Int = 24_000,
|
priceCents: Int = 24_000,
|
||||||
acceptsOffers: Bool = true
|
acceptsOffers: Bool = true,
|
||||||
|
soldAt: Date? = nil
|
||||||
) -> Self {
|
) -> Self {
|
||||||
.init(
|
.init(
|
||||||
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
|
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
|
||||||
@@ -23,7 +24,8 @@ extension Listing {
|
|||||||
keyNotes: keyNotes, sellerNotes: "Clean case.", priceCents: priceCents, shippingCents: 600,
|
keyNotes: keyNotes, sellerNotes: "Clean case.", priceCents: priceCents, shippingCents: 600,
|
||||||
acceptsOffers: acceptsOffers,
|
acceptsOffers: acceptsOffers,
|
||||||
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
|
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
|
||||||
listedAt: .init(timeIntervalSince1970: 0)
|
listedAt: .init(timeIntervalSince1970: 0),
|
||||||
|
soldAt: soldAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
/// The moment the trade stubs are stamped at: 23 September 2026, 09:14 in Amsterdam.
|
||||||
|
static let stub = Date(timeIntervalSince1970: 1_790_147_640)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User {
|
||||||
|
/// The demo buyer.
|
||||||
|
static let mara = User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser")
|
||||||
|
/// The demo seller, owner of the stub shop.
|
||||||
|
static let joost = User(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit")
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Offer {
|
||||||
|
/// An offer of €215 by the demo buyer on the stub listing, open for another 47 hours unless told otherwise.
|
||||||
|
static func stub(
|
||||||
|
listing: Listing = .stub(),
|
||||||
|
amountCents: Int = 21_500,
|
||||||
|
status: Status = .open
|
||||||
|
) -> Self {
|
||||||
|
.init(
|
||||||
|
id: .init(uuidString: "00000000-0000-0000-0000-0000000000AA") ?? .init(),
|
||||||
|
conversationID: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
|
||||||
|
listing: listing,
|
||||||
|
buyer: .mara,
|
||||||
|
amountCents: amountCents,
|
||||||
|
status: status,
|
||||||
|
createdAt: .stub.addingTimeInterval(-3_600),
|
||||||
|
expiresAt: .stub.addingTimeInterval(47 * 3_600 + 60)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Sale {
|
||||||
|
/// A sale of the stub listing at €215 + €6, agreed at the stub moment, as far along as told.
|
||||||
|
static func stub(
|
||||||
|
priceCents: Int = 21_500,
|
||||||
|
paid: Bool = false,
|
||||||
|
sent: Bool = false,
|
||||||
|
received: Bool = false,
|
||||||
|
buyerRating: Int? = nil,
|
||||||
|
sellerRating: Int? = nil
|
||||||
|
) -> Self {
|
||||||
|
.init(
|
||||||
|
number: 48_213,
|
||||||
|
conversationID: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
|
||||||
|
listing: .stub(soldAt: .stub),
|
||||||
|
buyer: .mara,
|
||||||
|
seller: .joost,
|
||||||
|
priceCents: priceCents,
|
||||||
|
askedCents: 24_000,
|
||||||
|
shippingCents: 600,
|
||||||
|
declared: .init(grade: "CGC 9.8 · cert 4312087-002", pageQuality: "White", printing: "First printing", notes: "Clean case."),
|
||||||
|
agreedAt: .stub,
|
||||||
|
paidAt: paid ? .stub.addingTimeInterval(360) : nil,
|
||||||
|
paymentNote: paid ? "iDEAL" : nil,
|
||||||
|
sentAt: sent ? .stub.addingTimeInterval(720) : nil,
|
||||||
|
shippingNote: sent ? "DHL 3S123" : nil,
|
||||||
|
receivedAt: received ? .stub.addingTimeInterval(1_080) : nil,
|
||||||
|
buyerRating: buyerRating,
|
||||||
|
sellerRating: sellerRating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Conversation {
|
||||||
|
/// A conversation about the stub listing, seen from the given side, holding the given entries.
|
||||||
|
static func stub(
|
||||||
|
side: Side = .buying,
|
||||||
|
listing: Listing? = .stub(),
|
||||||
|
entries: [Entry] = [],
|
||||||
|
unreadCount: Int = 0,
|
||||||
|
saleNumber: Int? = nil
|
||||||
|
) -> Self {
|
||||||
|
.init(
|
||||||
|
id: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
|
||||||
|
buyer: .mara,
|
||||||
|
shop: .stub(),
|
||||||
|
listing: listing,
|
||||||
|
side: side,
|
||||||
|
entries: entries,
|
||||||
|
unreadCount: unreadCount,
|
||||||
|
saleNumber: saleNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Conversation.Entry {
|
||||||
|
/// Something a party wrote at the stub moment.
|
||||||
|
static func text(
|
||||||
|
_ body: String,
|
||||||
|
from sender: User
|
||||||
|
) -> Self {
|
||||||
|
.init(kind: .text, sender: sender, body: body, createdAt: .stub)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user