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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user