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

This commit is contained in:
javier committed 2026-09-21 11:44:58 +02:00
1 parent 09c7b8a1a7
commit 43dcc03da6
28 files changed
+1468 -65

No files matched your search

@@ -56,6 +56,8 @@ extension Icon {
case bookmark
/// A check mark.
case check
/// A cross.
case close
/// A page of text.
case document
/// Filter lines.
@@ -85,6 +87,7 @@ extension Icon.Glyph {
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
case .close: #"<path d="M6 6l12 12M18 6 6 18"></path>"#
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
@@ -100,7 +103,7 @@ extension Icon.Glyph {
var strokeWidth: String {
switch self {
case .check: "3.5"
case .filter, .key, .search, .sort: "2.2"
case .close, .filter, .key, .search, .sort: "2.2"
default: "2"
}
}
@@ -218,12 +218,25 @@ private extension SiteHeader {
)
) {
span(.class("from-desktop")) {
viewer.displayName
viewer.firstName
}
span(.class("site-header__avatar")) {
viewer.initials
}
}
// Not in the design, whose sign-out lives on the account screen the prototype leaves out.
form(
.class("site-header__sign-out"),
.action(.Route.signOut),
.method(.post)
) {
button(
.class("site-header__text-link"),
.type(.submit)
) {
"Sign out"
}
}
}
/// An icon link with a count badge, omitted at zero.
@@ -0,0 +1,41 @@
import Elementary
/// An action the prototype shows but does not build: a button that is visibly, and audibly, unavailable.
struct Unavailable: HTML {
// MARK: Properties
/// The action's name.
let label: String
/// A modifier class sizing or styling the button, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates an unavailable action.
/// - Parameters:
/// - label: the action's name.
/// - modifier: a modifier class sizing or styling the button, or `nil` (the default) for none.
init(
_ label: String,
modifier: String? = nil
) {
self.label = label
self.modifier = modifier
}
// MARK: Computed
var body: some HTML {
button(
.class(["button", modifier].compactMap(\.self).joined(separator: " ")),
.type(.button),
.disabled,
.title("Not part of the prototype")
) {
label
}
}
}
@@ -0,0 +1,8 @@
import HTTPTypes
extension HTTPFields {
/// The headers of a page rendered for its viewer: kept out of shared caches, and revalidated before reuse.
static let perViewer: Self = [
.cacheControl: "private, no-cache"
]
}
@@ -33,11 +33,34 @@ extension String {
static let series = "/series"
/// The passkey sign-in screen.
static let signIn = "/sign-in"
/// The path a sign-out is posted to.
static let signOut = "/sign-out"
/// The terms of service.
static let terms = "/terms"
/// The want list.
static let wants = "/wants"
/// The path buying a listing outright starts at.
/// - Parameter slug: the listing's URL slug.
static func buy(
_ slug: String
) -> String {
"\(listing(slug))/buy"
}
/// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
/// - Parameters:
/// - path: the action's own path.
/// - origin: the path of the screen the action is taken on.
/// - viewer: the signed-in viewer, or `nil` when signed out.
static func gated(
_ path: String,
from origin: String,
viewer: Viewer?
) -> String {
viewer == nil ? signIn(returningTo: origin) : path
}
/// The path of a listing's detail screen.
/// - Parameter slug: the listing's URL slug.
static func listing(
@@ -46,6 +69,30 @@ extension String {
"/listing/\(slug)"
}
/// The path of a new message thread about a listing.
/// - Parameter slug: the listing's URL slug.
static func message(
listing slug: String
) -> String {
"\(messages)/new?listing=\(slug)"
}
/// The path of a new message thread with a shop.
/// - Parameter slug: the shop's URL slug.
static func message(
shop slug: String
) -> String {
"\(messages)/new?shop=\(slug)"
}
/// The path of the offer sheet for a listing.
/// - Parameter slug: the listing's URL slug.
static func offer(
_ slug: String
) -> String {
"\(listing(slug))/offer"
}
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
@@ -54,6 +101,27 @@ extension String {
"/shop/\(slug)"
}
/// The path to return to after signing in: the one asked for when it stays on this site, else the home screen.
///
/// Only a rooted path is followed. Anything else — another origin, a scheme-relative `//host`, a backslash a browser reads as a slash —
/// would turn the sign-in into an open redirect.
/// - Parameter path: the path asked for, as the `return` parameter carried it.
static func safeReturn(
_ path: String?
) -> String {
guard
let path,
path.hasPrefix("/"),
!path.hasPrefix("//"),
!path.contains("\\"),
!path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control })
else {
return home
}
return path
}
/// The path of the sign-in screen, returning to the given path afterwards.
/// - Parameter path: the path the visitor's gated action started from.
static func signIn(
@@ -24,6 +24,9 @@ struct IndexPage {
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
let siteOrigin: String?
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize: Localize
@@ -36,13 +39,16 @@ struct IndexPage {
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
/// - listings: the listings the page shows in its rows; none by default.
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
init(
locale: Locale,
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil,
listings: HomeListings = .init()
listings: HomeListings = .init(),
viewer: Viewer? = nil
) {
self.viewer = viewer
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = listings
@@ -90,7 +96,7 @@ extension IndexPage: Page {
// MARK: Properties
var content: some HTML {
SiteLayout {
SiteLayout(viewer: viewer) {
div(.class("page home")) {
section(.class("hero")) {
div(.class("hero__text")) {
@@ -18,20 +18,26 @@ struct ListingPage {
/// Other listings from the same shop.
let related: [Listing]
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the detail screen of a listing.
/// - Parameters:
/// - listing: the listing shown.
/// - related: other listings from the same shop.
/// - 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],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.viewer = viewer
self.analytics = analytics
self.assetVersion = assetVersion
self.listing = listing
@@ -48,7 +54,10 @@ extension ListingPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout(active: listing.kind) {
SiteLayout(
active: listing.kind,
viewer: viewer
) {
div(.class("page listing")) {
breadcrumb
div(.class("listing__body")) {
@@ -201,48 +210,54 @@ private extension ListingPage {
listing.acceptsOffers ? " · accepts offers" : ""
}
}
a(
.class("button button--sm"),
.href(signIn)
) {
Icon(.bookmark, size: 16)
"Save"
if viewer == nil {
a(
.class("button button--sm"),
.href(.Route.signIn(returningTo: listing.path))
) {
Icon(.bookmark, size: 16)
"Save"
}
} else {
Unavailable("Save", modifier: "button--sm")
}
}
div(.class("buy-box__actions")) {
a(
.class("button button--primary button--lg"),
.href(signIn)
.href(gated(.Route.buy(listing.slug)))
) {
"Buy now"
}
if listing.acceptsOffers {
a(
.class("button button--outline button--lg"),
.href(signIn)
.href(gated(.Route.offer(listing.slug)))
) {
"Make an offer"
}
}
a(
.class("button button--bare button--lg"),
.href(signIn)
.href(gated(.Route.message(listing: listing.slug)))
) {
Icon(.message, size: 16)
"Message seller"
}
}
p(.class("gate-note")) {
Icon(.lock, size: 16)
span {
"Buying, offers, saving and messages need a signed-in account. "
a(
.class("gate-note__link"),
.href(signIn)
) {
"Sign in or join with a passkey"
if viewer == nil {
p(.class("gate-note")) {
Icon(.lock, size: 16)
span {
"Buying, offers, saving and messages need a signed-in account. "
a(
.class("gate-note__link"),
.href(.Route.signIn(returningTo: listing.path))
) {
"Sign in or join with a passkey"
}
" — browsing stays open to everyone."
}
" — browsing stays open to everyone."
}
}
}
@@ -302,9 +317,13 @@ private extension ListingPage {
}
}
/// The sign-in screen, returning to this listing.
var signIn: String {
.Route.signIn(returningTo: listing.path)
// MARK: Methods
/// The path a gated action on this listing leads to.
func gated(
_ path: String
) -> String {
.Route.gated(path, from: listing.path, viewer: viewer)
}
}
@@ -30,6 +30,9 @@ struct SearchPage {
/// The number of shops the matching listings come from.
let shopCount: Int
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the search results screen for a search and the listings matching it.
@@ -37,15 +40,18 @@ struct SearchPage {
/// - parameters: the search the screen answers.
/// - matches: every listing matching the search; the screen shows the page the parameters ask for.
/// - publishers: every publisher a listing can be filtered by.
/// - 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(
parameters: SearchParameters,
matches: [Listing],
publishers: [String],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.viewer = viewer
let slice = Pagination.slice(
matches,
page: parameters.page,
@@ -82,7 +88,8 @@ extension SearchPage: Page {
var content: some HTML {
SiteLayout(
active: formats.count == 1 ? formats.first : nil,
query: parameters.value(.query)
query: parameters.value(.query),
viewer: viewer
) {
div(.class("page search")) {
div(.class("search__head")) {
@@ -27,6 +27,9 @@ struct ShopPage {
/// The shop shown.
let shop: Shop
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the public screen of a shop.
@@ -35,6 +38,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.
/// - 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(
@@ -42,9 +46,11 @@ struct ShopPage {
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.viewer = viewer
let slice = Pagination.slice(
matches,
page: parameters.page,
@@ -78,7 +84,7 @@ extension ShopPage: Page {
// MARK: Computed
var content: some HTML {
SiteLayout {
SiteLayout(viewer: viewer) {
div(.class("page shop")) {
section(.class("shop__head")) {
span(.class("monogram monogram--xl")) {
@@ -116,20 +122,24 @@ extension ShopPage: Page {
div(.class("shop__actions")) {
a(
.class("button button--primary"),
.href(signIn)
.href(.Route.gated(.Route.message(shop: shop.slug), from: shop.path, viewer: viewer))
) {
Icon(.message, size: 16)
"Message shop"
}
a(
.class("button button--outline"),
.href(signIn)
) {
"Follow shop"
}
span(.class("type-small shop__gate")) {
Icon(.key, size: 13)
"Both need a passkey sign-in"
if viewer == nil {
a(
.class("button button--outline"),
.href(.Route.signIn(returningTo: shop.path))
) {
"Follow shop"
}
span(.class("type-small shop__gate")) {
Icon(.key, size: 13)
"Both need a passkey sign-in"
}
} else {
Unavailable("Follow shop", modifier: "button--outline")
}
}
}
@@ -286,11 +296,6 @@ private extension ShopPage {
// MARK: Computed
/// The sign-in screen, returning to this shop.
var signIn: String {
.Route.signIn(returningTo: shop.path)
}
var stats: [(value: String, label: String)] {
[
("\(counts.values.reduce(0, +))", "listings"),
@@ -0,0 +1,181 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
///
/// The prototype stubs the passkey ceremony — see ``SessionController``.
struct SignInPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// What went wrong with the last attempt, or `nil` when nothing did.
let error: String?
/// What the visitor was about to do, or `nil` when they came here directly.
let intent: String?
/// The path the visitor returns to, signed in or not.
let returnPath: String
// MARK: Initializers
/// Creates the sign-in screen.
/// - Parameters:
/// - returnPath: the path the visitor returns to, signed in or not.
/// - intent: what the visitor was about to do, or `nil` (the default) when they came here directly.
/// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did.
/// - 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(
returnPath: String,
intent: String? = nil,
error: String? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.error = error
self.intent = intent
self.locale = .init(identifier: "en")
self.returnPath = returnPath
}
}
// MARK: - Page
extension SignInPage: Page {
// MARK: Computed
var content: some HTML {
div(.class("gate")) {
header(.class("gate__bar")) {
a(
.class("gate__close"),
.href(returnPath),
.custom(name: "aria-label", value: "Close and go back")
) {
Icon(.close)
}
span(.class("gate__wordmark")) {
"CCN"
}
}
main(.class("gate__main")) {
div(.class("gate__intro")) {
if let intent {
p(.class("type-eyebrow")) {
intent
}
}
h1(.class("type-h1")) {
"Sign in or join"
}
p(.class("gate__lead")) {
"No password. Your device confirms it's you."
}
}
form(
.class("gate__form"),
.action(.Route.signIn),
.method(.post)
) {
input(
.type(.hidden),
.name("return"),
.value(returnPath)
)
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
) {
Icon(.key, size: 18)
"Continue with passkey"
}
div(.class("field")) {
label(
.class("field__label"),
.for("email")
) {
"New here, or on a new device? Enter your email"
}
input(
.class("input input--lg"),
.id("email"),
.type(.email),
.name("email"),
.placeholder("you@example.com"),
.custom(name: "autocomplete", value: "username webauthn")
)
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "email-error"),
when: error != nil
)
if let error {
p(
.class("field__error"),
.id("email-error")
) {
error
}
}
div(.class("gate__saved")) {
Icon(.key, size: 16)
span {
strong {
AccountRepository.demoEmail
}
span(.class("gate__saved-note")) {
" · saved passkey"
}
}
}
p(.class("field__hint")) {
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
}
}
}
p(.class("notice notice--warn")) {
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it."
}
p(.class("gate__recover")) {
a(.href(.Route.recover)) {
"Lost access to your passkeys?"
}
}
}
}
}
/// Kept out of search results: the screen is a step, not a destination.
@HTMLBuilder
var metadata: some HTML {
meta(
.name("robots"),
.content("noindex")
)
siteMetadata
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.signIn]
}
var title: String {
"Sign in or join — CCN"
}
}
@@ -64,6 +64,7 @@ extension StaticFile {
.search,
.shared,
.shop,
.signIn,
.site,
.sitemap,
]
@@ -149,6 +150,9 @@ extension StaticFile {
/// The `css/shop.css` stylesheet for the shop page.
static let shop = Self("shop", as: .css)
/// The `css/sign-in.css` stylesheet for the sign-in page.
static let signIn = Self("sign-in", as: .css)
/// The `site.webmanifest` web application manifest.
static let site = Self("site", as: .webmanifest)
@@ -1,3 +1,5 @@
import Persistence
/// The signed-in person a page is rendered for.
struct Viewer: Sendable {
@@ -35,8 +37,26 @@ struct Viewer: Sendable {
extension Viewer {
// MARK: Initializers
/// Creates the viewer an account signs in as.
/// - Parameter user: the signed-in account.
init(
_ user: User
) {
self.init(displayName: user.displayName)
}
// MARK: Computed
/// The first word of the display name, shown beside the avatar.
var firstName: String {
displayName
.split(separator: " ")
.first
.map(String.init) ?? displayName
}
/// The uppercased initials of the first two words of the display name.
var initials: String {
displayName