import Elementary import Foundation import Infrastructure import Localization import Persistence /// 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? /// The listings the page shows in its rows. let listings: HomeListings /// The locale the page content is localized to. let locale: Locale /// 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 // 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. /// - 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(), viewer: Viewer? = nil ) { self.viewer = viewer self.analytics = analytics self.assetVersion = assetVersion self.listings = listings self.locale = locale self.siteOrigin = siteOrigin self.localize = .init(bundle: .module) } } // MARK: - Page extension IndexPage: Page { // 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 } // MARK: Properties var content: some HTML { SiteLayout(viewer: viewer) { div(.class("page home")) { section(.class("hero")) { div(.class("hero__text")) { p(.class("type-eyebrow")) { "Single issues · Manga · Trades · Hardcovers" } 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 { a( .class("formats__tile"), .href(format.path) ) { 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 } } } } } } } var scripts: [any Asset] { [StaticFile.index, StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.index] } var title: String { localize("index.title", locale: locale) } } // 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) } } } } } }