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

This commit is contained in:
javier committed 2026-09-21 11:16:52 +02:00
1 parent 6d7ab4bcba
commit ea89ea892a
37 files changed
+4173 -43

No files matched your search

@@ -0,0 +1,172 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the public catalogue: the search results, a listing's detail, and a seller's shop.
///
/// Unlike the landing page, these screens depend on the request, so each is rendered per request rather than cached.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct CatalogueController<Context: RequestContext> {
// MARK: Properties
/// The analytics tracker the pages embed, or `nil` to omit it.
private let analytics: Analytics?
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
private let assetVersion: String?
/// The repository the shops and listings are read through.
private let repository: CatalogueRepository
// MARK: Initializers
/// Creates a catalogue controller.
/// - Parameters:
/// - repository: the repository the shops and listings are read through.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
repository: CatalogueRepository,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.repository = repository
}
}
// MARK: - RouterController
extension CatalogueController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(
.Catalogue.search,
use: search
)
routes.get(
.Catalogue.listing,
use: listing
)
routes.get(
.Catalogue.shop,
use: shop
)
return routes
}
}
// MARK: - Helpers
private extension CatalogueController {
// MARK: Constants
/// The number of other listings shown under a listing: one line of the desktop grid.
static var relatedCount: Int { 5 }
// MARK: Methods
/// Handles a request for a listing's detail; an unknown slug is not found.
@Sendable
func listing(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let listing = try await repository.listing(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let related = try await repository
.listings(matching: .init(shop: listing.shop.slug))
.filter { $0.slug != listing.slug }
.prefix(Self.relatedCount)
return .init { [analytics, assetVersion] in
ListingPage(
listing: listing,
related: Array(related),
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for the search results, filtered through the query parameters.
@Sendable
func search(
request: Request,
context: Context
) async throws -> HTMLResponse {
let parameters = SearchParameters(query: request.uri.query)
let matches = try await repository.listings(matching: parameters.filter)
let publishers = try await repository.publishers()
return .init { [analytics, assetVersion] in
SearchPage(
parameters: parameters,
matches: matches,
publishers: publishers,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for a seller's shop; an unknown slug is not found.
@Sendable
func shop(
request: Request,
context: Context
) async throws -> HTMLResponse {
guard let shop = try await repository.shop(slug: context.parameters.require(.slug)) else {
throw HTTPError(.notFound)
}
let parameters = SearchParameters(query: request.uri.query)
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
return .init { [analytics, assetVersion] in
ShopPage(
shop: shop,
inventory: inventory,
matches: matches,
parameters: parameters,
assetVersion: assetVersion,
analytics: analytics
)
}
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``CatalogueController`` route paths.
enum Catalogue {
/// The path of a listing's detail.
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
/// The path of the search results.
static let search: RouterPath = .init(.Route.search)
/// The path of a seller's shop.
static let shop: RouterPath = .init(.Route.shop(":\(String.slug)"))
}
}
private extension String {
/// The name of the route parameter carrying a listing's or a shop's slug.
static let slug = "slug"
}
@@ -31,10 +31,12 @@ public struct RootController<Context: RequestContext> {
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - 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 the landing page embeds, or `nil` (the default) to omit it.
/// - listings: the listings the landing page shows in its rows; none by default.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
listings: HomeListings = .init()
) {
self.negotiate = .init(bundle: .module)
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
@@ -47,7 +49,8 @@ public struct RootController<Context: RequestContext> {
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
analytics: analytics,
listings: listings
)
}
}
@@ -0,0 +1,38 @@
import Persistence
/// The listings the landing page shows, fetched once at startup: the page is rendered once and cached.
public struct HomeListings: Sendable {
// MARK: Constants
/// The number of listings in a row: one line of the desktop grid.
public static let rowSize = 5
// MARK: Properties
/// The newest manga, trade paperbacks, and hardcovers.
public let collected: [Listing]
/// The newest single issues.
public let singleIssues: [Listing]
// MARK: Initializers
/// Picks the landing page's rows out of a list of listings.
/// - Parameter listings: the listings to pick from, newest first; none by default.
public init(
from listings: [Listing] = []
) {
self.collected = Array(
listings
.filter { $0.kind != .singleIssues }
.prefix(Self.rowSize)
)
self.singleIssues = Array(
listings
.filter { $0.kind == .singleIssues }
.prefix(Self.rowSize)
)
}
}