diff --git a/Services/Website/README.md b/Services/Website/README.md index 2677044..7f4b020 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -5,6 +5,7 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin The service: - Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)). - Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)). +- 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)). - 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)). @@ -50,6 +51,7 @@ LogRequestsMiddleware RootController (GET / → landing page, language negotiated; GET / → its other editions) CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request) 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) ``` @@ -64,9 +66,9 @@ The screens follow the **CCN marketplace** design canvas: ### Catalogue - **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch. -- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`). +- **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. -- **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. - **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. - **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 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. diff --git a/Services/Website/Resources/Static/css/listing.css b/Services/Website/Resources/Static/css/listing.css index 29842c9..692093d 100644 --- a/Services/Website/Resources/Static/css/listing.css +++ b/Services/Website/Resources/Static/css/listing.css @@ -133,24 +133,6 @@ font-size: 13px; } -.details { - display: grid; - grid-template-columns: minmax(96px, 180px) minmax(0, 1fr); - column-gap: var(--space-6); - border-bottom: 1px solid var(--line); - font-size: 14px; -} - -.details dt, -.details dd { - padding: 10px 0; - border-top: 1px solid var(--line); -} - -.details dt { - color: var(--muted); -} - .listing__notes { color: var(--ink-soft); font-size: 14px; diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index a9b5250..7075174 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -1818,6 +1818,25 @@ textarea.input { 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 { display: flex; diff --git a/Services/Website/Resources/Static/css/trade.css b/Services/Website/Resources/Static/css/trade.css new file mode 100644 index 0000000..3b8a6cf --- /dev/null +++ b/Services/Website/Resources/Static/css/trade.css @@ -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; + } +} diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 1d833f9..f129d5e 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -71,6 +71,7 @@ func application( logLevel: reader.logLevel, probe: Probe(fluent: fluent), accounts: AccountRepository(fluent: fluent), + trade: TradeRepository(fluent: fluent), repository: repository, listings: await homeListings( from: repository, @@ -184,6 +185,7 @@ private func logger( /// - logLevel: the level the request-logging middleware logs at. /// - probe: the probe consulted by the `HealthController` readiness route. /// - 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. /// - listings: the listings the landing page shows in its rows. /// - Returns: the configured router. @@ -200,6 +202,7 @@ private func router( logLevel: Logger.Level, probe: Probe, accounts: AccountRepository, + trade: TradeRepository, repository: CatalogueRepository, listings: HomeListings ) -> Router { @@ -209,7 +212,7 @@ private func router( context: AppRequestContext.self, options: .autoGenerateHeadEndpoints ) - let sessions = SessionStore() + let sessions = SessionStore(trade: trade) router.addMiddleware { LogRequestsMiddleware(logLevel) @@ -257,6 +260,13 @@ private func router( assetVersion: assetVersion, analytics: analytics ) + TradeController( + trade: trade, + catalogue: repository, + sessions: sessions, + assetVersion: assetVersion, + analytics: analytics + ) HealthController( probe: probe ) diff --git a/Services/Website/Sources/Library/Internal/Components/AnswerOffer.swift b/Services/Website/Sources/Library/Internal/Components/AnswerOffer.swift new file mode 100644 index 0000000..6b85a61 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/AnswerOffer.swift @@ -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 + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/Handshake.swift b/Services/Website/Sources/Library/Internal/Components/Handshake.swift new file mode 100644 index 0000000..736fcc3 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Handshake.swift @@ -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) + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/Icon.swift b/Services/Website/Sources/Library/Internal/Components/Icon.swift index ddda1b7..f563840 100644 --- a/Services/Website/Sources/Library/Internal/Components/Icon.swift +++ b/Services/Website/Sources/Library/Internal/Components/Icon.swift @@ -62,6 +62,8 @@ extension Icon { case document /// Filter lines. case filter + /// A circled "i". + case info /// A key. case key /// A padlock. @@ -90,6 +92,7 @@ extension Icon.Glyph { case .close: #""# case .document: #""# case .filter: #""# + case .info: #""# case .key: #""# case .lock: #""# case .message: #""# diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift index b3eb8bc..f2b9f87 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift @@ -203,11 +203,13 @@ private extension SiteHeader { unit: "unread", path: .Route.messages ) - a( - .class("site-header__text-link site-header__text-link--strong from-desktop"), - .href(.Route.sellerDashboard) - ) { - "My shop" + if viewer.hasShop { + a( + .class("site-header__text-link site-header__text-link--strong from-tablet"), + .href(.Route.sellerDashboard) + ) { + "My shop" + } } a( .class("site-header__account"), diff --git a/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift b/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift new file mode 100644 index 0000000..d8241b4 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/Date+Stamps.swift @@ -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 + +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift index 1b158a5..e86af6e 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -40,6 +40,14 @@ extension String { /// The want list. 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. /// - Parameter slug: the listing's URL slug. static func buy( @@ -48,6 +56,14 @@ extension String { "\(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. /// - Parameters: /// - path: the action's own path. @@ -93,6 +109,14 @@ extension String { "\(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. /// - Parameter slug: the shop's URL slug. static func shop( diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift index 61ea2e2..a97c77b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -222,42 +222,62 @@ private extension ListingPage { Unavailable("Save", modifier: "button--sm") } } - div(.class("buy-box__actions")) { - a( - .class("button button--primary button--lg"), - .href(gated(.Route.buy(listing.slug))) - ) { - "Buy now" + if !listing.isAvailable { + p(.class("notice")) { + "This copy is sold. A sale was agreed on CCN." } - if listing.acceptsOffers { - a( - .class("button button--outline button--lg"), - .href(gated(.Route.offer(listing.slug))) - ) { - "Make an offer" + } else if viewer?.shopSlug == listing.shop.slug { + p(.class("notice")) { + "This is your listing. Offers on it arrive in your " + a(.href(.Route.sellerDashboard)) { + "seller dashboard" } + "." } + } 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( - .class("button button--bare button--lg"), - .href(gated(.Route.message(listing: listing.slug))) + .class("button button--outline button--lg"), + .href(gated(.Route.offer(listing.slug))) ) { - Icon(.message, size: 16) - "Message seller" + "Make an offer" } } - if viewer == nil { - p(.class("gate-note")) { - Icon(.lock, size: 16) - span { - "Buying, offers, saving and messages need a signed-in account. " - a( - .class("gate-note__link"), - .href(.Route.signIn(returningTo: listing.path)) - ) { - "Sign in or join with a passkey" - } - " — browsing stays open to everyone." + a( + .class("button button--bare button--lg"), + .href(gated(.Route.message(listing: listing.slug))) + ) { + Icon(.message, size: 16) + "Message seller" + } + } + if viewer == nil { + p(.class("gate-note")) { + Icon(.lock, size: 16) + span { + "Buying, offers, saving and messages need a signed-in account. " + a( + .class("gate-note__link"), + .href(.Route.signIn(returningTo: listing.path)) + ) { + "Sign in or join with a passkey" } + " — browsing stays open to everyone." } } } diff --git a/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift b/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift new file mode 100644 index 0000000..81423d3 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/MessagesPage.swift @@ -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)" + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/OfferPage.swift b/Services/Website/Sources/Library/Internal/Pages/OfferPage.swift new file mode 100644 index 0000000..8c07e7f --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/OfferPage.swift @@ -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 + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SalePage.swift b/Services/Website/Sources/Library/Internal/Pages/SalePage.swift new file mode 100644 index 0000000..a560490 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SalePage.swift @@ -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 + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift new file mode 100644 index 0000000..e484299 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift @@ -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 + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift index 75f1f40..8d52c53 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift @@ -145,7 +145,7 @@ extension SignInPage: Page { } } 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")) { a(.href(.Route.recover)) { diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 70a7cac..09195d9 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -67,6 +67,7 @@ extension StaticFile { .signIn, .site, .sitemap, + .trade, ] } @@ -158,4 +159,7 @@ extension StaticFile { /// The `sitemap.xml` crawler sitemap. 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) } diff --git a/Services/Website/Sources/Library/Internal/Types/Viewer.swift b/Services/Website/Sources/Library/Internal/Types/Viewer.swift index 5192046..77b2405 100644 --- a/Services/Website/Sources/Library/Internal/Types/Viewer.swift +++ b/Services/Website/Sources/Library/Internal/Types/Viewer.swift @@ -8,6 +8,9 @@ struct Viewer: Sendable { /// The viewer's name. 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. let newWantMatches: Int @@ -19,14 +22,17 @@ struct Viewer: Sendable { /// Creates a viewer. /// - Parameters: /// - 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. /// - unreadMessages: the number of messages the viewer has not read yet; none by default. init( displayName: String, + shopSlug: String? = nil, newWantMatches: Int = 0, unreadMessages: Int = 0 ) { self.displayName = displayName + self.shopSlug = shopSlug self.newWantMatches = newWantMatches self.unreadMessages = unreadMessages } @@ -40,15 +46,29 @@ extension Viewer { // MARK: Initializers /// 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( - _ 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 + /// Whether the viewer owns a shop. + var hasShop: Bool { + shopSlug != nil + } + /// The first word of the display name, shown beside the avatar. var firstName: String { displayName diff --git a/Services/Website/Sources/Library/Public/Controllers/TradeController.swift b/Services/Website/Sources/Library/Public/Controllers/TradeController.swift new file mode 100644 index 0000000..0648a4c --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/TradeController.swift @@ -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 { + + // 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 { + 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" +} diff --git a/Services/Website/Sources/Library/Public/Types/SessionStore.swift b/Services/Website/Sources/Library/Public/Types/SessionStore.swift index 8e8bd2e..40cfce2 100644 --- a/Services/Website/Sources/Library/Public/Types/SessionStore.swift +++ b/Services/Website/Sources/Library/Public/Types/SessionStore.swift @@ -10,13 +10,21 @@ public actor SessionStore { // 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. private var users: [String: User] = [:] // MARK: Initializers /// 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 @@ -88,13 +96,26 @@ extension SessionStore { 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( for request: Request - ) -> Viewer? { - Self.token(in: request) - .flatMap(user) - .map(Viewer.init) + ) async -> Viewer? { + guard let user = user(for: request) else { + return nil + } + + return .init( + user, + shopSlug: (try? await trade?.shop(ownedBy: user))?.slug, + unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0 + ) } } diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 6dc603b..38503e0 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -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 func `error page to be served for an unknown listing`() async throws { try await app( diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift index b1fef3e..14c0991 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift @@ -25,6 +25,7 @@ struct SiteHeaderTests { func `renders the signed-in actions for a viewer`() { let html = SiteHeader(viewer: .init( displayName: "Mara Visser", + shopSlug: "kestrel-row-comics", newWantMatches: 2, unreadMessages: 3 )).render() @@ -38,6 +39,11 @@ struct SiteHeaderTests { #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 func `omits a badge that counts nothing`() { let html = SiteHeader(viewer: .init(displayName: "Mara")).render() diff --git a/Services/Website/Tests/Library/Cases/Internal/Extensions/DateStampsTests.swift b/Services/Website/Tests/Library/Cases/Internal/Extensions/DateStampsTests.swift new file mode 100644 index 0000000..03941b5 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Extensions/DateStampsTests.swift @@ -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) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/TradePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/TradePagesTests.swift new file mode 100644 index 0000000..46b1e9d --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/TradePagesTests.swift @@ -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 €192")) + #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?")) + #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">

Is the case clean?"#)) + #expect(html.contains(#"entry--text entry--theirs">

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("Hello")) + } + } + } + + // 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(repository: catalogue, sessions: sessions).routes) + router.addRoutes(SessionController(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes) + router.addRoutes(TradeController(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() + } + +} diff --git a/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift b/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift index 0f0ce0c..717afda 100644 --- a/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift +++ b/Services/Website/Tests/Library/Utils/Stubs/Listing+Stub.swift @@ -13,7 +13,8 @@ extension Listing { condition: String = "NM/M", keyNotes: String? = nil, priceCents: Int = 24_000, - acceptsOffers: Bool = true + acceptsOffers: Bool = true, + soldAt: Date? = nil ) -> Self { .init( 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, acceptsOffers: acceptsOffers, cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"), - listedAt: .init(timeIntervalSince1970: 0) + listedAt: .init(timeIntervalSince1970: 0), + soldAt: soldAt ) } } diff --git a/Services/Website/Tests/Library/Utils/Stubs/Trade+Stub.swift b/Services/Website/Tests/Library/Utils/Stubs/Trade+Stub.swift new file mode 100644 index 0000000..d5576f7 --- /dev/null +++ b/Services/Website/Tests/Library/Utils/Stubs/Trade+Stub.swift @@ -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) + } +}