Implemented phase 2 for the POC prototype in the Website service.
This commit is contained in:
1 parent
09c7b8a1a7
commit
43dcc03da6
28 files changed
+1468
-65
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user