Implemented phase 6 for the POC prototype in the Website service.
This commit is contained in:
1 parent
256d22df40
commit
f2f445827b
40 files changed
+1906
-37
No files matched your search
@@ -52,9 +52,9 @@ LogRequestsMiddleware
|
|||||||
→ FileMiddleware (serves Resources/Static)
|
→ FileMiddleware (serves Resources/Static)
|
||||||
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
|
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
|
||||||
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
|
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
|
||||||
SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
|
SessionController (GET /sign-in, POST /sign-in, POST /sign-out, GET/POST /recover, GET /recover/{token})
|
||||||
AccountController (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop)
|
AccountController (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop)
|
||||||
CollectingController (GET/POST /wants…, GET /series, GET /series/{slug}, GET /compare)
|
CollectingController (GET/POST /wants…, GET/POST /collection, GET /series…, GET /compare, GET /events)
|
||||||
PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON)
|
PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON)
|
||||||
ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
|
ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
|
||||||
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
|
TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
|
||||||
@@ -91,7 +91,8 @@ How it holds together:
|
|||||||
- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
|
- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
|
||||||
- **Adding a passkey** happens from the account screen alone: the account's own session is the proof of who is asking.
|
- **Adding a passkey** happens from the account screen alone: the account's own session is the proof of who is asking.
|
||||||
- **Sign-out** is a `POST` form on the account screen, so a link on another site cannot trigger it. The not-found page always renders signed out.
|
- **Sign-out** is a `POST` form on the account screen, so a link on another site cannot trigger it. The not-found page always renders signed out.
|
||||||
- **Left out**: passkey recovery.
|
- **Recovery**: `/recover` hands out a one-time link (`RecoveryLinks`: in memory, good once, for an hour); following it runs a WebAuthn registration that replaces every passkey on the account and signs in. **Stubbed**: with no mail to send, the link is shown on the screen — so anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built.
|
||||||
|
- **States**: a cancelled passkey prompt says so, and a browser without WebAuthn — or without scripts — is told it can't use passkeys.
|
||||||
|
|
||||||
The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in `swift test`: `Tests/Browser/passkeys.py` drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands).
|
The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in `swift test`: `Tests/Browser/passkeys.py` drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands).
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ The signatures need a WebAuthn client, so the ceremonies are checked in a browse
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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, and vacation mode.
|
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode. Terms, privacy, and seller rules have no screens: the design has none to build them from.
|
||||||
|
|
||||||
### Accounts and collecting
|
### Accounts and collecting
|
||||||
- **First sign-in**: a new account — joined by passkey or through the demo sign-in — meets `/welcome` once, picks its display name, and goes on to what it came for.
|
- **First sign-in**: a new account — joined by passkey or through the demo sign-in — meets `/welcome` once, picks its display name, and goes on to what it came for.
|
||||||
@@ -115,6 +116,8 @@ The signatures need a WebAuthn client, so the ceremonies are checked in a browse
|
|||||||
- **Open a shop**: one form — the shop's public identity, private or business seller (a business states its KvK number, as Dutch consumer law asks), and policies — then straight on to the first listing. A new shop starts unverified and unrated.
|
- **Open a shop**: one form — the shop's public identity, private or business seller (a business states its KvK number, as Dutch consumer law asks), and policies — then straight on to the first listing. A new shop starts unverified and unrated.
|
||||||
- **Want list**: a want is words a listing's title must carry, within a grade and a price, among copies shipping from the EU. Matches listed since the list was last looked at are new, and ring the header's bell. Saving a copy, and a gap in a series, both fill the want form.
|
- **Want list**: a want is words a listing's title must carry, within a grade and a price, among copies shipping from the EU. Matches listed since the list was last looked at are new, and ring the header's bell. Saving a copy, and a gap in a series, both fill the want form.
|
||||||
- **Series**: `Persistence` seeds a small catalogue of runs, which is what lets a gap show: every issue of the run, the cheapest copy of each, and *alert me* where none is listed. A series sellers list but the catalogue lacks is made up from its listings, without gaps.
|
- **Series**: `Persistence` seeds a small catalogue of runs, which is what lets a gap show: every issue of the run, the cheapest copy of each, and *alert me* where none is listed. A series sellers list but the catalogue lacks is made up from its listings, without gaps.
|
||||||
|
- **Collection**: the issues an account owns of each series, added from a form (`1-6, 9, 12`) or a switch per issue on the series screen — which then dims what is owned and counts the gaps listed right now.
|
||||||
|
- **Events**: a seeded calendar of cons and the shops trading at each. A seller picks theirs on the dashboard; a listing and its shop say where a copy can be picked up; and `pickup=` narrows a search to the shops at one event, or at any.
|
||||||
- **Compare**: up to three copies of an issue, ordered by what each costs delivered to the Netherlands, flagging a customs border.
|
- **Compare**: up to three copies of an issue, ordered by what each costs delivered to the Netherlands, flagging a customs border.
|
||||||
|
|
||||||
Every link on the site leads somewhere: `Tests/Browser/links.py` crawls them all — signed out, as a buyer, and as a seller — against a running service.
|
Every link on the site leads somewhere: `Tests/Browser/links.py` crawls them all — signed out, as a buyer, and as a seller — against a running service.
|
||||||
|
|||||||
@@ -233,3 +233,36 @@
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The viewer's collection on a series */
|
||||||
|
.series__collection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
max-width: 320px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series__issue--owned {
|
||||||
|
background: var(--tint);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series__own {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series__own:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series__own[aria-pressed="true"] {
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
@@ -1937,6 +1937,58 @@ textarea.input {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A progress bar, in twentieths: inline widths are off the table under the site's Content-Security-Policy. */
|
||||||
|
.progress {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: var(--progress, 0%);
|
||||||
|
background: var(--header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress--5 { --progress: 5%; }
|
||||||
|
.progress--10 { --progress: 10%; }
|
||||||
|
.progress--15 { --progress: 15%; }
|
||||||
|
.progress--20 { --progress: 20%; }
|
||||||
|
.progress--25 { --progress: 25%; }
|
||||||
|
.progress--30 { --progress: 30%; }
|
||||||
|
.progress--35 { --progress: 35%; }
|
||||||
|
.progress--40 { --progress: 40%; }
|
||||||
|
.progress--45 { --progress: 45%; }
|
||||||
|
.progress--50 { --progress: 50%; }
|
||||||
|
.progress--55 { --progress: 55%; }
|
||||||
|
.progress--60 { --progress: 60%; }
|
||||||
|
.progress--65 { --progress: 65%; }
|
||||||
|
.progress--70 { --progress: 70%; }
|
||||||
|
.progress--75 { --progress: 75%; }
|
||||||
|
.progress--80 { --progress: 80%; }
|
||||||
|
.progress--85 { --progress: 85%; }
|
||||||
|
.progress--90 { --progress: 90%; }
|
||||||
|
.progress--95 { --progress: 95%; }
|
||||||
|
|
||||||
|
.progress--100 {
|
||||||
|
--progress: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress--100::after {
|
||||||
|
background: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
/* The gate note under gated actions */
|
/* The gate note under gated actions */
|
||||||
.gate-note {
|
.gate-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1066,3 +1066,79 @@ a.rows__row:hover {
|
|||||||
padding-top: var(--space-8);
|
padding-top: var(--space-8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Collection and events
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.tiles--three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection__series,
|
||||||
|
.events__event {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection__series:hover,
|
||||||
|
.events__event:hover {
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection__text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection__complete {
|
||||||
|
color: var(--good);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events__intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.events__event {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events__name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing__sold {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
@@ -25,14 +25,23 @@ if (form && supported) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
button.hidden = false;
|
button.hidden = false;
|
||||||
|
|
||||||
|
// The word for a browser without passkeys is on the page by default; this one has them.
|
||||||
|
for (const notice of document.querySelectorAll("[data-passkey-unsupported]")) {
|
||||||
|
notice.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
status.textContent = "";
|
status.textContent = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// On the account screen the button adds a passkey to the account signed in; on the sign-in screen it signs in or joins.
|
// The button adds a passkey on the account screen, replaces them all from a recovery link, and otherwise signs in or joins.
|
||||||
const adding = button.dataset.passkey === "add";
|
const start = button.dataset.passkey === "add"
|
||||||
const start = adding ? { add: true } : { email: form.querySelector("[name=email]").value };
|
? { add: true }
|
||||||
|
: button.dataset.passkey === "recover"
|
||||||
|
? { recover: form.querySelector("[name=recover]").value }
|
||||||
|
: { email: form.querySelector("[name=email]").value };
|
||||||
const { mode, options } = await post("/sign-in/passkey/options", start);
|
const { mode, options } = await post("/sign-in/passkey/options", start);
|
||||||
let credential;
|
let credential;
|
||||||
|
|
||||||
@@ -55,8 +64,9 @@ if (form && supported) {
|
|||||||
|
|
||||||
window.location.assign(location);
|
window.location.assign(location);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Dismissing the browser's prompt is not an error worth a message.
|
status.textContent = error.name === "NotAllowedError"
|
||||||
status.textContent = error.name === "NotAllowedError" ? "" : error.message;
|
? "That didn't go through: the passkey prompt was cancelled. Nothing was created or changed — try again."
|
||||||
|
: error.message;
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ private func router(
|
|||||||
options: .autoGenerateHeadEndpoints
|
options: .autoGenerateHeadEndpoints
|
||||||
)
|
)
|
||||||
let collecting = CollectingRepository(fluent: fluent)
|
let collecting = CollectingRepository(fluent: fluent)
|
||||||
|
let recoveryLinks = RecoveryLinks()
|
||||||
let sessions = SessionStore(
|
let sessions = SessionStore(
|
||||||
trade: trade,
|
trade: trade,
|
||||||
collecting: collecting
|
collecting: collecting
|
||||||
@@ -268,6 +269,7 @@ private func router(
|
|||||||
CatalogueController<AppRequestContext>(
|
CatalogueController<AppRequestContext>(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
|
collecting: collecting,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
)
|
)
|
||||||
@@ -275,6 +277,7 @@ private func router(
|
|||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
catalogue: repository,
|
catalogue: repository,
|
||||||
|
recoveryLinks: recoveryLinks,
|
||||||
// Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
|
// Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
|
||||||
isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"),
|
isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"),
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
@@ -297,7 +300,8 @@ private func router(
|
|||||||
PasskeyController<AppRequestContext>(
|
PasskeyController<AppRequestContext>(
|
||||||
origin: passkeysOrigin,
|
origin: passkeysOrigin,
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
sessions: sessions
|
sessions: sessions,
|
||||||
|
recoveryLinks: recoveryLinks
|
||||||
)
|
)
|
||||||
ListingFormController<AppRequestContext>(
|
ListingFormController<AppRequestContext>(
|
||||||
catalogue: repository,
|
catalogue: repository,
|
||||||
@@ -312,6 +316,7 @@ private func router(
|
|||||||
trade: trade,
|
trade: trade,
|
||||||
catalogue: repository,
|
catalogue: repository,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
|
collecting: collecting,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics,
|
analytics: analytics,
|
||||||
onCatalogueChange: refreshHome
|
onCatalogueChange: refreshHome
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
/// What a browser without passkeys is told. Shown by default; `js/passkey.js` hides it once it finds WebAuthn.
|
||||||
|
struct PasskeyUnsupported: HTML {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
div(
|
||||||
|
.class("notice"),
|
||||||
|
.custom(name: "data-passkey-unsupported", value: "")
|
||||||
|
) {
|
||||||
|
p {
|
||||||
|
strong {
|
||||||
|
"This browser can't use passkeys."
|
||||||
|
}
|
||||||
|
" Open this page in Safari, Chrome, Edge or Firefox on a device with a screen lock — with scripts allowed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ private extension SiteFooter {
|
|||||||
("Open a shop", .Route.openShop),
|
("Open a shop", .Route.openShop),
|
||||||
("Seller dashboard", .Route.sellerDashboard),
|
("Seller dashboard", .Route.sellerDashboard),
|
||||||
("Grading guide", .Route.gradingGuide),
|
("Grading guide", .Route.gradingGuide),
|
||||||
|
("Trade at events", .Route.events),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -84,8 +85,10 @@ private extension SiteFooter {
|
|||||||
links: [
|
links: [
|
||||||
("Sign in or join", .Route.signIn),
|
("Sign in or join", .Route.signIn),
|
||||||
("Want list & alerts", .Route.wants),
|
("Want list & alerts", .Route.wants),
|
||||||
|
("My collection", .Route.collection),
|
||||||
("Messages", .Route.messages),
|
("Messages", .Route.messages),
|
||||||
("Passkeys & settings", .Route.account),
|
("Passkeys & settings", .Route.account),
|
||||||
|
("Lost your passkey?", .Route.recover),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ private extension SiteHeader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The format links, plus the series link shown on tablet only.
|
/// The format links, plus the series and events links shown on tablet only.
|
||||||
var navigation: some HTML {
|
var navigation: some HTML {
|
||||||
nav(
|
nav(
|
||||||
.class("site-nav"),
|
.class("site-nav"),
|
||||||
@@ -134,6 +134,12 @@ private extension SiteHeader {
|
|||||||
) {
|
) {
|
||||||
"Series"
|
"Series"
|
||||||
}
|
}
|
||||||
|
a(
|
||||||
|
.class("site-nav__link site-nav__link--extra"),
|
||||||
|
.href(.Route.events)
|
||||||
|
) {
|
||||||
|
"Events"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ extension Date {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The days an event runs over, e.g. `Sat 26 – Sun 27 Sep`.
|
||||||
|
static func span(
|
||||||
|
from start: Date,
|
||||||
|
to end: Date
|
||||||
|
) -> String {
|
||||||
|
let first = start.formatted(style.weekday(.abbreviated).day())
|
||||||
|
let last = end.formatted(style.weekday(.abbreviated).day().month(.abbreviated))
|
||||||
|
|
||||||
|
return calendar.isDate(start, inSameDayAs: end) ? last : "\(first) – \(last)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the date falls within the week from the given moment: an event "this weekend".
|
||||||
|
func isWithinAWeek(
|
||||||
|
of now: Date
|
||||||
|
) -> Bool {
|
||||||
|
(0..<7 * 86_400).contains(timeIntervalSince(now)) || Self.calendar.isDate(self, inSameDayAs: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The date as a day on a record, e.g. `20 Sep`.
|
||||||
|
var dayStamp: String {
|
||||||
|
formatted(Self.style.day().month(.abbreviated))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
/// 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.
|
/// Read on Amsterdam's clock: the marketplace is NL-first.
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ extension String {
|
|||||||
enum Route {
|
enum Route {
|
||||||
/// The account screen.
|
/// The account screen.
|
||||||
static let account = "/account"
|
static let account = "/account"
|
||||||
|
/// The collection tracker.
|
||||||
|
static let collection = "/collection"
|
||||||
/// The side-by-side comparison of copies.
|
/// The side-by-side comparison of copies.
|
||||||
static let compare = "/compare"
|
static let compare = "/compare"
|
||||||
|
/// The events where shops trade in person.
|
||||||
|
static let events = "/events"
|
||||||
/// The public grading guide.
|
/// The public grading guide.
|
||||||
static let gradingGuide = "/grading-guide"
|
static let gradingGuide = "/grading-guide"
|
||||||
/// The browse / home screen.
|
/// The browse / home screen.
|
||||||
@@ -17,6 +21,8 @@ extension String {
|
|||||||
static let messages = "/messages"
|
static let messages = "/messages"
|
||||||
/// The open-a-shop onboarding.
|
/// The open-a-shop onboarding.
|
||||||
static let openShop = "/sell/open-a-shop"
|
static let openShop = "/sell/open-a-shop"
|
||||||
|
/// The passkey recovery screen.
|
||||||
|
static let recover = "/recover"
|
||||||
/// The search results screen.
|
/// The search results screen.
|
||||||
static let search = "/search"
|
static let search = "/search"
|
||||||
/// The seller dashboard.
|
/// The seller dashboard.
|
||||||
@@ -101,6 +107,21 @@ extension String {
|
|||||||
"\(listing(slug))/offer"
|
"\(listing(slug))/offer"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path of the search narrowed to the copies that can be picked up at an event.
|
||||||
|
/// - Parameter slug: the event's URL slug.
|
||||||
|
static func pickup(
|
||||||
|
at slug: String
|
||||||
|
) -> String {
|
||||||
|
"\(search)?pickup=\(slug)&ships-from=anywhere"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path a one-time recovery link leads to.
|
||||||
|
static func recover(
|
||||||
|
token: String
|
||||||
|
) -> String {
|
||||||
|
"\(recover)/\(token)"
|
||||||
|
}
|
||||||
|
|
||||||
/// The path of a sale record.
|
/// The path of a sale record.
|
||||||
/// - Parameter number: the record's number.
|
/// - Parameter number: the record's number.
|
||||||
static func sale(
|
static func sale(
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ extension AccountPage: Page {
|
|||||||
a(.href(.Route.wants)) {
|
a(.href(.Route.wants)) {
|
||||||
"Want list"
|
"Want list"
|
||||||
}
|
}
|
||||||
|
a(.href(.Route.collection)) {
|
||||||
|
"Collection"
|
||||||
|
}
|
||||||
a(.href(.Route.messages)) {
|
a(.href(.Route.messages)) {
|
||||||
"Messages"
|
"Messages"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The collection tracker: what the viewer owns of each series, what is missing, and which of the gaps are listed right now.
|
||||||
|
struct CollectionPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// Every series in the catalogue, for the add-books form.
|
||||||
|
let catalogue: [Series]
|
||||||
|
|
||||||
|
/// The series the viewer owns issues of, each with the issues owned and the numbers of those listed for sale.
|
||||||
|
let entries: [Entry]
|
||||||
|
|
||||||
|
/// The signed-in viewer.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the collection tracker.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - catalogue: every series in the catalogue.
|
||||||
|
/// - owned: the issues the viewer owns, by series slug.
|
||||||
|
/// - listings: every listing for sale, to tell which gaps are listed.
|
||||||
|
/// - 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(
|
||||||
|
catalogue: [Series],
|
||||||
|
owned: [String: Set<Int>],
|
||||||
|
listings: [Listing],
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.catalogue = catalogue
|
||||||
|
self.entries = catalogue.compactMap { series in
|
||||||
|
guard let issues = owned[series.slug], !issues.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let ofSeries = listings.filter { $0.seriesSlug == series.slug }
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
series: series,
|
||||||
|
owned: issues,
|
||||||
|
listed: Set(ofSeries.compactMap(\.issueNumber)),
|
||||||
|
cover: ofSeries.first?.cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
extension CollectionPage {
|
||||||
|
/// A series in the collection.
|
||||||
|
struct Entry: Sendable {
|
||||||
|
let series: Series
|
||||||
|
let owned: Set<Int>
|
||||||
|
let listed: Set<Int>
|
||||||
|
let cover: Listing.Cover?
|
||||||
|
|
||||||
|
/// The issues of the run not owned yet.
|
||||||
|
var missing: Set<Int> {
|
||||||
|
Set(1...max(series.issueCount, 1)).subtracting(owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share of the run owned, to the nearest twentieth — what the progress bar's classes come in.
|
||||||
|
var progress: Int {
|
||||||
|
Int((Double(owned.count) / Double(max(series.issueCount, 1)) * 20).rounded()) * 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension CollectionPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(viewer: viewer) {
|
||||||
|
div(.class("page wants")) {
|
||||||
|
div(.class("section-head")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Collection"
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(total(\.owned)) \(total(\.owned) == 1 ? "book" : "books") · \(entries.count) series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries.isEmpty {
|
||||||
|
div(.class("empty")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Your collection is empty"
|
||||||
|
}
|
||||||
|
p(.class("type-body-sm")) {
|
||||||
|
"Add what you own and every series page shows the gaps — and which of them are listed right now."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ul(.class("tiles tiles--three")) {
|
||||||
|
tile(total(\.owned), "owned")
|
||||||
|
tile(total(\.missing), "missing")
|
||||||
|
tile(entries.reduce(0) { $0 + $1.missing.intersection($1.listed).count }, "listed now")
|
||||||
|
}
|
||||||
|
ul(.class("wants__list")) {
|
||||||
|
ForEach(entries) { entry in
|
||||||
|
li {
|
||||||
|
a(
|
||||||
|
.class("card collection__series"),
|
||||||
|
.href(.Route.series(entry.series.slug))
|
||||||
|
) {
|
||||||
|
if let cover = entry.cover {
|
||||||
|
Cover(cover, modifier: "cover--thumb")
|
||||||
|
}
|
||||||
|
span(.class("collection__text")) {
|
||||||
|
span(.class("collection__top")) {
|
||||||
|
strong {
|
||||||
|
entry.series.name
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
"\(entry.owned.count)/\(entry.series.issueCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(entry.series.publisher) · \(entry.series.years)"
|
||||||
|
}
|
||||||
|
span(.class("progress progress--\(entry.progress)")) {}
|
||||||
|
if entry.missing.isEmpty {
|
||||||
|
span(.class("collection__complete")) {
|
||||||
|
"Complete"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"\(entry.missing.count) missing · \(entry.missing.intersection(entry.listed).count) listed now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form(
|
||||||
|
.class("card wants__form"),
|
||||||
|
.action(.Route.collection),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Add books"
|
||||||
|
}
|
||||||
|
div(.class("listing-form__grid")) {
|
||||||
|
SelectField(
|
||||||
|
name: "series",
|
||||||
|
title: "Series",
|
||||||
|
options: catalogue.map { ($0.slug, "\($0.name) · \($0.issueCount) issues") }
|
||||||
|
)
|
||||||
|
TextField(
|
||||||
|
name: "issues",
|
||||||
|
title: "Issues you own",
|
||||||
|
placeholder: "1-6, 9, 12",
|
||||||
|
hint: "Numbers and ranges, separated by commas",
|
||||||
|
isWide: true,
|
||||||
|
maxLength: 120
|
||||||
|
)
|
||||||
|
}
|
||||||
|
div(.class("listing-form__submit")) {
|
||||||
|
button(
|
||||||
|
.class("button button--primary"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Add to collection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("type-small wants__note")) {
|
||||||
|
"On a series page, every issue has its own switch — and the gaps are one tap from an alert."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
"Collection — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension CollectionPage {
|
||||||
|
|
||||||
|
/// A figure and what it counts.
|
||||||
|
func tile(
|
||||||
|
_ figure: Int,
|
||||||
|
_ label: String
|
||||||
|
) -> some HTML {
|
||||||
|
li(.class("card tiles__tile")) {
|
||||||
|
span(.class("type-stat")) {
|
||||||
|
"\(figure)"
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of a set, summed over the series.
|
||||||
|
func total(
|
||||||
|
_ issues: KeyPath<Entry, Set<Int>>
|
||||||
|
) -> Int {
|
||||||
|
entries.reduce(0) { $0 + $1[keyPath: issues].count }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension CollectionPage {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The issue numbers a collector typed — `1-6, 9, 12` — capped so a range cannot run away.
|
||||||
|
static func issues(
|
||||||
|
in text: String
|
||||||
|
) -> [Int] {
|
||||||
|
text
|
||||||
|
.split { $0 == "," || $0 == " " || $0 == ";" }
|
||||||
|
.flatMap { part -> [Int] in
|
||||||
|
let bounds = part.split(separator: "-").compactMap { Int($0) }
|
||||||
|
|
||||||
|
switch bounds.count {
|
||||||
|
case 1: return [bounds[0]]
|
||||||
|
case 2 where bounds[0] <= bounds[1] && bounds[1] - bounds[0] < 500: return Array(bounds[0]...bounds[1])
|
||||||
|
default: return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { (1...999).contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The events where shops trade in person: agree on the site, skip shipping, collect the copy at the con.
|
||||||
|
struct EventsPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The events still to come, soonest first, each with the number of listings that can be picked up there.
|
||||||
|
let events: [(event: Event, listingCount: Int)]
|
||||||
|
|
||||||
|
/// The moment "this weekend" is judged from.
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
/// The signed-in viewer, or `nil` when signed out.
|
||||||
|
let viewer: Viewer?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the events screen.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - events: the events still to come, soonest first.
|
||||||
|
/// - listings: every listing for sale, counted per event by the shops trading there.
|
||||||
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||||
|
/// - now: the moment "this weekend" is judged from; 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(
|
||||||
|
events: [Event],
|
||||||
|
listings: [Listing],
|
||||||
|
viewer: Viewer? = nil,
|
||||||
|
now: Date = .init(),
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.events = events.map { event in
|
||||||
|
let shops = Set(event.shops.map(\.slug))
|
||||||
|
|
||||||
|
return (event, listings.count { shops.contains($0.shop.slug) })
|
||||||
|
}
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.now = now
|
||||||
|
self.viewer = viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension EventsPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
SiteLayout(viewer: viewer) {
|
||||||
|
div(.class("page wants")) {
|
||||||
|
div(.class("events__intro")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Pick up at a con"
|
||||||
|
}
|
||||||
|
p(.class("type-lead")) {
|
||||||
|
"Sellers mark which events they'll trade at. Agree on CCN, skip shipping, collect it in person."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if events.isEmpty {
|
||||||
|
div(.class("empty")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"No events coming up"
|
||||||
|
}
|
||||||
|
p(.class("type-body-sm")) {
|
||||||
|
"Every copy still ships, tracked."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ul(.class("wants__list")) {
|
||||||
|
ForEach(events) { entry in
|
||||||
|
li {
|
||||||
|
a(
|
||||||
|
.class("card events__event"),
|
||||||
|
.href(.Route.pickup(at: entry.event.slug))
|
||||||
|
) {
|
||||||
|
span(.class("wants__top")) {
|
||||||
|
span(.class("events__name")) {
|
||||||
|
strong {
|
||||||
|
entry.event.name
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
Date.span(from: entry.event.startsOn, to: entry.event.endsOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.event.startsOn.isWithinAWeek(of: now) {
|
||||||
|
Chip("This weekend", tone: .warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
entry.event.venue
|
||||||
|
}
|
||||||
|
span(.class("events__chips")) {
|
||||||
|
Chip("\(entry.event.shops.count) \(entry.event.shops.count == 1 ? "shop" : "shops") trading")
|
||||||
|
Chip("\(entry.listingCount) \(entry.listingCount == 1 ? "listing" : "listings") for pickup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("type-small wants__note")) {
|
||||||
|
"Selling? Pick the events you'll trade at on your seller dashboard."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.trade]
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String? {
|
||||||
|
"Comic cons where CCN shops trade in person: agree on a copy online, collect it at the event."
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Events — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ struct ListingPage {
|
|||||||
/// The listing shown.
|
/// The listing shown.
|
||||||
let listing: Listing
|
let listing: Listing
|
||||||
|
|
||||||
|
/// The number of other copies of the same title for sale: in this shop, and across the site.
|
||||||
|
let otherCopies: (inShop: Int, everywhere: Int)
|
||||||
|
|
||||||
|
/// The upcoming events the shop trades at, where the copy can be picked up.
|
||||||
|
let pickups: [Event]
|
||||||
|
|
||||||
/// Other listings from the same shop.
|
/// Other listings from the same shop.
|
||||||
let related: [Listing]
|
let related: [Listing]
|
||||||
|
|
||||||
@@ -27,12 +33,16 @@ struct ListingPage {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - listing: the listing shown.
|
/// - listing: the listing shown.
|
||||||
/// - related: other listings from the same shop.
|
/// - related: other listings from the same shop.
|
||||||
|
/// - pickups: the upcoming events the shop trades at; none by default.
|
||||||
|
/// - otherCopies: the number of other copies of the same title for sale, in this shop and across the site; none by default.
|
||||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
init(
|
init(
|
||||||
listing: Listing,
|
listing: Listing,
|
||||||
related: [Listing],
|
related: [Listing],
|
||||||
|
pickups: [Event] = [],
|
||||||
|
otherCopies: (inShop: Int, everywhere: Int) = (0, 0),
|
||||||
viewer: Viewer? = nil,
|
viewer: Viewer? = nil,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
@@ -42,6 +52,8 @@ struct ListingPage {
|
|||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.listing = listing
|
self.listing = listing
|
||||||
self.locale = .init(identifier: "en")
|
self.locale = .init(identifier: "en")
|
||||||
|
self.otherCopies = otherCopies
|
||||||
|
self.pickups = pickups
|
||||||
self.related = related
|
self.related = related
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +240,15 @@ private extension ListingPage {
|
|||||||
span(.class("type-body-sm buy-box__terms")) {
|
span(.class("type-body-sm buy-box__terms")) {
|
||||||
"+ \(listing.shippingCents.euros) tracked shipping to NL"
|
"+ \(listing.shippingCents.euros) tracked shipping to NL"
|
||||||
listing.acceptsOffers ? " · accepts offers" : ""
|
listing.acceptsOffers ? " · accepts offers" : ""
|
||||||
|
if let event = pickups.first {
|
||||||
|
" · "
|
||||||
|
a(
|
||||||
|
.class("listing__series"),
|
||||||
|
.href(.Route.events)
|
||||||
|
) {
|
||||||
|
"or pick up at \(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a(
|
a(
|
||||||
@@ -239,8 +260,28 @@ private extension ListingPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !listing.isAvailable {
|
if !listing.isAvailable {
|
||||||
p(.class("notice")) {
|
div(.class("notice listing__sold")) {
|
||||||
"This copy is sold. A sale was agreed on CCN."
|
p {
|
||||||
|
strong {
|
||||||
|
"This copy sold\(listing.soldAt.map { " on \($0.dayStamp)" } ?? "")."
|
||||||
|
}
|
||||||
|
" \(listing.shop.name) has \(otherCopies.inShop) more \(otherCopies.inShop == 1 ? "copy" : "copies") of \(listing.title), and \(otherCopies.everywhere) \(otherCopies.everywhere == 1 ? "is" : "are") listed across CCN."
|
||||||
|
}
|
||||||
|
div(.class("empty__actions")) {
|
||||||
|
a(
|
||||||
|
.class("button button--primary button--sm"),
|
||||||
|
.href(SearchParameters().setting(.query, to: listing.title).setting(.shipsFrom, to: "anywhere").path(.Route.search))
|
||||||
|
) {
|
||||||
|
"Other copies"
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--outline button--sm"),
|
||||||
|
.href(.Route.newWant(title: listing.title))
|
||||||
|
) {
|
||||||
|
Icon(.bell, size: 15)
|
||||||
|
"Alert me"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if viewer?.shopSlug == listing.shop.slug {
|
} else if viewer?.shopSlug == listing.shop.slug {
|
||||||
p(.class("notice")) {
|
p(.class("notice")) {
|
||||||
|
|||||||
@@ -332,6 +332,25 @@ private extension MessagesPage {
|
|||||||
"“\(note)”"
|
"“\(note)”"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if offer.status == .expired, conversation.side == .buying, offer.listing.isAvailable {
|
||||||
|
p(.class("type-small")) {
|
||||||
|
"\(conversation.shop.name) didn't answer within 48 hours. The listing is still available at \(offer.listing.priceCents.euros)."
|
||||||
|
}
|
||||||
|
div(.class("answer")) {
|
||||||
|
a(
|
||||||
|
.class("button button--primary button--sm"),
|
||||||
|
.href(.Route.offer(offer.listing.slug))
|
||||||
|
) {
|
||||||
|
"Offer again"
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--sm"),
|
||||||
|
.href(.Route.buy(offer.listing.slug))
|
||||||
|
) {
|
||||||
|
"Buy at \(offer.listing.priceCents.euros)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if offer.status == .open, conversation.side == .selling {
|
if offer.status == .open, conversation.side == .selling {
|
||||||
AnswerOffer(
|
AnswerOffer(
|
||||||
offer: offer,
|
offer: offer,
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Infrastructure
|
||||||
|
|
||||||
|
/// The recovery of an account whose every passkey is lost: ask for a one-time link, then — from the link — set up a new passkey.
|
||||||
|
///
|
||||||
|
/// Drawn bare, like the sign-in. The prototype sends no mail, so it shows the link it would have sent.
|
||||||
|
struct RecoverPage {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
let analytics: Analytics?
|
||||||
|
let assetVersion: String?
|
||||||
|
let locale: Locale
|
||||||
|
|
||||||
|
/// What the screen shows.
|
||||||
|
let step: Step
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates the recovery screen.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - step: what the screen shows.
|
||||||
|
/// - 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(
|
||||||
|
step: Step,
|
||||||
|
assetVersion: String? = nil,
|
||||||
|
analytics: Analytics? = nil
|
||||||
|
) {
|
||||||
|
self.analytics = analytics
|
||||||
|
self.assetVersion = assetVersion
|
||||||
|
self.locale = .init(identifier: "en")
|
||||||
|
self.step = step
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
extension RecoverPage {
|
||||||
|
/// A step of the recovery.
|
||||||
|
enum Step: Sendable, Equatable {
|
||||||
|
/// Asking for the account's email, with what was wrong with the last one given.
|
||||||
|
case ask(problem: String?)
|
||||||
|
/// The link is on its way — here, on the screen, when the email has an account.
|
||||||
|
case sent(link: String?)
|
||||||
|
/// The link was followed: a new passkey can be set up. Carries the link's token.
|
||||||
|
case renew(token: String)
|
||||||
|
/// The link was used, ran out, or never existed.
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page
|
||||||
|
|
||||||
|
extension RecoverPage: Page {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
div(.class("gate")) {
|
||||||
|
header(.class("gate__bar")) {
|
||||||
|
a(
|
||||||
|
.class("gate__close"),
|
||||||
|
.href(.Route.signIn),
|
||||||
|
.custom(name: "aria-label", value: "Back to sign in")
|
||||||
|
) {
|
||||||
|
Icon(.close)
|
||||||
|
}
|
||||||
|
span(.class("gate__wordmark")) {
|
||||||
|
"CCN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main(.class("gate__main")) {
|
||||||
|
switch step {
|
||||||
|
case .ask(let problem):
|
||||||
|
ask(problem)
|
||||||
|
case .sent(let link):
|
||||||
|
sent(link)
|
||||||
|
case .renew(let token):
|
||||||
|
renew(token)
|
||||||
|
case .expired:
|
||||||
|
div(.class("gate__intro")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"That link has run out"
|
||||||
|
}
|
||||||
|
p(.class("gate__lead")) {
|
||||||
|
"A recovery link works once, for an hour. Ask for a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--primary button--xl button--block"),
|
||||||
|
.href(.Route.recover)
|
||||||
|
) {
|
||||||
|
"Send a new link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
var metadata: some HTML {
|
||||||
|
meta(
|
||||||
|
.name("robots"),
|
||||||
|
.content("noindex")
|
||||||
|
)
|
||||||
|
siteMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.passkey]
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets: [any Asset] {
|
||||||
|
[StaticFile.shared, StaticFile.signIn]
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
"Recover your account — CCN"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Steps
|
||||||
|
|
||||||
|
private extension RecoverPage {
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
func ask(
|
||||||
|
_ problem: String?
|
||||||
|
) -> some HTML {
|
||||||
|
div(.class("gate__intro")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Recover your account"
|
||||||
|
}
|
||||||
|
p(.class("gate__lead")) {
|
||||||
|
"We'll email you a one-time link to set up a new passkey. Your old passkeys are removed when you do."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form(
|
||||||
|
.class("gate__form"),
|
||||||
|
.action(.Route.recover),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
div(.class("field")) {
|
||||||
|
label(
|
||||||
|
.class("field__label"),
|
||||||
|
.for("email")
|
||||||
|
) {
|
||||||
|
"Account email"
|
||||||
|
}
|
||||||
|
input(
|
||||||
|
.class("input input--lg"),
|
||||||
|
.id("email"),
|
||||||
|
.type(.email),
|
||||||
|
.name("email"),
|
||||||
|
.required,
|
||||||
|
.placeholder("you@example.com"),
|
||||||
|
.custom(name: "autocomplete", value: "email")
|
||||||
|
)
|
||||||
|
FieldNote(name: "email", hint: nil, problem: problem)
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
.class("button button--primary button--xl button--block"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Send recovery link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p(.class("notice")) {
|
||||||
|
"Still have a passkey on another device? "
|
||||||
|
a(.href(.Route.signIn)) {
|
||||||
|
"Go back"
|
||||||
|
}
|
||||||
|
" and press Continue — your device will offer to use it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
func renew(
|
||||||
|
_ token: String
|
||||||
|
) -> some HTML {
|
||||||
|
div(.class("gate__intro")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Set up a new passkey"
|
||||||
|
}
|
||||||
|
p(.class("gate__lead")) {
|
||||||
|
"This replaces every passkey on the account, and signs you in."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(
|
||||||
|
.class("gate__form"),
|
||||||
|
.custom(name: "data-passkey-form", value: "")
|
||||||
|
) {
|
||||||
|
input(
|
||||||
|
.type(.hidden),
|
||||||
|
.name("recover"),
|
||||||
|
.value(token)
|
||||||
|
)
|
||||||
|
button(
|
||||||
|
.class("button button--primary button--xl button--block"),
|
||||||
|
.type(.button),
|
||||||
|
.hidden,
|
||||||
|
.custom(name: "data-passkey", value: "recover")
|
||||||
|
) {
|
||||||
|
Icon(.key, size: 18)
|
||||||
|
"Create a new passkey"
|
||||||
|
}
|
||||||
|
p(
|
||||||
|
.class("field__error"),
|
||||||
|
.custom(name: "role", value: "alert"),
|
||||||
|
.custom(name: "data-passkey-status", value: "")
|
||||||
|
) {}
|
||||||
|
PasskeyUnsupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HTMLBuilder
|
||||||
|
func sent(
|
||||||
|
_ link: String?
|
||||||
|
) -> some HTML {
|
||||||
|
div(.class("gate__intro")) {
|
||||||
|
h1(.class("type-h1")) {
|
||||||
|
"Check your email"
|
||||||
|
}
|
||||||
|
p(.class("gate__lead")) {
|
||||||
|
"If that email has an account, a one-time link is on its way. It works once, for an hour."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("notice notice--warn gate__demo")) {
|
||||||
|
p {
|
||||||
|
"Prototype: CCN sends no mail yet, so the link appears here instead — which also means anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built."
|
||||||
|
}
|
||||||
|
if let link {
|
||||||
|
a(
|
||||||
|
.class("button button--outline button--block"),
|
||||||
|
.href(link)
|
||||||
|
) {
|
||||||
|
"Open the recovery link"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p {
|
||||||
|
strong {
|
||||||
|
"No account has that email, so no link was made."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -137,16 +137,27 @@ extension SearchPage: Page {
|
|||||||
if listings.isEmpty {
|
if listings.isEmpty {
|
||||||
div(.class("empty")) {
|
div(.class("empty")) {
|
||||||
h2(.class("type-panel")) {
|
h2(.class("type-panel")) {
|
||||||
"No listings match"
|
parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match"
|
||||||
}
|
}
|
||||||
p(.class("type-body-sm")) {
|
p(.class("type-body-sm")) {
|
||||||
"Try fewer filters, or let listings ship from anywhere."
|
"Nothing listed meets that right now. Clear the filters — listings outside the EU are left out by default — or ask to be told when a copy appears."
|
||||||
}
|
}
|
||||||
a(
|
div(.class("empty__actions")) {
|
||||||
.class("button button--outline"),
|
if let query = parameters.value(.query) {
|
||||||
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
|
a(
|
||||||
) {
|
.class("button button--primary"),
|
||||||
"Clear all filters"
|
.href(.Route.newWant(title: query))
|
||||||
|
) {
|
||||||
|
Icon(.bell, size: 16)
|
||||||
|
"Alert me"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
.class("button button--outline"),
|
||||||
|
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
|
||||||
|
) {
|
||||||
|
"Clear all filters"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -268,6 +279,7 @@ private extension SearchPage {
|
|||||||
span(.class("type-small")) {
|
span(.class("type-small")) {
|
||||||
"Outside the EU, import VAT may apply."
|
"Outside the EU, import VAT may apply."
|
||||||
}
|
}
|
||||||
|
checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event")
|
||||||
}
|
}
|
||||||
group("Key issues") {
|
group("Key issues") {
|
||||||
checkbox(.keys, value: "1", label: "Key issues only")
|
checkbox(.keys, value: "1", label: "Key issues only")
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ struct SellerDashboardPage {
|
|||||||
let assetVersion: String?
|
let assetVersion: String?
|
||||||
let locale: Locale
|
let locale: Locale
|
||||||
|
|
||||||
|
/// The upcoming events, each with whether the shop trades there.
|
||||||
|
let events: [(event: Event, isTrading: Bool)]
|
||||||
|
|
||||||
/// The shop's listings still for sale, newest first.
|
/// The shop's listings still for sale, newest first.
|
||||||
let listings: [Listing]
|
let listings: [Listing]
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ struct SellerDashboardPage {
|
|||||||
/// - offers: the offers waiting for the seller's answer, the soonest to expire first.
|
/// - offers: the offers waiting for the seller's answer, the soonest to expire first.
|
||||||
/// - sales: the shop's sales, the latest agreed first.
|
/// - sales: the shop's sales, the latest agreed first.
|
||||||
/// - listings: the shop's listings still for sale, newest first.
|
/// - listings: the shop's listings still for sale, newest first.
|
||||||
|
/// - events: the upcoming events; none by default.
|
||||||
/// - viewer: the signed-in viewer.
|
/// - viewer: the signed-in viewer.
|
||||||
/// - now: the moment stamps are read relative to; the present by default.
|
/// - 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.
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
@@ -47,6 +51,7 @@ struct SellerDashboardPage {
|
|||||||
offers: [Offer],
|
offers: [Offer],
|
||||||
sales: [Sale],
|
sales: [Sale],
|
||||||
listings: [Listing],
|
listings: [Listing],
|
||||||
|
events: [Event] = [],
|
||||||
viewer: Viewer? = nil,
|
viewer: Viewer? = nil,
|
||||||
now: Date = .init(),
|
now: Date = .init(),
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
@@ -54,6 +59,7 @@ struct SellerDashboardPage {
|
|||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
|
self.events = events.map { event in (event, event.shops.contains { $0.slug == shop.slug }) }
|
||||||
self.listings = listings
|
self.listings = listings
|
||||||
self.locale = .init(identifier: "en")
|
self.locale = .init(identifier: "en")
|
||||||
self.now = now
|
self.now = now
|
||||||
@@ -231,6 +237,43 @@ extension SellerDashboardPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !events.isEmpty {
|
||||||
|
form(
|
||||||
|
.class("card sell__panel"),
|
||||||
|
.action("\(String.Route.sellerDashboard)/events"),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
div(.class("section-head")) {
|
||||||
|
h2(.class("type-panel")) {
|
||||||
|
"Events you'll trade at"
|
||||||
|
}
|
||||||
|
span(.class("type-small")) {
|
||||||
|
"Buyers can agree online and collect at the con"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("listing-form__checks")) {
|
||||||
|
ForEach(events) { entry in
|
||||||
|
label(.class("check")) {
|
||||||
|
input(
|
||||||
|
.type(.checkbox),
|
||||||
|
.name("events"),
|
||||||
|
.value(entry.event.slug)
|
||||||
|
)
|
||||||
|
.attributes(.checked, when: entry.isTrading)
|
||||||
|
"\(entry.event.name) · \(Date.span(from: entry.event.startsOn, to: entry.event.endsOn))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("listing-form__submit")) {
|
||||||
|
button(
|
||||||
|
.class("button button--dark button--sm"),
|
||||||
|
.type(.submit)
|
||||||
|
) {
|
||||||
|
"Save events"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ struct SeriesPage {
|
|||||||
/// Every listing of the series still for sale.
|
/// Every listing of the series still for sale.
|
||||||
let listings: [Listing]
|
let listings: [Listing]
|
||||||
|
|
||||||
|
/// The issues the viewer owns of the series, or `nil` signed out.
|
||||||
|
let owned: Set<Int>?
|
||||||
|
|
||||||
/// The series shown.
|
/// The series shown.
|
||||||
let series: Series
|
let series: Series
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ struct SeriesPage {
|
|||||||
/// - series: the series shown.
|
/// - series: the series shown.
|
||||||
/// - listings: every listing of the series still for sale.
|
/// - listings: every listing of the series still for sale.
|
||||||
/// - issue: the issue whose copies are listed, or `nil` (the default) for none.
|
/// - issue: the issue whose copies are listed, or `nil` (the default) for none.
|
||||||
|
/// - owned: the issues the viewer owns of the series, or `nil` (the default) signed out.
|
||||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
@@ -38,6 +42,7 @@ struct SeriesPage {
|
|||||||
series: Series,
|
series: Series,
|
||||||
listings: [Listing],
|
listings: [Listing],
|
||||||
issue: Int? = nil,
|
issue: Int? = nil,
|
||||||
|
owned: Set<Int>? = nil,
|
||||||
viewer: Viewer? = nil,
|
viewer: Viewer? = nil,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
@@ -47,6 +52,7 @@ struct SeriesPage {
|
|||||||
self.issue = issue
|
self.issue = issue
|
||||||
self.listings = listings
|
self.listings = listings
|
||||||
self.locale = .init(identifier: "en")
|
self.locale = .init(identifier: "en")
|
||||||
|
self.owned = owned
|
||||||
self.series = series
|
self.series = series
|
||||||
self.viewer = viewer
|
self.viewer = viewer
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,19 @@ extension SeriesPage: Page {
|
|||||||
p(.class("type-lead")) {
|
p(.class("type-lead")) {
|
||||||
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now."
|
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now."
|
||||||
}
|
}
|
||||||
|
if let owned, series.issueCount > 0 {
|
||||||
|
div(.class("card series__collection")) {
|
||||||
|
p(.class("type-eyebrow")) {
|
||||||
|
"Your collection"
|
||||||
|
}
|
||||||
|
p(.class("type-panel")) {
|
||||||
|
"\(owned.count) of \(series.issueCount) owned"
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
"\(gaps.count) missing · \(gaps.filter { !$0.copies.isEmpty }.count) of them listed now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ul(.class("series__keys")) {
|
ul(.class("series__keys")) {
|
||||||
ForEach(keys) { key in
|
ForEach(keys) { key in
|
||||||
li(.class("chip chip--warn")) {
|
li(.class("chip chip--warn")) {
|
||||||
@@ -125,7 +144,7 @@ extension SeriesPage: Page {
|
|||||||
}
|
}
|
||||||
ol(.class("series__issues")) {
|
ol(.class("series__issues")) {
|
||||||
ForEach(issues) { entry in
|
ForEach(issues) { entry in
|
||||||
li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) {
|
li(.class(issueClass(entry))) {
|
||||||
span(.class("series__number")) {
|
span(.class("series__number")) {
|
||||||
"#\(entry.number)"
|
"#\(entry.number)"
|
||||||
}
|
}
|
||||||
@@ -143,6 +162,25 @@ extension SeriesPage: Page {
|
|||||||
"None listed · alert me"
|
"None listed · alert me"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let owned {
|
||||||
|
form(
|
||||||
|
.action("\(String.Route.series(series.slug))/own"),
|
||||||
|
.method(.post)
|
||||||
|
) {
|
||||||
|
input(
|
||||||
|
.type(.hidden),
|
||||||
|
.name("issue"),
|
||||||
|
.value("\(entry.number)")
|
||||||
|
)
|
||||||
|
button(
|
||||||
|
.class("series__own"),
|
||||||
|
.type(.submit),
|
||||||
|
.custom(name: "aria-pressed", value: owned.contains(entry.number) ? "true" : "false")
|
||||||
|
) {
|
||||||
|
owned.contains(entry.number) ? "Owned ✓" : "I own this"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +312,24 @@ extension SeriesPage: Page {
|
|||||||
|
|
||||||
private extension SeriesPage {
|
private extension SeriesPage {
|
||||||
|
|
||||||
|
/// The issues of the run the viewer does not own.
|
||||||
|
var gaps: [(number: Int, copies: [Listing])] {
|
||||||
|
issues.filter { !(owned ?? []).contains($0.number) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An issue's tile: a gap reads quieter, and an owned issue is dimmed.
|
||||||
|
func issueClass(
|
||||||
|
_ entry: (number: Int, copies: [Listing])
|
||||||
|
) -> String {
|
||||||
|
[
|
||||||
|
"series__issue",
|
||||||
|
entry.copies.isEmpty ? "series__issue--gap" : nil,
|
||||||
|
owned?.contains(entry.number) == true ? "series__issue--owned" : nil,
|
||||||
|
]
|
||||||
|
.compactMap(\.self)
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
/// The series' manga, trade paperbacks, and hardcovers.
|
/// The series' manga, trade paperbacks, and hardcovers.
|
||||||
var collected: [Listing] {
|
var collected: [Listing] {
|
||||||
listings.filter { $0.issueNumber == nil }
|
listings.filter { $0.issueNumber == nil }
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ struct ShopPage {
|
|||||||
/// The tab, quick filters, and sort order applied.
|
/// The tab, quick filters, and sort order applied.
|
||||||
let parameters: SearchParameters
|
let parameters: SearchParameters
|
||||||
|
|
||||||
|
/// The upcoming events the shop trades at.
|
||||||
|
let pickups: [Event]
|
||||||
|
|
||||||
/// The shop shown.
|
/// The shop shown.
|
||||||
let shop: Shop
|
let shop: Shop
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ struct ShopPage {
|
|||||||
/// - inventory: every listing of the shop, counted per format on the tabs.
|
/// - inventory: every listing of the shop, counted per format on the tabs.
|
||||||
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
|
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
|
||||||
/// - parameters: the tab, quick filters, and sort order applied.
|
/// - parameters: the tab, quick filters, and sort order applied.
|
||||||
|
/// - pickups: the upcoming events the shop trades at; none by default.
|
||||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
@@ -46,6 +50,7 @@ struct ShopPage {
|
|||||||
inventory: [Listing],
|
inventory: [Listing],
|
||||||
matches: [Listing],
|
matches: [Listing],
|
||||||
parameters: SearchParameters,
|
parameters: SearchParameters,
|
||||||
|
pickups: [Event] = [],
|
||||||
viewer: Viewer? = nil,
|
viewer: Viewer? = nil,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
@@ -64,6 +69,7 @@ struct ShopPage {
|
|||||||
self.locale = .init(identifier: "en")
|
self.locale = .init(identifier: "en")
|
||||||
self.pages = (slice.current, slice.total)
|
self.pages = (slice.current, slice.total)
|
||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
|
self.pickups = pickups
|
||||||
self.shop = shop
|
self.shop = shop
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +110,12 @@ extension ShopPage: Page {
|
|||||||
}
|
}
|
||||||
p(.class("type-body-sm shop__standing")) {
|
p(.class("type-body-sm shop__standing")) {
|
||||||
"\(shop.standing) · \(shop.shipsNote)"
|
"\(shop.standing) · \(shop.shipsNote)"
|
||||||
|
if let event = pickups.first {
|
||||||
|
" · trading at "
|
||||||
|
a(.href(.Route.events)) {
|
||||||
|
"\(event.name), \(Date.span(from: event.startsOn, to: event.endsOn))"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p(.class("shop__about")) {
|
p(.class("shop__about")) {
|
||||||
shop.about
|
shop.about
|
||||||
@@ -197,10 +209,12 @@ extension ShopPage: Page {
|
|||||||
if listings.isEmpty {
|
if listings.isEmpty {
|
||||||
div(.class("empty")) {
|
div(.class("empty")) {
|
||||||
h2(.class("type-panel")) {
|
h2(.class("type-panel")) {
|
||||||
"Nothing here right now"
|
counts.isEmpty ? "\(shop.name) is restocking" : "Nothing here right now"
|
||||||
}
|
}
|
||||||
p(.class("type-body-sm")) {
|
p(.class("type-body-sm")) {
|
||||||
"No listing in this shop matches those filters."
|
counts.isEmpty
|
||||||
|
? "Nothing listed right now\(pickups.first.map { " — the shop is trading at \($0.name), \(Date.span(from: $0.startsOn, to: $0.endsOn))" } ?? "")."
|
||||||
|
: "No listing in this shop matches those filters."
|
||||||
}
|
}
|
||||||
a(
|
a(
|
||||||
.class("button button--outline"),
|
.class("button button--outline"),
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ extension SignInPage: Page {
|
|||||||
.custom(name: "role", value: "alert"),
|
.custom(name: "role", value: "alert"),
|
||||||
.custom(name: "data-passkey-status", value: "")
|
.custom(name: "data-passkey-status", value: "")
|
||||||
) {}
|
) {}
|
||||||
|
PasskeyUnsupported()
|
||||||
div(.class("field")) {
|
div(.class("field")) {
|
||||||
label(
|
label(
|
||||||
.class("field__label"),
|
.class("field__label"),
|
||||||
@@ -154,6 +155,11 @@ extension SignInPage: Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
p(.class("gate__recover")) {
|
||||||
|
a(.href(.Route.recover)) {
|
||||||
|
"Lost access to your passkeys?"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,13 @@ extension WantsPage: Page {
|
|||||||
if wants.isEmpty, draft == nil {
|
if wants.isEmpty, draft == nil {
|
||||||
div(.class("empty")) {
|
div(.class("empty")) {
|
||||||
h2(.class("type-panel")) {
|
h2(.class("type-panel")) {
|
||||||
"Nothing on your list yet"
|
"No wants yet"
|
||||||
}
|
}
|
||||||
p(.class("type-body-sm")) {
|
p(.class("type-body-sm")) {
|
||||||
"Add the issue you're missing, and CCN checks it against every listing published."
|
"Add the issues you're hunting. We check every new listing against them and tell you the moment one appears."
|
||||||
|
}
|
||||||
|
p(.class("type-small")) {
|
||||||
|
"Tip: on any series page, a missing issue is one tap from an alert."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ extension SearchParameters {
|
|||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The `pickup` value standing for any upcoming event, rather than one event's slug.
|
||||||
|
static let anyEvent = "any"
|
||||||
|
|
||||||
/// The number of listings on a page.
|
/// The number of listings on a page.
|
||||||
static let pageSize = 12
|
static let pageSize = 12
|
||||||
|
|
||||||
@@ -152,6 +155,10 @@ extension SearchParameters {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has(.pickup) {
|
||||||
|
pills.append(.init(label: "Event pickup", removed: removing(.pickup)))
|
||||||
|
}
|
||||||
|
|
||||||
if filter.keysOnly {
|
if filter.keysOnly {
|
||||||
pills.append(.init(label: "Key issues", removed: removing(.keys)))
|
pills.append(.init(label: "Key issues", removed: removing(.keys)))
|
||||||
}
|
}
|
||||||
@@ -278,6 +285,7 @@ extension SearchParameters {
|
|||||||
case keys
|
case keys
|
||||||
case offers
|
case offers
|
||||||
case page
|
case page
|
||||||
|
case pickup
|
||||||
case priceMax = "price-max"
|
case priceMax = "price-max"
|
||||||
case priceMin = "price-min"
|
case priceMin = "price-min"
|
||||||
case publisher
|
case publisher
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public struct CatalogueController<Context: RequestContext> {
|
|||||||
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
|
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
|
||||||
private let assetVersion: String?
|
private let assetVersion: String?
|
||||||
|
|
||||||
|
/// The repository the events are read through, or `nil` to leave pickup out.
|
||||||
|
private let collecting: CollectingRepository?
|
||||||
|
|
||||||
/// The repository the shops and listings are read through.
|
/// The repository the shops and listings are read through.
|
||||||
private let repository: CatalogueRepository
|
private let repository: CatalogueRepository
|
||||||
|
|
||||||
@@ -30,16 +33,19 @@ public struct CatalogueController<Context: RequestContext> {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - repository: the repository the shops and listings are read through.
|
/// - repository: the repository the shops and listings are read through.
|
||||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
|
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
|
||||||
|
/// - collecting: the repository the events are read through, or `nil` (the default) to leave pickup out.
|
||||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||||
public init(
|
public init(
|
||||||
repository: CatalogueRepository,
|
repository: CatalogueRepository,
|
||||||
sessions: SessionStore? = nil,
|
sessions: SessionStore? = nil,
|
||||||
|
collecting: CollectingRepository? = nil,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
|
self.collecting = collecting
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,13 @@ private extension CatalogueController {
|
|||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The upcoming events a shop trades at, soonest first.
|
||||||
|
func events(
|
||||||
|
tradedAtBy slug: String
|
||||||
|
) async throws -> [Event] {
|
||||||
|
try await collecting?.upcomingEvents().filter { $0.shops.contains { $0.slug == slug } } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles a request for the grading guide.
|
/// Handles a request for the grading guide.
|
||||||
@Sendable
|
@Sendable
|
||||||
func gradingGuide(
|
func gradingGuide(
|
||||||
@@ -115,10 +128,13 @@ private extension CatalogueController {
|
|||||||
throw HTTPError(.notFound)
|
throw HTTPError(.notFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
let related = try await repository
|
let inShop = try await repository
|
||||||
.listings(matching: .init(shop: listing.shop.slug))
|
.listings(matching: .init(shop: listing.shop.slug))
|
||||||
.filter { $0.slug != listing.slug }
|
.filter { $0.slug != listing.slug }
|
||||||
.prefix(Self.relatedCount)
|
let related = inShop.prefix(Self.relatedCount)
|
||||||
|
let pickups = try await events(tradedAtBy: listing.shop.slug)
|
||||||
|
// Only a sold copy says how many others there are.
|
||||||
|
let sameTitle = listing.isAvailable ? [] : try await repository.listings().filter { $0.title == listing.title }
|
||||||
|
|
||||||
let viewer = await sessions?.viewer(for: request)
|
let viewer = await sessions?.viewer(for: request)
|
||||||
|
|
||||||
@@ -126,6 +142,8 @@ private extension CatalogueController {
|
|||||||
ListingPage(
|
ListingPage(
|
||||||
listing: listing,
|
listing: listing,
|
||||||
related: Array(related),
|
related: Array(related),
|
||||||
|
pickups: pickups,
|
||||||
|
otherCopies: (inShop.count { $0.title == listing.title }, sameTitle.count),
|
||||||
viewer: viewer,
|
viewer: viewer,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
@@ -140,7 +158,16 @@ private extension CatalogueController {
|
|||||||
context: Context
|
context: Context
|
||||||
) async throws -> HTMLResponse {
|
) async throws -> HTMLResponse {
|
||||||
let parameters = SearchParameters(query: request.uri.query)
|
let parameters = SearchParameters(query: request.uri.query)
|
||||||
let matches = try await repository.listings(matching: parameters.filter)
|
var filter = parameters.filter
|
||||||
|
|
||||||
|
// Pickup narrows the search to the shops trading at one event — or at any, for the rail's checkbox.
|
||||||
|
if let pickup = parameters.value(.pickup) {
|
||||||
|
filter.shops = try await collecting?.upcomingEvents()
|
||||||
|
.filter { pickup == SearchParameters.anyEvent || $0.slug == pickup }
|
||||||
|
.flatMap { $0.shops.map(\.slug) } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = try await repository.listings(matching: filter)
|
||||||
let publishers = try await repository.publishers()
|
let publishers = try await repository.publishers()
|
||||||
|
|
||||||
let viewer = await sessions?.viewer(for: request)
|
let viewer = await sessions?.viewer(for: request)
|
||||||
@@ -170,6 +197,7 @@ private extension CatalogueController {
|
|||||||
let parameters = SearchParameters(query: request.uri.query)
|
let parameters = SearchParameters(query: request.uri.query)
|
||||||
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
|
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
|
||||||
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
|
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
|
||||||
|
let pickups = try await events(tradedAtBy: shop.slug)
|
||||||
|
|
||||||
let viewer = await sessions?.viewer(for: request)
|
let viewer = await sessions?.viewer(for: request)
|
||||||
|
|
||||||
@@ -179,6 +207,7 @@ private extension CatalogueController {
|
|||||||
inventory: inventory,
|
inventory: inventory,
|
||||||
matches: matches,
|
matches: matches,
|
||||||
parameters: parameters,
|
parameters: parameters,
|
||||||
|
pickups: pickups,
|
||||||
viewer: viewer,
|
viewer: viewer,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ extension CollectingController: RouterController {
|
|||||||
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
|
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
|
||||||
routes.get(.init(.Route.series), use: seriesIndex)
|
routes.get(.init(.Route.series), use: seriesIndex)
|
||||||
routes.get(.init("\(String.Route.series)/:slug"), use: series)
|
routes.get(.init("\(String.Route.series)/:slug"), use: series)
|
||||||
|
routes.post(.init("\(String.Route.series)/:slug/own"), use: toggleOwned)
|
||||||
routes.get(.init(.Route.compare), use: compare)
|
routes.get(.init(.Route.compare), use: compare)
|
||||||
|
routes.get(.init(.Route.collection), use: collection)
|
||||||
|
routes.post(.init(.Route.collection), use: addBooks)
|
||||||
|
routes.get(.init(.Route.events), use: events)
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
@@ -240,12 +244,18 @@ private extension CollectingController {
|
|||||||
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
|
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
|
||||||
let issue = request.uri.queryParameters.get("issue", as: Int.self)
|
let issue = request.uri.queryParameters.get("issue", as: Int.self)
|
||||||
let viewer = await sessions.viewer(for: request)
|
let viewer = await sessions.viewer(for: request)
|
||||||
|
var owned: Set<Int>?
|
||||||
|
|
||||||
|
if let user = await sessions.user(for: request) {
|
||||||
|
owned = try await collecting.ownedIssues(of: user)[slug] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
SeriesPage(
|
SeriesPage(
|
||||||
series: series,
|
series: series,
|
||||||
listings: listings,
|
listings: listings,
|
||||||
issue: issue,
|
issue: issue,
|
||||||
|
owned: owned,
|
||||||
viewer: viewer,
|
viewer: viewer,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
@@ -277,8 +287,114 @@ private extension CollectingController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection and events
|
||||||
|
|
||||||
|
private extension CollectingController {
|
||||||
|
|
||||||
|
/// Handles books added to the collection from its form.
|
||||||
|
@Sendable
|
||||||
|
func addBooks(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let user = await sessions.user(for: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(BooksForm.self, from: request, context: context)
|
||||||
|
|
||||||
|
try await collecting.own(issues: CollectionPage.issues(in: form.issues), ofSeries: form.series, by: user)
|
||||||
|
|
||||||
|
return .redirect(to: .Route.collection, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for the collection tracker.
|
||||||
|
@Sendable
|
||||||
|
func collection(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let user = await sessions.user(for: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .found)
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalogue = try await collecting.allSeries()
|
||||||
|
let owned = try await collecting.ownedIssues(of: user)
|
||||||
|
let listings = try await self.catalogue.listings()
|
||||||
|
let viewer = await sessions.viewer(for: request)
|
||||||
|
|
||||||
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
|
CollectionPage(
|
||||||
|
catalogue: catalogue,
|
||||||
|
owned: owned,
|
||||||
|
listings: listings,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for the events.
|
||||||
|
@Sendable
|
||||||
|
func events(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> HTMLResponse {
|
||||||
|
let events = try await collecting.upcomingEvents()
|
||||||
|
let listings = try await catalogue.listings()
|
||||||
|
let viewer = await sessions.viewer(for: request)
|
||||||
|
|
||||||
|
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
|
EventsPage(
|
||||||
|
events: events,
|
||||||
|
listings: listings,
|
||||||
|
viewer: viewer,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the switch on an issue of a series: into the collection, or out of it.
|
||||||
|
@Sendable
|
||||||
|
func toggleOwned(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let slug = try context.parameters.require("slug")
|
||||||
|
|
||||||
|
guard let user = await sessions.user(for: request) else {
|
||||||
|
return .redirect(to: .Route.signIn(returningTo: .Route.series(slug)), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(IssueForm.self, from: request, context: context)
|
||||||
|
|
||||||
|
if try await collecting.ownedIssues(of: user)[slug]?.contains(form.issue) == true {
|
||||||
|
try await collecting.disown(issue: form.issue, ofSeries: slug, by: user)
|
||||||
|
} else {
|
||||||
|
try await collecting.own(issues: [form.issue], ofSeries: slug, by: user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .redirect(to: .Route.series(slug), type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Structs
|
// MARK: - Structs
|
||||||
|
|
||||||
|
/// The fields the add-books form posts.
|
||||||
|
private struct BooksForm: Decodable {
|
||||||
|
let series: String
|
||||||
|
let issues: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field an issue's switch posts.
|
||||||
|
private struct IssueForm: Decodable {
|
||||||
|
let issue: Int
|
||||||
|
}
|
||||||
|
|
||||||
/// The fields the add-a-want form posts.
|
/// The fields the add-a-want form posts.
|
||||||
private struct WantForm: Decodable {
|
private struct WantForm: Decodable {
|
||||||
let title: String
|
let title: String
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public struct PasskeyController<Context: RequestContext> {
|
|||||||
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
||||||
private let origin: String
|
private let origin: String
|
||||||
|
|
||||||
|
/// The one-time recovery links a recovery ceremony answers to.
|
||||||
|
private let recoveryLinks: RecoveryLinks
|
||||||
|
|
||||||
/// The store sessions are started in.
|
/// The store sessions are started in.
|
||||||
private let sessions: SessionStore
|
private let sessions: SessionStore
|
||||||
|
|
||||||
@@ -40,12 +43,15 @@ public struct PasskeyController<Context: RequestContext> {
|
|||||||
/// - accounts: the repository accounts and their passkeys are read and created through.
|
/// - accounts: the repository accounts and their passkeys are read and created through.
|
||||||
/// - sessions: the store sessions are started in.
|
/// - sessions: the store sessions are started in.
|
||||||
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
||||||
|
/// - recoveryLinks: the one-time recovery links a recovery ceremony answers to; a set of its own by default.
|
||||||
public init(
|
public init(
|
||||||
origin: String,
|
origin: String,
|
||||||
accounts: AccountRepository,
|
accounts: AccountRepository,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
ceremonies: PasskeyCeremonies = .init()
|
ceremonies: PasskeyCeremonies = .init(),
|
||||||
|
recoveryLinks: RecoveryLinks = .init()
|
||||||
) {
|
) {
|
||||||
|
self.recoveryLinks = recoveryLinks
|
||||||
self.accounts = accounts
|
self.accounts = accounts
|
||||||
self.ceremonies = ceremonies
|
self.ceremonies = ceremonies
|
||||||
self.isSecure = origin.hasPrefix("https://")
|
self.isSecure = origin.hasPrefix("https://")
|
||||||
@@ -136,6 +142,29 @@ private extension PasskeyController {
|
|||||||
|
|
||||||
// A new account picks its display name before going on.
|
// A new account picks its display name before going on.
|
||||||
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
|
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
|
||||||
|
case .recovery(let token):
|
||||||
|
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
|
||||||
|
let credential = try await manager.finishRegistration(
|
||||||
|
challenge: ceremony.challenge,
|
||||||
|
credentialCreationData: answer.credential,
|
||||||
|
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||||
|
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Used up only now: a prompt cancelled leaves the link good for another try.
|
||||||
|
guard let owner = await recoveryLinks.redeem(token) else {
|
||||||
|
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await accounts.replacePasskeys(
|
||||||
|
of: owner,
|
||||||
|
credentialID: Self.base64URL(credential.id),
|
||||||
|
publicKey: credential.publicKey,
|
||||||
|
signCount: credential.signCount
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await signedIn(owner, returningTo: .Route.account)
|
||||||
case .addition(let userID):
|
case .addition(let userID):
|
||||||
guard let owner = await sessions.user(for: request), owner.id == userID else {
|
guard let owner = await sessions.user(for: request), owner.id == userID else {
|
||||||
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
|
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
|
||||||
@@ -198,6 +227,21 @@ private extension PasskeyController {
|
|||||||
let start = try? await request.decode(as: Start.self, context: context)
|
let start = try? await request.decode(as: Start.self, context: context)
|
||||||
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
|
||||||
|
// From a recovery link: a new passkey, replacing the account's others.
|
||||||
|
if let token = start?.recover {
|
||||||
|
guard let owner = await recoveryLinks.user(for: token) else {
|
||||||
|
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = manager.beginRegistration(user: .init(
|
||||||
|
id: Array(UUID().uuidString.utf8),
|
||||||
|
name: owner.email,
|
||||||
|
displayName: owner.displayName
|
||||||
|
))
|
||||||
|
|
||||||
|
return try await begin(.recovery(token: token), options: options, challenge: options.challenge, mode: "create")
|
||||||
|
}
|
||||||
|
|
||||||
// From the account screen: another passkey for the account signed in.
|
// From the account screen: another passkey for the account signed in.
|
||||||
if start?.add == true {
|
if start?.add == true {
|
||||||
guard let owner = await sessions.user(for: request) else {
|
guard let owner = await sessions.user(for: request) else {
|
||||||
@@ -343,6 +387,7 @@ private extension PasskeyController {
|
|||||||
private struct Start: Decodable {
|
private struct Start: Decodable {
|
||||||
let email: String?
|
let email: String?
|
||||||
let add: Bool?
|
let add: Bool?
|
||||||
|
let recover: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public struct SessionController<Context: RequestContext> {
|
|||||||
/// Whether the session cookie is limited to HTTPS.
|
/// Whether the session cookie is limited to HTTPS.
|
||||||
private let isSecure: Bool
|
private let isSecure: Bool
|
||||||
|
|
||||||
|
/// The one-time recovery links handed out.
|
||||||
|
private let recoveryLinks: RecoveryLinks
|
||||||
|
|
||||||
/// The store sessions are started and ended in.
|
/// The store sessions are started and ended in.
|
||||||
private let sessions: SessionStore
|
private let sessions: SessionStore
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ public struct SessionController<Context: RequestContext> {
|
|||||||
/// - sessions: the store sessions are started and ended in.
|
/// - sessions: the store sessions are started and ended in.
|
||||||
/// - accounts: the repository accounts are found and created through.
|
/// - accounts: the repository accounts are found and created through.
|
||||||
/// - catalogue: the repository the visitor's intent is described from.
|
/// - catalogue: the repository the visitor's intent is described from.
|
||||||
|
/// - recoveryLinks: the one-time recovery links handed out; a set of its own by default.
|
||||||
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
|
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||||
@@ -46,6 +50,7 @@ public struct SessionController<Context: RequestContext> {
|
|||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
accounts: AccountRepository,
|
accounts: AccountRepository,
|
||||||
catalogue: CatalogueRepository,
|
catalogue: CatalogueRepository,
|
||||||
|
recoveryLinks: RecoveryLinks = .init(),
|
||||||
isSecure: Bool = false,
|
isSecure: Bool = false,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
@@ -55,6 +60,7 @@ public struct SessionController<Context: RequestContext> {
|
|||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.catalogue = catalogue
|
self.catalogue = catalogue
|
||||||
self.isSecure = isSecure
|
self.isSecure = isSecure
|
||||||
|
self.recoveryLinks = recoveryLinks
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +87,9 @@ extension SessionController: RouterController {
|
|||||||
.Session.signOut,
|
.Session.signOut,
|
||||||
use: signOut
|
use: signOut
|
||||||
)
|
)
|
||||||
|
routes.get(.init(.Route.recover), use: recover)
|
||||||
|
routes.post(.init(.Route.recover), use: sendRecoveryLink)
|
||||||
|
routes.get(.init("\(String.Route.recover)/:token"), use: followRecoveryLink)
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,19 @@ private extension SessionController {
|
|||||||
.split(separator: "/")
|
.split(separator: "/")
|
||||||
.map(String.init)
|
.map(String.init)
|
||||||
|
|
||||||
|
guard let first = segments.first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch first {
|
||||||
|
case "messages": return "To read and send messages"
|
||||||
|
case "wants": return "To keep a want list"
|
||||||
|
case "collection": return "To track your collection"
|
||||||
|
case "sell": return "To sell on CCN"
|
||||||
|
case "account": return "To manage your account"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
guard segments.count >= 2 else {
|
guard segments.count >= 2 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -155,6 +177,79 @@ private extension SessionController {
|
|||||||
.response(from: request, context: context)
|
.response(from: request, context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles a one-time recovery link followed: the way to a new passkey while it is good, and word that it ran out otherwise.
|
||||||
|
@Sendable
|
||||||
|
func followRecoveryLink(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let token = try context.parameters.require("token")
|
||||||
|
|
||||||
|
return recoveryPage(
|
||||||
|
await recoveryLinks.user(for: token) == nil ? .expired : .renew(token: token),
|
||||||
|
request: request,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for the recovery screen.
|
||||||
|
@Sendable
|
||||||
|
func recover(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
recoveryPage(.ask(problem: nil), request: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a step of the recovery, never stored.
|
||||||
|
func recoveryPage(
|
||||||
|
_ step: RecoverPage.Step,
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) -> Response {
|
||||||
|
let status: HTTPResponse.Status = switch step {
|
||||||
|
case .ask(let problem) where problem != nil: .unprocessableContent
|
||||||
|
case .expired: .gone
|
||||||
|
default: .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
return HTMLResponse(
|
||||||
|
status: status,
|
||||||
|
additionalHeaders: [.cacheControl: "no-store"]
|
||||||
|
) { [analytics, assetVersion] in
|
||||||
|
RecoverPage(
|
||||||
|
step: step,
|
||||||
|
assetVersion: assetVersion,
|
||||||
|
analytics: analytics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.response(from: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a request for a recovery link.
|
||||||
|
///
|
||||||
|
/// **Stubbed**: with no mail to send, the link is shown on the screen — to anyone who asks. It must be mailed, and the screen say the
|
||||||
|
/// same whether or not the email has an account, before anything real depends on it.
|
||||||
|
@Sendable
|
||||||
|
func sendRecoveryLink(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
let form = try await URLEncodedFormDecoder().decode(RecoveryForm.self, from: request, context: context)
|
||||||
|
|
||||||
|
guard let email = NormalizeEmail()(form.email) else {
|
||||||
|
return recoveryPage(.ask(problem: "That doesn't look like an email address."), request: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var link: String?
|
||||||
|
|
||||||
|
if let user = try await accounts.existingAccount(email: email) {
|
||||||
|
link = .Route.recover(token: await recoveryLinks.make(for: user))
|
||||||
|
}
|
||||||
|
|
||||||
|
return recoveryPage(.sent(link: link), request: request, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
|
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
|
||||||
@Sendable
|
@Sendable
|
||||||
func show(
|
func show(
|
||||||
@@ -237,3 +332,8 @@ private extension RouterPath {
|
|||||||
static let signOut: RouterPath = .init(.Route.signOut)
|
static let signOut: RouterPath = .init(.Route.signOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The field the recovery form posts.
|
||||||
|
private struct RecoveryForm: Decodable {
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
/// The repository listings and shops are read through.
|
/// The repository listings and shops are read through.
|
||||||
private let catalogue: CatalogueRepository
|
private let catalogue: CatalogueRepository
|
||||||
|
|
||||||
|
/// The repository the events a shop trades at are read and set through, or `nil` to leave them out.
|
||||||
|
private let collecting: CollectingRepository?
|
||||||
|
|
||||||
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
|
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
|
||||||
private let onCatalogueChange: @Sendable () async -> Void
|
private let onCatalogueChange: @Sendable () async -> Void
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
/// - trade: the repository conversations, offers, and sales are read and written through.
|
/// - trade: the repository conversations, offers, and sales are read and written through.
|
||||||
/// - catalogue: the repository listings and shops are read through.
|
/// - catalogue: the repository listings and shops are read through.
|
||||||
/// - sessions: the store the signed-in account is read from.
|
/// - sessions: the store the signed-in account is read from.
|
||||||
|
/// - collecting: the repository the events a shop trades at are read and set through, or `nil` (the default) to leave them out.
|
||||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||||
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||||
@@ -43,6 +47,7 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
trade: TradeRepository,
|
trade: TradeRepository,
|
||||||
catalogue: CatalogueRepository,
|
catalogue: CatalogueRepository,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
|
collecting: CollectingRepository? = nil,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
analytics: Analytics? = nil,
|
analytics: Analytics? = nil,
|
||||||
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||||
@@ -51,6 +56,7 @@ public struct TradeController<Context: RequestContext> {
|
|||||||
self.onCatalogueChange = onCatalogueChange
|
self.onCatalogueChange = onCatalogueChange
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.catalogue = catalogue
|
self.catalogue = catalogue
|
||||||
|
self.collecting = collecting
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
self.trade = trade
|
self.trade = trade
|
||||||
}
|
}
|
||||||
@@ -78,6 +84,7 @@ extension TradeController: RouterController {
|
|||||||
routes.post(.Trade.sale, use: takeStep)
|
routes.post(.Trade.sale, use: takeStep)
|
||||||
routes.get(.Trade.dashboard, use: dashboard)
|
routes.get(.Trade.dashboard, use: dashboard)
|
||||||
routes.post(.Trade.answer, use: answer)
|
routes.post(.Trade.answer, use: answer)
|
||||||
|
routes.post(.init("\(String.Route.sellerDashboard)/events"), use: setEvents)
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
@@ -429,6 +436,26 @@ private extension TradeController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles the events a seller ticked on the dashboard. A form with no box ticked posts no field at all.
|
||||||
|
@Sendable
|
||||||
|
func setEvents(
|
||||||
|
request: Request,
|
||||||
|
context: Context
|
||||||
|
) async throws -> Response {
|
||||||
|
guard let (user, _) = await account(of: request) else {
|
||||||
|
return .redirect(to: .Route.signIn, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = try await request.body.collect(upTo: 16 * 1_024)
|
||||||
|
let slugs = SearchParameters(query: String(buffer: body)).items
|
||||||
|
.filter { $0.name == "events" }
|
||||||
|
.map(\.value)
|
||||||
|
|
||||||
|
try await collecting?.setEvents(slugs, forShopOwnedBy: user)
|
||||||
|
|
||||||
|
return .redirect(to: .Route.sellerDashboard, type: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
|
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
|
||||||
@Sendable
|
@Sendable
|
||||||
func dashboard(
|
func dashboard(
|
||||||
@@ -446,6 +473,7 @@ private extension TradeController {
|
|||||||
let offers = try await trade.openOffers(for: user)
|
let offers = try await trade.openOffers(for: user)
|
||||||
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
|
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))
|
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
|
||||||
|
let events = try await collecting?.upcomingEvents() ?? []
|
||||||
|
|
||||||
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||||
SellerDashboardPage(
|
SellerDashboardPage(
|
||||||
@@ -453,6 +481,7 @@ private extension TradeController {
|
|||||||
offers: offers,
|
offers: offers,
|
||||||
sales: sales,
|
sales: sales,
|
||||||
listings: listings,
|
listings: listings,
|
||||||
|
events: events,
|
||||||
viewer: viewer,
|
viewer: viewer,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ extension PasskeyCeremonies {
|
|||||||
case addition(userID: UUID)
|
case addition(userID: UUID)
|
||||||
/// Signs in with a passkey registered earlier.
|
/// Signs in with a passkey registered earlier.
|
||||||
case authentication
|
case authentication
|
||||||
|
/// Replaces the passkeys of the account a one-time recovery link names.
|
||||||
|
case recovery(token: String)
|
||||||
/// Creates the account of the given email, with its first passkey.
|
/// Creates the account of the given email, with its first passkey.
|
||||||
case registration(email: String)
|
case registration(email: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import Persistence
|
||||||
|
|
||||||
|
/// The one-time recovery links handed out: each an unguessable token naming an account, good once, for an hour.
|
||||||
|
public actor RecoveryLinks {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// How long a link works: an hour.
|
||||||
|
static let lifetime: TimeInterval = 3_600
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The clock expiries are judged by.
|
||||||
|
private let now: @Sendable () -> Date
|
||||||
|
|
||||||
|
/// The links still good, by token.
|
||||||
|
private var links: [String: (user: User, expiresAt: Date)] = [:]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates an empty set of links.
|
||||||
|
/// - Parameter now: the clock expiries are judged by; the system's by default.
|
||||||
|
public init(
|
||||||
|
now: @escaping @Sendable () -> Date = { .init() }
|
||||||
|
) {
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Makes a link for an account, ending any it had before.
|
||||||
|
/// - Returns: the link's token.
|
||||||
|
func make(
|
||||||
|
for user: User
|
||||||
|
) -> String {
|
||||||
|
let token = "\(UUID().uuidString)\(UUID().uuidString)"
|
||||||
|
.replacingOccurrences(of: "-", with: "")
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
links = links.filter { $0.value.user.id != user.id && $0.value.expiresAt > now() }
|
||||||
|
links[token] = (user, now().addingTimeInterval(Self.lifetime))
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account a link still good names, without using the link up.
|
||||||
|
func user(
|
||||||
|
for token: String
|
||||||
|
) -> User? {
|
||||||
|
guard let link = links[token], link.expiresAt > now() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return link.user
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses a link up.
|
||||||
|
/// - Returns: the account it named, or `nil` when it was no longer good.
|
||||||
|
func redeem(
|
||||||
|
_ token: String
|
||||||
|
) -> User? {
|
||||||
|
defer {
|
||||||
|
links[token] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return user(for: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -82,7 +82,21 @@ chrome.send("Page.navigate", url=f"{BASE}/")
|
|||||||
chrome.wait_for("document.readyState === 'complete'")
|
chrome.wait_for("document.readyState === 'complete'")
|
||||||
anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)")
|
anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)")
|
||||||
check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous)
|
check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous)
|
||||||
held_before = 0
|
|
||||||
|
# 3c. Every passkey lost, a one-time link sets up a new one — which replaces the others, and signs in.
|
||||||
|
link = chrome.evaluate("""fetch('/recover', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'email=sam.okafor%40example.com'})
|
||||||
|
.then(r => r.text()).then(html => (html.match(/\\/recover\\/[0-9a-f]+/) || [null])[0])""")
|
||||||
|
check("recover: a known email gets a one-time link", bool(link), link)
|
||||||
|
chrome.send("Page.navigate", url=f"{BASE}{link}")
|
||||||
|
assert chrome.wait_for("document.querySelector('[data-passkey=recover]') && !document.querySelector('[data-passkey=recover]').hidden")
|
||||||
|
chrome.evaluate("document.querySelector('[data-passkey=recover]').click(); true")
|
||||||
|
recovered = chrome.wait_for("location.pathname === '/account'")
|
||||||
|
check("recover: the new passkey signs in, on the account screen", recovered, status() if not recovered else "")
|
||||||
|
check("recover: it replaced every other passkey", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
|
||||||
|
chrome.send("Page.navigate", url=f"{BASE}{link}")
|
||||||
|
check("recover: the link works once", chrome.wait_for("document.body.textContent.includes('That link has run out')"))
|
||||||
|
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
||||||
|
held_before = len(credentials())
|
||||||
|
|
||||||
# 4. A demo account has no passkey, and none can be added to it.
|
# 4. A demo account has no passkey, and none can be added to it.
|
||||||
open_sign_in("/")
|
open_sign_in("/")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct SiteFooterTests {
|
|||||||
#expect(html.contains(">\(title)</h2>"))
|
#expect(html.contains(">\(title)</h2>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] {
|
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.events, .Route.signIn, .Route.wants, .Route.collection, .Route.account, .Route.recover] {
|
||||||
#expect(html.contains(#"href="\#(path)""#))
|
#expect(html.contains(#"href="\#(path)""#))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ struct CataloguePagesTests {
|
|||||||
publishers: []
|
publishers: []
|
||||||
).render()
|
).render()
|
||||||
|
|
||||||
#expect(html.contains("No listings match"))
|
#expect(html.contains("Nothing for “nothing”"))
|
||||||
|
#expect(html.contains(#"href="/wants/new?title=nothing""#))
|
||||||
#expect(html.contains(#"href="/search?ships-from=anywhere""#))
|
#expect(html.contains(#"href="/search?ships-from=anywhere""#))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ struct CollectorPagesTests {
|
|||||||
#expect(html.contains(#"name="title" value="Nightward #2""#))
|
#expect(html.contains(#"name="title" value="Nightward #2""#))
|
||||||
#expect(html.contains(#"<option value="9.6" selected>"#))
|
#expect(html.contains(#"<option value="9.6" selected>"#))
|
||||||
#expect(html.contains(#"name="max-price" value="85""#))
|
#expect(html.contains(#"name="max-price" value="85""#))
|
||||||
#expect(!html.contains("Nothing on your list yet"))
|
#expect(!html.contains("No wants yet"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: SeriesPage tests
|
// MARK: SeriesPage tests
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Collection, events, and recovery pages",
|
||||||
|
.tags(.page)
|
||||||
|
)
|
||||||
|
struct CommunityPagesTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
private static let nightward = Series(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 4, blurb: "")
|
||||||
|
|
||||||
|
private static let con = Event(
|
||||||
|
slug: "dutch-comic-con", name: "Dutch Comic Con", venue: "Jaarbeurs, Utrecht",
|
||||||
|
startsOn: .stub.addingTimeInterval(3 * 86_400), endsOn: .stub.addingTimeInterval(4 * 86_400), shops: [.stub()]
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: CollectionPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `collection counts what is owned, missing, and listed among the gaps`() {
|
||||||
|
let html = CollectionPage(
|
||||||
|
catalogue: [Self.nightward],
|
||||||
|
owned: ["nightward": [2, 3]],
|
||||||
|
// #1 is a gap that is listed; #4 a gap that is not.
|
||||||
|
listings: [.stub()]
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("2 books · 1 series"))
|
||||||
|
#expect(html.contains(">2/4<"))
|
||||||
|
#expect(html.contains("progress--50"))
|
||||||
|
#expect(html.contains("2 missing · 1 listed now"))
|
||||||
|
#expect(html.contains(#"href="/series/nightward""#))
|
||||||
|
#expect(html.contains(#"action="/collection" method="post""#))
|
||||||
|
#expect(html.contains(#"<option value="nightward">Nightward · 4 issues"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `collection marks a finished run, and says so when there is nothing yet`() {
|
||||||
|
let complete = CollectionPage(catalogue: [Self.nightward], owned: ["nightward": [1, 2, 3, 4]], listings: []).render()
|
||||||
|
let empty = CollectionPage(catalogue: [Self.nightward], owned: [:], listings: []).render()
|
||||||
|
|
||||||
|
#expect(complete.contains("progress--100"))
|
||||||
|
#expect(complete.contains(">Complete<"))
|
||||||
|
#expect(empty.contains("Your collection is empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
["1-6, 9, 12", "3", "5-3, x, 0, 1000", " 2 ; 2 - 4 ", "1-9999"],
|
||||||
|
[[1, 2, 3, 4, 5, 6, 9, 12], [3], [], [2, 2, 4], []]
|
||||||
|
))
|
||||||
|
func `reads the issue numbers a collector typed`(
|
||||||
|
_ text: String,
|
||||||
|
expects issues: [Int]
|
||||||
|
) {
|
||||||
|
#expect(CollectionPage.issues(in: text) == issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SeriesPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `series shows the viewer's collection, a switch per issue, and dims what is owned`() {
|
||||||
|
let html = SeriesPage(series: Self.nightward, listings: [.stub()], owned: [1, 3], viewer: .init(displayName: "Mara")).render()
|
||||||
|
let signedOut = SeriesPage(series: Self.nightward, listings: [.stub()]).render()
|
||||||
|
|
||||||
|
#expect(html.contains("2 of 4 owned"))
|
||||||
|
#expect(html.contains("2 missing · 0 of them listed now"))
|
||||||
|
#expect(html.components(separatedBy: "series__issue--owned").count - 1 == 2)
|
||||||
|
#expect(html.contains(#"action="/series/nightward/own" method="post""#))
|
||||||
|
#expect(html.contains(#"aria-pressed="true">Owned ✓"#))
|
||||||
|
#expect(html.contains(#"aria-pressed="false">I own this"#))
|
||||||
|
#expect(!signedOut.contains("Your collection"))
|
||||||
|
#expect(!signedOut.contains("series__own"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: EventsPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `events count the shops trading and the copies to pick up, and flag this weekend`() {
|
||||||
|
let later = Event(
|
||||||
|
slug: "comic-con-amsterdam", name: "Comic Con Amsterdam", venue: "RAI, Amsterdam",
|
||||||
|
startsOn: .stub.addingTimeInterval(59 * 86_400), endsOn: .stub.addingTimeInterval(60 * 86_400)
|
||||||
|
)
|
||||||
|
let html = EventsPage(
|
||||||
|
events: [Self.con, later],
|
||||||
|
listings: [.stub(), .stub(slug: "b"), .stub(slug: "c", shop: .stub(slug: "elsewhere", name: "Elsewhere"))],
|
||||||
|
now: .stub
|
||||||
|
).render()
|
||||||
|
|
||||||
|
#expect(html.contains("Sat 26 – Sun 27 Sep"))
|
||||||
|
#expect(html.contains("Sat 21 – Sun 22 Nov"))
|
||||||
|
#expect(html.components(separatedBy: "This weekend").count - 1 == 1)
|
||||||
|
#expect(html.contains("1 shop trading"))
|
||||||
|
#expect(html.contains("2 listings for pickup"))
|
||||||
|
#expect(html.contains("0 shops trading"))
|
||||||
|
#expect(html.contains(#"href="/search?pickup=dutch-comic-con&ships-from=anywhere""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Pickup and state tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `listing and shop say where the copy can be picked up`() {
|
||||||
|
let listing = ListingPage(listing: .stub(), related: [], pickups: [Self.con]).render()
|
||||||
|
let shop = ShopPage(shop: .stub(), inventory: [.stub()], matches: [.stub()], parameters: .init(), pickups: [Self.con]).render()
|
||||||
|
let restocking = ShopPage(shop: .stub(), inventory: [], matches: [], parameters: .init(), pickups: [Self.con]).render()
|
||||||
|
|
||||||
|
#expect(listing.contains("or pick up at Dutch Comic Con, Sat 26 – Sun 27 Sep"))
|
||||||
|
#expect(shop.contains("trading at "))
|
||||||
|
#expect(restocking.contains("Kestrel Row Comics is restocking"))
|
||||||
|
#expect(restocking.contains("the shop is trading at Dutch Comic Con"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `search offers pickup as a filter, and a pill to drop it`() {
|
||||||
|
let html = SearchPage(parameters: .init(query: "pickup=dutch-comic-con"), matches: [.stub()], publishers: []).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"name="pickup" value="dutch-comic-con" checked"#))
|
||||||
|
#expect(html.contains(#"aria-label="Remove filter: Event pickup""#))
|
||||||
|
#expect(SearchPage(parameters: .init(), matches: [], publishers: []).render().contains(#"name="pickup" value="any""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `messages offer the ways on from an expired offer, to the buyer alone`() {
|
||||||
|
let entries = [Conversation.Entry(kind: .offer, sender: .mara, body: "", offer: .stub(status: .expired), createdAt: .stub)]
|
||||||
|
let buying = MessagesPage(conversations: [], open: .stub(entries: entries), now: .stub).render()
|
||||||
|
let selling = MessagesPage(conversations: [], open: .stub(side: .selling, entries: entries), now: .stub).render()
|
||||||
|
|
||||||
|
#expect(buying.contains("didn't answer within 48 hours") || buying.contains("didn't answer within 48 hours"))
|
||||||
|
#expect(buying.contains(">Offer again<"))
|
||||||
|
#expect(buying.contains(#"href="/listing/nightward-1/buy">Buy at €240"#))
|
||||||
|
#expect(!selling.contains("Offer again"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `seller dashboard ticks the events the shop trades at`() {
|
||||||
|
let other = Event(slug: "other", name: "Other Con", venue: "", startsOn: .stub, endsOn: .stub)
|
||||||
|
let html = SellerDashboardPage(shop: .stub(), offers: [], sales: [], listings: [], events: [Self.con, other], now: .stub).render()
|
||||||
|
|
||||||
|
#expect(html.contains(#"action="/sell/events" method="post""#))
|
||||||
|
#expect(html.contains(#"name="events" value="dutch-comic-con" checked"#))
|
||||||
|
#expect(html.contains(#"name="events" value="other">"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: RecoverPage tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `recovery walks from the email, to the link, to a new passkey`() {
|
||||||
|
let ask = RecoverPage(step: .ask(problem: "That doesn't look like an email address.")).render()
|
||||||
|
let sent = RecoverPage(step: .sent(link: "/recover/abc")).render()
|
||||||
|
let unknown = RecoverPage(step: .sent(link: nil)).render()
|
||||||
|
let renew = RecoverPage(step: .renew(token: "abc")).render()
|
||||||
|
let expired = RecoverPage(step: .expired).render()
|
||||||
|
|
||||||
|
#expect(ask.contains(#"action="/recover" method="post""#))
|
||||||
|
#expect(ask.contains(#"id="email-error""#))
|
||||||
|
#expect(sent.contains(#"href="/recover/abc""#))
|
||||||
|
#expect(sent.contains("Prototype: CCN sends no mail yet"))
|
||||||
|
#expect(unknown.contains("No account has that email"))
|
||||||
|
#expect(renew.contains(#"name="recover" value="abc""#))
|
||||||
|
#expect(renew.contains(#"hidden data-passkey="recover""#))
|
||||||
|
#expect(renew.contains("data-passkey-unsupported"))
|
||||||
|
#expect(renew.contains("/js/passkey.js"))
|
||||||
|
#expect(expired.contains("That link has run out"))
|
||||||
|
|
||||||
|
for html in [ask, sent, renew, expired] {
|
||||||
|
#expect(html.contains(#"content="noindex""#))
|
||||||
|
#expect(!html.contains("site-header"))
|
||||||
|
#expect(!html.contains("style="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `sign-in tells a browser without passkeys so, and leads to recovery`() {
|
||||||
|
let html = SignInPage(returnPath: "/").render()
|
||||||
|
|
||||||
|
#expect(html.contains("data-passkey-unsupported"))
|
||||||
|
#expect(html.contains(#"href="/recover">Lost access to your passkeys?"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -228,7 +228,9 @@ struct TradePagesTests {
|
|||||||
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
|
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
|
||||||
).render()
|
).render()
|
||||||
|
|
||||||
#expect(sold.contains("This copy is sold."))
|
#expect(sold.contains("This copy sold on 23 Sep."))
|
||||||
|
#expect(sold.contains("Kestrel Row Comics has 0 more copies of Nightward #1, and 0 are listed across CCN."))
|
||||||
|
#expect(sold.contains(#"href="/wants/new?title=Nightward%20%231""#))
|
||||||
#expect(!sold.contains("Buy now"))
|
#expect(!sold.contains("Buy now"))
|
||||||
#expect(own.contains("This is your listing."))
|
#expect(own.contains("This is your listing."))
|
||||||
#expect(!own.contains("Make an offer"))
|
#expect(!own.contains("Make an offer"))
|
||||||
|
|||||||
+99
-2
@@ -140,7 +140,7 @@ struct AccountAndCollectingControllerTests {
|
|||||||
_ = try await post(client, "/wants/\(id)/remove", "", as: mara)
|
_ = try await post(client, "/wants/\(id)/remove", "", as: mara)
|
||||||
|
|
||||||
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||||
#expect(String(buffer: response.body).contains("Nothing on your list yet"))
|
#expect(String(buffer: response.body).contains("No wants yet"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,7 @@ private extension AccountAndCollectingControllerTests {
|
|||||||
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
|
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
|
||||||
let router = Router(context: WebsiteRequestContext.self)
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
|
|
||||||
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions, collecting: collecting).routes)
|
||||||
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes)
|
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes)
|
||||||
router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes)
|
router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes)
|
||||||
router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, catalogue: catalogue, sessions: sessions).routes)
|
router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, catalogue: catalogue, sessions: sessions).routes)
|
||||||
@@ -282,3 +282,100 @@ private extension AccountAndCollectingControllerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection, events, and recovery
|
||||||
|
|
||||||
|
extension AccountAndCollectingControllerTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps a collection through its form and the switches on a series`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let mara = try await signIn(client)
|
||||||
|
|
||||||
|
_ = try await post(client, "/collection", "series=echo-country&issues=1-3%2C+9", as: mara)
|
||||||
|
_ = try await post(client, "/series/echo-country/own", "issue=5", as: mara)
|
||||||
|
_ = try await post(client, "/series/echo-country/own", "issue=1", as: mara)
|
||||||
|
|
||||||
|
try await client.execute(uri: "/collection", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
// #2, #3 and #5: #9 is outside the six-issue run, and #1 was switched back off.
|
||||||
|
#expect(body.contains(">3/6<"))
|
||||||
|
#expect(body.contains("3 missing · 1 listed now"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/series/echo-country", method: .get, headers: [.cookie: mara]) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("3 of 6 owned"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `serves the events, and narrows a search to the copies to pick up at one`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
try await client.execute(uri: "/events", method: .get) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(body.contains("Dutch Comic Con"))
|
||||||
|
#expect(body.contains("Comic Con Brussels"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silverfish alone trades at Stripdagen.
|
||||||
|
try await client.execute(uri: "/search?pickup=stripdagen-haarlem&ships-from=anywhere", method: .get) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("4 listings"))
|
||||||
|
#expect(body.contains("from 1 shop"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/search?pickup=no-such-event", method: .get) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("No listings match"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/listing/nightward-1-cgc-9-8", method: .get) { response in
|
||||||
|
#expect(String(buffer: response.body).contains("or pick up at Dutch Comic Con"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `hands a recovery link to an account's email alone, good until it runs out`() async throws {
|
||||||
|
try await withApp { client in
|
||||||
|
let link = try await client.execute(
|
||||||
|
uri: "/recover",
|
||||||
|
method: .post,
|
||||||
|
headers: Self.form,
|
||||||
|
body: ByteBuffer(string: "email=Mara.V%40example.com")
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.cacheControl] == "no-store")
|
||||||
|
|
||||||
|
return String(try #require(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/)).output)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: link, method: .get) { response in
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(String(buffer: response.body).contains("Set up a new passkey"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/recover/0000", method: .get) { response in
|
||||||
|
#expect(response.status == .gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nobody%40example.com")) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("No account has that email"))
|
||||||
|
#expect(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nope")) { response in
|
||||||
|
#expect(response.status == .unprocessableContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -141,7 +141,11 @@ struct TradeControllerTests {
|
|||||||
|
|
||||||
// The copy is off sale, for everyone.
|
// The copy is off sale, for everyone.
|
||||||
try await client.execute(uri: listing, method: .get) { response in
|
try await client.execute(uri: listing, method: .get) { response in
|
||||||
#expect(String(buffer: response.body).contains("This copy is sold."))
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(body.contains("This copy sold on "))
|
||||||
|
// Three other copies of Nightward #1 remain listed, none of them in this shop.
|
||||||
|
#expect(body.contains("has 0 more copies of Nightward #1, and 3 are listed across CCN."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A step out of turn changes nothing.
|
// A step out of turn changes nothing.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
import Persistence
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import WebsiteLibrary
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"RecoveryLinks type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct RecoveryLinksTests {
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `hands out a link that can be looked at, and used once`() async {
|
||||||
|
let links = RecoveryLinks()
|
||||||
|
let token = await links.make(for: .mara)
|
||||||
|
let peeked = await links.user(for: token)
|
||||||
|
let stillGood = await links.user(for: token)
|
||||||
|
let redeemed = await links.redeem(token)
|
||||||
|
let again = await links.redeem(token)
|
||||||
|
|
||||||
|
#expect(token.count == 64)
|
||||||
|
#expect(peeked == .mara)
|
||||||
|
#expect(stillGood == .mara)
|
||||||
|
#expect(redeemed == .mara)
|
||||||
|
#expect(again == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lets a link run out after an hour, and ends an account's older link`() async {
|
||||||
|
let clock = Clock()
|
||||||
|
let links = RecoveryLinks(now: clock.now)
|
||||||
|
let older = await links.make(for: .mara)
|
||||||
|
let newer = await links.make(for: .mara)
|
||||||
|
let other = await links.make(for: .joost)
|
||||||
|
let replaced = await links.user(for: older)
|
||||||
|
|
||||||
|
clock.advance(by: 3_599)
|
||||||
|
|
||||||
|
let inTime = await links.user(for: newer)
|
||||||
|
|
||||||
|
clock.advance(by: 1)
|
||||||
|
|
||||||
|
let tooLate = await links.redeem(newer)
|
||||||
|
let othersToo = await links.user(for: other)
|
||||||
|
|
||||||
|
#expect(replaced == nil)
|
||||||
|
#expect(inTime == .mara)
|
||||||
|
#expect(tooLate == nil)
|
||||||
|
#expect(othersToo == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user