2026-08-19 22:55:00 +02:00
import Elementary
import Foundation
import Infrastructure
import Localization
2026-09-21 11:16:52 +02:00
import Persistence
2026-08-19 22:55:00 +02:00
/// The website's landing page, with its text localized to a given locale.
struct IndexPage {
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
let analytics : Analytics ?
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion : String ?
2026-09-21 11:16:52 +02:00
/// The listings the page shows in its rows.
let listings : HomeListings
2026-08-19 22:55:00 +02:00
/// The locale the page content is localized to.
let locale : Locale
2026-09-04 13:40:35 +00:00
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
let siteOrigin : String ?
2026-09-21 11:44:58 +02:00
/// The signed-in viewer, or `nil` when signed out.
let viewer : Viewer ?
2026-08-19 22:55:00 +02:00
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize : Localize
// MARK: Initializers
/// Creates a landing page localized to the given locale.
/// - Parameters:
/// - locale: the locale the page content is localized to.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
2026-09-04 13:40:35 +00:00
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
2026-08-19 22:55:00 +02:00
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
2026-09-21 11:16:52 +02:00
/// - listings: the listings the page shows in its rows; none by default.
2026-09-21 11:44:58 +02:00
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
2026-08-19 22:55:00 +02:00
init (
locale : Locale ,
assetVersion : String ? = nil ,
2026-09-04 13:40:35 +00:00
siteOrigin : String ? = nil ,
2026-09-21 11:16:52 +02:00
analytics : Analytics ? = nil ,
2026-09-21 11:44:58 +02:00
listings : HomeListings = . init (),
viewer : Viewer ? = nil
2026-08-19 22:55:00 +02:00
) {
2026-09-21 11:44:58 +02:00
self . viewer = viewer
2026-08-19 22:55:00 +02:00
self . analytics = analytics
self . assetVersion = assetVersion
2026-09-21 11:16:52 +02:00
self . listings = listings
2026-08-19 22:55:00 +02:00
self . locale = locale
2026-09-04 13:40:35 +00:00
self . siteOrigin = siteOrigin
2026-08-19 22:55:00 +02:00
self . localize = . init ( bundle : . module )
}
}
// MARK: - Page
extension IndexPage : Page {
2026-09-04 13:40:35 +00:00
// MARK: Constants
/// The path the page is served at; ``RootController`` registers its route against it, and the page builds its canonical URL from it.
static let path = "/"
// MARK: Computed
/// The canonical URL of the page's edition in its language, or `nil` when the origin is unknown.
///
/// The origin alone for the default language, since canonical URLs carry no trailing slash — `TrailingSlashRedirectMiddleware` enforces
/// that on every path but the root, which has nothing to strip.
var canonicalURL : String ? {
siteOrigin . map {
Language ( of : locale ). url (
origin : $0 ,
path : Self . path
)
}
}
/// The site-wide metadata, preceded by the `hreflang` alternates tying the page's language editions together.
@ HTMLBuilder
var metadata : some HTML {
languageAlternates (
origin : siteOrigin ,
path : Self . path
)
siteMetadata
}
2026-08-19 22:55:00 +02:00
// MARK: Properties
var content : some HTML {
2026-09-21 11:44:58 +02:00
SiteLayout ( viewer : viewer ) {
2026-09-21 11:16:52 +02:00
div (. class ( "page home" )) {
2026-09-21 10:22:55 +02:00
section (. class ( "hero" )) {
2026-09-21 11:16:52 +02:00
div (. class ( "hero__text" )) {
p (. class ( "type-eyebrow" )) {
"Single issues · Manga · Trades · Hardcovers"
2026-09-21 10:22:55 +02:00
}
2026-09-21 11:16:52 +02:00
h1 (. class ( "type-hero" )) {
localize ( "index.heading" , locale : locale )
}
p (. class ( "type-lead" )) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div (. class ( "hero__actions" )) {
a (
. class ( "button button--primary button--lg" ),
. href ( Format . singleIssues . path )
) {
"Browse single issues"
}
a (
. class ( "button button--outline button--lg" ),
. href (. Route . series )
) {
"Browse by series"
}
}
}
if listings . singleIssues . count >= 3 {
div (. class ( "hero__covers" )) {
Cover ( listings . singleIssues [ 1 ]. cover , modifier : "cover--left" )
Cover ( listings . singleIssues [ 0 ]. cover , modifier : "cover--centre" )
Cover ( listings . singleIssues [ 2 ]. cover , modifier : "cover--right" )
}
}
}
nav (
. class ( "formats" ),
. custom ( name : "aria-label" , value : "Browse by format" )
) {
for format in Format . allCases {
2026-09-21 10:22:55 +02:00
a (
2026-09-21 11:16:52 +02:00
. class ( "formats__tile" ),
. href ( format . path )
2026-09-21 10:22:55 +02:00
) {
2026-09-21 11:16:52 +02:00
span (. class ( "formats__name" )) {
format . label
}
span (. class ( "type-caption" )) {
format . summary
}
}
}
}
row (
"New in single issues" ,
more : "All single issues" ,
path : Format . singleIssues . path ,
listings : listings . singleIssues
)
row (
"Manga, trades and hardcovers" ,
more : "All collected editions" ,
path : SearchParameters ( items : Format . allCases . dropFirst (). map {
. init ( name : SearchParameters . Name . format . rawValue , value : $0 . rawValue )
}). path (. Route . search ),
listings : listings . collected
)
section (. class ( "promises" )) {
for promise in Self . promises {
div (. class ( "promises__item" )) {
Icon ( promise . glyph , size : 28 , weight : "1.8" )
h3 (. class ( "promises__title" )) {
promise . title
}
p (. class ( "type-body-sm promises__text" )) {
promise . text
}
2026-09-21 10:22:55 +02:00
}
}
}
}
2026-08-19 22:55:00 +02:00
}
}
var scripts : [ any Asset ] {
[ StaticFile . index , StaticFile . shared ]
}
var stylesheets : [ any Asset ] {
[ StaticFile . shared , StaticFile . index ]
}
var title : String {
localize ( "index.title" , locale : locale )
}
}
2026-09-21 11:16:52 +02:00
// MARK: - Helpers
private extension IndexPage {
// MARK: Constants
/// What the marketplace promises, under the listing rows.
static let promises : [( glyph : Icon . Glyph , title : String , text : String )] = [
(. key , "Passkeys, not passwords" , "Sign up and sign in with Face ID, a fingerprint or a hardware key. Nothing to phish, nothing to reuse, nothing to reset." ),
(. document , "Grade stated up front" , "Every listing declares slab or raw, grader and grade, printing and defects, with photos of the actual copy front and back." ),
(. shop , "Shops are open to everyone" , "Anyone can browse a seller's inventory without an account. Buying, offers and messages need a signed-in account on both sides." ),
]
// MARK: Methods
/// A titled row of listing cards; renders nothing without listings.
@ HTMLBuilder
func row (
_ title : String ,
more : String ,
path : String ,
listings : [ Listing ]
) -> some HTML {
if ! listings . isEmpty {
section (. class ( "home__row" )) {
div (. class ( "section-head" )) {
h2 (. class ( "type-h2" )) {
title
}
a (
. class ( "link-ink" ),
. href ( path )
) {
more
}
}
div (. class ( "card-grid" )) {
for listing in listings {
ListingCard ( listing )
}
}
}
}
}
}