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
@@ -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),
|
||||
("Seller dashboard", .Route.sellerDashboard),
|
||||
("Grading guide", .Route.gradingGuide),
|
||||
("Trade at events", .Route.events),
|
||||
]
|
||||
),
|
||||
(
|
||||
@@ -84,8 +85,10 @@ private extension SiteFooter {
|
||||
links: [
|
||||
("Sign in or join", .Route.signIn),
|
||||
("Want list & alerts", .Route.wants),
|
||||
("My collection", .Route.collection),
|
||||
("Messages", .Route.messages),
|
||||
("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 {
|
||||
nav(
|
||||
.class("site-nav"),
|
||||
@@ -134,6 +134,12 @@ private extension SiteHeader {
|
||||
) {
|
||||
"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.
|
||||
///
|
||||
/// Read on Amsterdam's clock: the marketplace is NL-first.
|
||||
|
||||
@@ -7,8 +7,12 @@ extension String {
|
||||
enum Route {
|
||||
/// The account screen.
|
||||
static let account = "/account"
|
||||
/// The collection tracker.
|
||||
static let collection = "/collection"
|
||||
/// The side-by-side comparison of copies.
|
||||
static let compare = "/compare"
|
||||
/// The events where shops trade in person.
|
||||
static let events = "/events"
|
||||
/// The public grading guide.
|
||||
static let gradingGuide = "/grading-guide"
|
||||
/// The browse / home screen.
|
||||
@@ -17,6 +21,8 @@ extension String {
|
||||
static let messages = "/messages"
|
||||
/// The open-a-shop onboarding.
|
||||
static let openShop = "/sell/open-a-shop"
|
||||
/// The passkey recovery screen.
|
||||
static let recover = "/recover"
|
||||
/// The search results screen.
|
||||
static let search = "/search"
|
||||
/// The seller dashboard.
|
||||
@@ -101,6 +107,21 @@ extension String {
|
||||
"\(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.
|
||||
/// - Parameter number: the record's number.
|
||||
static func sale(
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ extension SearchParameters {
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
pills.append(.init(label: "Key issues", removed: removing(.keys)))
|
||||
}
|
||||
@@ -278,6 +285,7 @@ extension SearchParameters {
|
||||
case keys
|
||||
case offers
|
||||
case page
|
||||
case pickup
|
||||
case priceMax = "price-max"
|
||||
case priceMin = "price-min"
|
||||
case publisher
|
||||
|
||||
Reference in new issue
Block a user