Implemented phase 6 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 13:53:13 +02:00
1 parent 256d22df40
commit f2f445827b
40 files changed
+1906 -37

No files matched your search

@@ -88,6 +88,9 @@ extension AccountPage: Page {
a(.href(.Route.wants)) {
"Want list"
}
a(.href(.Route.collection)) {
"Collection"
}
a(.href(.Route.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.
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.
let related: [Listing]
@@ -27,12 +33,16 @@ struct ListingPage {
/// - Parameters:
/// - listing: the listing shown.
/// - 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.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
listing: Listing,
related: [Listing],
pickups: [Event] = [],
otherCopies: (inShop: Int, everywhere: Int) = (0, 0),
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -42,6 +52,8 @@ struct ListingPage {
self.assetVersion = assetVersion
self.listing = listing
self.locale = .init(identifier: "en")
self.otherCopies = otherCopies
self.pickups = pickups
self.related = related
}
@@ -228,6 +240,15 @@ private extension ListingPage {
span(.class("type-body-sm buy-box__terms")) {
"+ \(listing.shippingCents.euros) tracked shipping to NL"
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(
@@ -239,8 +260,28 @@ private extension ListingPage {
}
}
if !listing.isAvailable {
p(.class("notice")) {
"This copy is sold. A sale was agreed on CCN."
div(.class("notice listing__sold")) {
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 {
p(.class("notice")) {
@@ -332,6 +332,25 @@ private extension MessagesPage {
"“\(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 {
AnswerOffer(
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 {
div(.class("empty")) {
h2(.class("type-panel")) {
"No listings match"
parameters.value(.query).map { "Nothing for “\($0)”" } ?? "No listings match"
}
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(
.class("button button--outline"),
.href(SearchParameters().setting(.shipsFrom, to: "anywhere").path(Self.path))
) {
"Clear all filters"
div(.class("empty__actions")) {
if let query = parameters.value(.query) {
a(
.class("button button--primary"),
.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 {
@@ -268,6 +279,7 @@ private extension SearchPage {
span(.class("type-small")) {
"Outside the EU, import VAT may apply."
}
checkbox(.pickup, value: parameters.value(.pickup) ?? SearchParameters.anyEvent, label: "Pickup at an event")
}
group("Key issues") {
checkbox(.keys, value: "1", label: "Key issues only")
@@ -12,6 +12,9 @@ struct SellerDashboardPage {
let assetVersion: String?
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.
let listings: [Listing]
@@ -38,6 +41,7 @@ struct SellerDashboardPage {
/// - offers: the offers waiting for the seller's answer, the soonest to expire first.
/// - sales: the shop's sales, the latest agreed first.
/// - listings: the shop's listings still for sale, newest first.
/// - events: the upcoming events; none by default.
/// - viewer: the signed-in viewer.
/// - now: the moment stamps are read relative to; the present by default.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
@@ -47,6 +51,7 @@ struct SellerDashboardPage {
offers: [Offer],
sales: [Sale],
listings: [Listing],
events: [Event] = [],
viewer: Viewer? = nil,
now: Date = .init(),
assetVersion: String? = nil,
@@ -54,6 +59,7 @@ struct SellerDashboardPage {
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.events = events.map { event in (event, event.shops.contains { $0.slug == shop.slug }) }
self.listings = listings
self.locale = .init(identifier: "en")
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.
let listings: [Listing]
/// The issues the viewer owns of the series, or `nil` signed out.
let owned: Set<Int>?
/// The series shown.
let series: Series
@@ -31,6 +34,7 @@ struct SeriesPage {
/// - series: the series shown.
/// - listings: every listing of the series still for sale.
/// - 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.
/// - 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.
@@ -38,6 +42,7 @@ struct SeriesPage {
series: Series,
listings: [Listing],
issue: Int? = nil,
owned: Set<Int>? = nil,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -47,6 +52,7 @@ struct SeriesPage {
self.issue = issue
self.listings = listings
self.locale = .init(identifier: "en")
self.owned = owned
self.series = series
self.viewer = viewer
}
@@ -104,6 +110,19 @@ extension SeriesPage: Page {
p(.class("type-lead")) {
"\(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")) {
ForEach(keys) { key in
li(.class("chip chip--warn")) {
@@ -125,7 +144,7 @@ extension SeriesPage: Page {
}
ol(.class("series__issues")) {
ForEach(issues) { entry in
li(.class(entry.copies.isEmpty ? "series__issue series__issue--gap" : "series__issue")) {
li(.class(issueClass(entry))) {
span(.class("series__number")) {
"#\(entry.number)"
}
@@ -143,6 +162,25 @@ extension SeriesPage: Page {
"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 {
/// 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.
var collected: [Listing] {
listings.filter { $0.issueNumber == nil }
@@ -24,6 +24,9 @@ struct ShopPage {
/// The tab, quick filters, and sort order applied.
let parameters: SearchParameters
/// The upcoming events the shop trades at.
let pickups: [Event]
/// The shop shown.
let shop: Shop
@@ -38,6 +41,7 @@ struct ShopPage {
/// - inventory: every listing of the shop, counted per format on the tabs.
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
/// - parameters: the tab, quick filters, and sort order applied.
/// - pickups: the upcoming events the shop trades at; none by default.
/// - 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.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
@@ -46,6 +50,7 @@ struct ShopPage {
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
pickups: [Event] = [],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -64,6 +69,7 @@ struct ShopPage {
self.locale = .init(identifier: "en")
self.pages = (slice.current, slice.total)
self.parameters = parameters
self.pickups = pickups
self.shop = shop
}
@@ -104,6 +110,12 @@ extension ShopPage: Page {
}
p(.class("type-body-sm shop__standing")) {
"\(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")) {
shop.about
@@ -197,10 +209,12 @@ extension ShopPage: Page {
if listings.isEmpty {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing here right now"
counts.isEmpty ? "\(shop.name) is restocking" : "Nothing here right now"
}
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(
.class("button button--outline"),
@@ -110,6 +110,7 @@ extension SignInPage: Page {
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
PasskeyUnsupported()
div(.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 {
div(.class("empty")) {
h2(.class("type-panel")) {
"Nothing on your list yet"
"No wants yet"
}
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."
}
}
}