1269 lines
61 KiB
Text
1269 lines
61 KiB
Text
|
|
/*
|
|||
|
|
catcrafts.net
|
|||
|
|
Copyright (C) 2026 Catcrafts
|
|||
|
|
|
|||
|
|
The source code of this website is made available for viewing purposes only.
|
|||
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
// The page renderers — the reason Catcrafts.Shared exists.
|
|||
|
|
//
|
|||
|
|
// Each returns SafeHtml for the inner content of <main>, plus the PageMeta the
|
|||
|
|
// document head needs. The wasm app feeds the markup to SetInnerHTML; the
|
|||
|
|
// native server will wrap it in RenderDocument and send it as the response
|
|||
|
|
// body. One template per page, so a crawler and the app can never be looking
|
|||
|
|
// at different markup.
|
|||
|
|
//
|
|||
|
|
// Every renderer is a pure function of its inputs. No I/O, no globals, no DOM.
|
|||
|
|
// That is what makes them testable on the host (see server/implementations)
|
|||
|
|
// rather than only observable in a browser tab.
|
|||
|
|
|
|||
|
|
export module Catcrafts.Shared:Views;
|
|||
|
|
import std;
|
|||
|
|
import :Html;
|
|||
|
|
import :Form;
|
|||
|
|
import :Model;
|
|||
|
|
import :Money;
|
|||
|
|
import :Route;
|
|||
|
|
|
|||
|
|
namespace Catcrafts::Views {
|
|||
|
|
|
|||
|
|
using Html::SafeHtml;
|
|||
|
|
using Html::Escape;
|
|||
|
|
using Html::Format;
|
|||
|
|
using Html::Num;
|
|||
|
|
using Html::Raw;
|
|||
|
|
using Html::Url;
|
|||
|
|
using Html::Attr;
|
|||
|
|
using Html::Join;
|
|||
|
|
|
|||
|
|
export struct RenderedPage {
|
|||
|
|
PageMeta meta;
|
|||
|
|
SafeHtml main;
|
|||
|
|
// 404 for NotFound, 301 for a legacy redirect, 200 otherwise. The wasm app
|
|||
|
|
// ignores this; the server uses it as the response status.
|
|||
|
|
int status = 200;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── chrome ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// Nav is emitted as real <a href> links, not click-only elements.
|
|||
|
|
//
|
|||
|
|
// The previous version used `<a id="blog-nav-button">` with no href, because
|
|||
|
|
// Crafter.Graphics could not cancel a click and any real link would trigger a
|
|||
|
|
// full page load. That made the nav invisible to crawlers, un-middle-clickable
|
|||
|
|
// and unreachable by keyboard. The preventDefault support added to
|
|||
|
|
// AddClickListener is what lets these be honest links that the app upgrades to
|
|||
|
|
// client-side navigation.
|
|||
|
|
export SafeHtml RenderNav(RouteKind current) {
|
|||
|
|
std::vector<SafeHtml> items;
|
|||
|
|
for (const NavItem& item : NavItems()) {
|
|||
|
|
const bool active = item.kind == current;
|
|||
|
|
items.push_back(Format(
|
|||
|
|
R"(<li><a class="nav-link{}"{}{}>{}</a></li>)",
|
|||
|
|
active ? Raw(" nav-link--active") : SafeHtml{},
|
|||
|
|
Url("href", item.href),
|
|||
|
|
// aria-current is how a screen reader conveys "you are here";
|
|||
|
|
// the active class alone is a purely visual signal.
|
|||
|
|
active ? Attr("aria-current", "page") : SafeHtml{},
|
|||
|
|
Escape(item.label)));
|
|||
|
|
}
|
|||
|
|
// The mark: an ASCII cat drawn as paths — ^ ^ caret eyes, and the old ">_"
|
|||
|
|
// prompt kept as the mouth. Inline rather than an <img> so it takes its
|
|||
|
|
// colours from the CSS variables (face = currentColor via .logo__mark,
|
|||
|
|
// prompt = --text) and costs no extra request. The underscore carries
|
|||
|
|
// class="logo-cursor", which styles.css blinks like a real terminal cursor
|
|||
|
|
// (and stops under prefers-reduced-motion).
|
|||
|
|
//
|
|||
|
|
// Same geometry as favicon.svg with hardcoded colours — change one, change
|
|||
|
|
// both, or the header and the tab icon drift apart.
|
|||
|
|
//
|
|||
|
|
// aria-label on the <a> rather than text: on narrow viewports .logo__text
|
|||
|
|
// is display:none, which would leave the link with no accessible name.
|
|||
|
|
return Format(
|
|||
|
|
R"(<div class="nav-container">)"
|
|||
|
|
R"(<a class="logo" aria-label="Catcrafts"{}>)"
|
|||
|
|
R"(<span class="logo__mark" aria-hidden="true">)"
|
|||
|
|
R"(<svg viewBox="0 0 64 64" fill="none">)"
|
|||
|
|
R"(<path d="M9 22 L13 7 L25 15 L39 15 L51 7 L55 22 L55 44 Q55 56 43 56 L21 56 Q9 56 9 44 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
|
|||
|
|
R"(<path d="M17 34 L23 27 L29 34 M35 34 L41 27 L47 34" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)"
|
|||
|
|
// R"x(...)x": stroke="var(--text)" contains the plain-delimiter
|
|||
|
|
// terminator )" and would end the literal mid-attribute.
|
|||
|
|
R"x(<path d="M23 40 L29 45 L23 50" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
|
|||
|
|
R"x(<path class="logo-cursor" d="M34 50 L42 50" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
|
|||
|
|
R"(</svg>)"
|
|||
|
|
R"(</span>)"
|
|||
|
|
R"(<span class="logo__text">Catcrafts</span></a>)"
|
|||
|
|
R"(<nav aria-label="Main"><ul class="nav-list">{}</ul></nav>)"
|
|||
|
|
R"(</div>)",
|
|||
|
|
Url("href", "/"),
|
|||
|
|
Join(items));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Deliberately no link to the fediverse account.
|
|||
|
|
//
|
|||
|
|
// The account is used for plenty that has nothing to do with this work, so
|
|||
|
|
// pointing visitors at the profile would send them somewhere mostly irrelevant.
|
|||
|
|
// The posts page carries the individual posts instead, each linking to its own
|
|||
|
|
// thread — which is the part that is actually about the work.
|
|||
|
|
export SafeHtml RenderFooter() {
|
|||
|
|
return Format(
|
|||
|
|
R"(<div class="footer-content">)"
|
|||
|
|
R"(<p class="footer-tagline">Open-source systems work from the Netherlands. )"
|
|||
|
|
R"(This site is C++23 compiled to WebAssembly.</p>)"
|
|||
|
|
R"(<p class="footer-links">)"
|
|||
|
|
R"(<a{}>Forgejo</a><a{}>Source</a>)"
|
|||
|
|
R"(</p>)"
|
|||
|
|
R"(<p class="footer-links">)"
|
|||
|
|
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint & contact</a>)"
|
|||
|
|
R"(</p>)"
|
|||
|
|
R"(<p class="footer-legal">© 2026 Catcrafts®. Crafter® and Catcrafts® )"
|
|||
|
|
R"(are registered trademarks with the EUIPO.</p>)"
|
|||
|
|
R"(</div>)",
|
|||
|
|
Url("href", "https://forgejo.catcrafts.net/Catcrafts/"),
|
|||
|
|
Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"),
|
|||
|
|
Url("href", "/legal/privacy"),
|
|||
|
|
Url("href", "/legal/terms"),
|
|||
|
|
Url("href", "/legal/imprint"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── home ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export RenderedPage RenderHome(std::span<const Project> projects,
|
|||
|
|
std::span<const Post> posts) {
|
|||
|
|
std::vector<SafeHtml> cards;
|
|||
|
|
for (const Project& p : projects) {
|
|||
|
|
if (!p.featured) continue;
|
|||
|
|
cards.push_back(Format(
|
|||
|
|
R"(<article class="mini-card">)"
|
|||
|
|
R"(<h3 class="mini-card__title"><a{}>{}</a></h3>)"
|
|||
|
|
R"(<p class="mini-card__blurb">{}</p>)"
|
|||
|
|
R"(</article>)",
|
|||
|
|
Url("href", p.url), Escape(p.name), Escape(p.blurb)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
std::vector<SafeHtml> recent;
|
|||
|
|
for (std::size_t i = 0; i < posts.size() && i < 3; ++i) {
|
|||
|
|
const Post& post = posts[i];
|
|||
|
|
recent.push_back(Format(
|
|||
|
|
R"(<li class="recent__item"><a{}>{}</a><span class="recent__date">{}</span></li>)",
|
|||
|
|
Url("href", post.permalink),
|
|||
|
|
Escape(post.title),
|
|||
|
|
Escape(DateOnly(post.published))));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SafeHtml main = Format(
|
|||
|
|
R"(<section class="hero">)"
|
|||
|
|
// No visible headline on purpose: a hero slogan reads as marketing,
|
|||
|
|
// which is not the register this site wants. The h1 stays for the
|
|||
|
|
// document outline (screen readers, crawlers) but is visually hidden;
|
|||
|
|
// the mission text below is the first thing anyone sees.
|
|||
|
|
R"(<h1 class="visually-hidden">Catcrafts</h1>)"
|
|||
|
|
R"(<p class="hero__lede">Catcrafts makes accessible, open-source software )"
|
|||
|
|
R"(and hardware for the benefit of everyone. The goal: a real alternative )"
|
|||
|
|
R"(to big tech that anyone can use, not just Linux nerds.</p>)"
|
|||
|
|
R"(<p class="hero__actions">)"
|
|||
|
|
R"(<a class="btn btn--primary"{}>Browse projects</a>)"
|
|||
|
|
R"(<a class="btn"{}>Browse shop</a>)"
|
|||
|
|
R"(</p>)"
|
|||
|
|
R"(</section>)"
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Featured work</h2>)"
|
|||
|
|
R"(<div class="mini-grid">{}</div>)"
|
|||
|
|
R"(</section>)"
|
|||
|
|
R"({})",
|
|||
|
|
Url("href", "/projects"),
|
|||
|
|
Url("href", "/shop"),
|
|||
|
|
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
|
|||
|
|
recent.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Recent posts</h2>)"
|
|||
|
|
R"(<ul class="recent">{}</ul>)"
|
|||
|
|
R"(<p><a class="link-more"{}>All posts</a></p>)"
|
|||
|
|
R"(</section>)",
|
|||
|
|
Join(recent), Url("href", "/posts")));
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Catcrafts — open-source software and hardware";
|
|||
|
|
page.meta.description =
|
|||
|
|
"Catcrafts makes accessible, open-source software and hardware: an "
|
|||
|
|
"alternative to big tech. A C++23 engine, build system and networking "
|
|||
|
|
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
|
|||
|
|
page.meta.canonical = "/";
|
|||
|
|
page.main = std::move(main);
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── projects ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// A high-level overview on purpose: name, one sentence, a link. Each project's
|
|||
|
|
// Forgejo page is the canonical source for status, docs and detail — repeating
|
|||
|
|
// any of that here just gives it a second place to go stale.
|
|||
|
|
export RenderedPage RenderProjects(std::span<const Project> projects) {
|
|||
|
|
std::vector<SafeHtml> cards;
|
|||
|
|
for (const Project& p : projects) {
|
|||
|
|
cards.push_back(Format(
|
|||
|
|
R"(<article class="project-card">)"
|
|||
|
|
R"(<h2 class="project-card__title"><a{}>{}</a></h2>)"
|
|||
|
|
R"(<p class="project-card__blurb">{}</p>)"
|
|||
|
|
R"(<span class="project-card__lang">{}</span>)"
|
|||
|
|
R"(</article>)",
|
|||
|
|
Url("href", p.url),
|
|||
|
|
Escape(p.name),
|
|||
|
|
Escape(p.blurb),
|
|||
|
|
Escape(p.language)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Projects — Catcrafts";
|
|||
|
|
page.meta.description =
|
|||
|
|
"The Crafter suite: a C++23 build system, graphics engine, networking "
|
|||
|
|
"stack, and an IMS/VoLTE implementation for the Fairphone 6.";
|
|||
|
|
page.meta.canonical = "/projects";
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Projects</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">The short version of each. )"
|
|||
|
|
R"(Docs, status and source live on <a{}>Forgejo</a>; )"
|
|||
|
|
R"(every card links to its repository.</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="project-grid">{}</div>)",
|
|||
|
|
Url("href", "https://forgejo.catcrafts.net/Catcrafts/"),
|
|||
|
|
cards.empty() ? Raw(R"(<p class="empty">Project list unavailable.</p>)") : Join(cards));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── posts ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// The media a post carries — usually the point of the post rather than
|
|||
|
|
// decoration, since these are screen recordings of the work.
|
|||
|
|
//
|
|||
|
|
// Served from our own origin (tools/fetch-media.sh mirrors it), which is what
|
|||
|
|
// keeps the privacy notice's "everything comes from catcrafts.net" true and
|
|||
|
|
// stops every visitor's IP reaching whichever instance hosted the file.
|
|||
|
|
//
|
|||
|
|
// Video is preload="metadata", not "auto": a page with several 5 MB recordings
|
|||
|
|
// must not pull them all on load. Dimensions come from the content file so the
|
|||
|
|
// browser reserves the right box and nothing jumps as files arrive.
|
|||
|
|
SafeHtml RenderPostMedia(std::span<const PostMedia> media) {
|
|||
|
|
if (media.empty()) return SafeHtml{};
|
|||
|
|
std::vector<SafeHtml> items;
|
|||
|
|
for (const PostMedia& m : media) {
|
|||
|
|
SafeHtml dims = (m.width > 0 && m.height > 0)
|
|||
|
|
? Format("{}{}", Attr("width", std::to_string(m.width)),
|
|||
|
|
Attr("height", std::to_string(m.height)))
|
|||
|
|
: SafeHtml{};
|
|||
|
|
if (m.kind == "video") {
|
|||
|
|
SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster);
|
|||
|
|
items.push_back(Format(
|
|||
|
|
R"(<video class="post-media__item" controls preload="metadata" )"
|
|||
|
|
R"(playsinline{}{}{}></video>)",
|
|||
|
|
Url("src", m.src), poster, dims));
|
|||
|
|
} else {
|
|||
|
|
// alt is empty and aria-hidden is absent on purpose: these are
|
|||
|
|
// screenshots whose meaning is already in the post title and
|
|||
|
|
// excerpt, and inventing descriptive alt text here would be making
|
|||
|
|
// up what the picture shows.
|
|||
|
|
items.push_back(Format(
|
|||
|
|
R"(<img class="post-media__item" loading="lazy" decoding="async" )"
|
|||
|
|
R"(alt=""{}{}>)",
|
|||
|
|
Url("src", m.src), dims));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Format(R"(<div class="post-media">{}</div>)", Join(items));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export RenderedPage RenderPosts(std::span<const Post> posts) {
|
|||
|
|
std::vector<SafeHtml> cards;
|
|||
|
|
for (const Post& p : posts) {
|
|||
|
|
// A link post gets a second, clearly-labelled outbound link. Without
|
|||
|
|
// the label the two links are indistinguishable and one of them
|
|||
|
|
// silently leaves the site.
|
|||
|
|
SafeHtml linkRow = p.linkUrl.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<p class="post-card__link"><a{} rel="nofollow noopener">{}</a></p>)",
|
|||
|
|
Url("href", p.linkUrl), Escape(p.linkUrl));
|
|||
|
|
|
|||
|
|
cards.push_back(Format(
|
|||
|
|
R"(<article class="post-card">)"
|
|||
|
|
R"(<header class="post-card__header">)"
|
|||
|
|
R"(<h2 class="post-card__title"><a{} rel="noopener">{}</a></h2>)"
|
|||
|
|
R"(<p class="post-card__meta">)"
|
|||
|
|
R"(<time{}>{}</time><span class="post-card__community">{}</span>)"
|
|||
|
|
R"(</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<footer class="post-card__footer">)"
|
|||
|
|
R"(<span class="stat">{} points</span>)"
|
|||
|
|
R"(<a class="stat stat--link"{} rel="noopener">Discuss on the fediverse ({} comments) →</a>)"
|
|||
|
|
R"(</footer>)"
|
|||
|
|
R"(</article>)",
|
|||
|
|
Url("href", p.permalink), Escape(p.title),
|
|||
|
|
Attr("datetime", p.published), Escape(DateOnly(p.published)),
|
|||
|
|
Escape(p.community),
|
|||
|
|
p.excerpt.empty() ? SafeHtml{}
|
|||
|
|
: Format(R"(<p class="post-card__excerpt">{}</p>)", Escape(p.excerpt)),
|
|||
|
|
RenderPostMedia(p.media),
|
|||
|
|
linkRow,
|
|||
|
|
Num(p.score),
|
|||
|
|
Url("href", p.permalink), Num(p.comments)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Posts — Catcrafts";
|
|||
|
|
page.meta.description = "Posts from the fediverse; discussion happens there.";
|
|||
|
|
page.meta.canonical = "/posts";
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Posts</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">Catcrafts posts on the fediverse rather than keeping a blog here. )"
|
|||
|
|
R"(Each of these links to the original thread on whichever instance it lives on. )"
|
|||
|
|
R"(Follow one to read it and join the discussion there.</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="post-list">{}</div>)",
|
|||
|
|
cards.empty()
|
|||
|
|
? Raw(R"(<p class="empty">No posts loaded right now.</p>)")
|
|||
|
|
: Join(cards));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── shop ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// The product page's price block: the same single headline number as the shop
|
|||
|
|
// card (so a Canadian sees ~CA$776 at the top here too), plus one supporting
|
|||
|
|
// line per region. Both lines are always in the markup — which one shows is
|
|||
|
|
// CSS driven by the cc-eu/cc-noneu classes; without JS both show, which is
|
|||
|
|
// complete and honest. The HTML stays identical for every visitor.
|
|||
|
|
//
|
|||
|
|
// "21% VAT", not "21% EU VAT": VAT rates are per member state, and this is
|
|||
|
|
// the Dutch rate charged under the OSS distance-selling threshold. The terms
|
|||
|
|
// page spells out "Dutch VAT"; the price label stays plain.
|
|||
|
|
SafeHtml PriceSingle(const Product& p, const Rates& rates);
|
|||
|
|
|
|||
|
|
SafeHtml RenderPriceLine(const Product& p, const Rates& rates) {
|
|||
|
|
const std::int64_t net = Money::NetFromGross(p.priceInclMinor);
|
|||
|
|
return Format(
|
|||
|
|
R"(<p class="price price--product">)"
|
|||
|
|
R"({}{})"
|
|||
|
|
R"(</p>)",
|
|||
|
|
p.variants.size() > 1
|
|||
|
|
? Raw(R"(<span class="price__from">from</span> )") : SafeHtml{},
|
|||
|
|
PriceSingle(p, rates),
|
|||
|
|
Escape(Money::FormatEuro(net)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The shop card's price: ONE easy number. The default text is the euro price
|
|||
|
|
// (what no-JS visitors and crawlers read); every supported currency rides
|
|||
|
|
// along as a pre-formatted data attribute, and the timezone hint script swaps
|
|||
|
|
// the text to the visitor's own. "~" marks a converted amount as approximate.
|
|||
|
|
//
|
|||
|
|
// The conversion basis differs by membership, not by currency whim: an EU
|
|||
|
|
// member's currency (SEK, PLN, …) converts the VAT-inclusive price the buyer
|
|||
|
|
// will actually pay; a non-EU currency converts the ex-VAT export price. All
|
|||
|
|
// arithmetic happens HERE, server-side, from the same integers the checkout
|
|||
|
|
// charges — the script only ever picks a string.
|
|||
|
|
SafeHtml PriceSingle(const Product& p, const Rates& rates) {
|
|||
|
|
const std::int64_t net = Money::NetFromGross(p.priceInclMinor);
|
|||
|
|
std::vector<SafeHtml> attrs;
|
|||
|
|
attrs.push_back(Attr("data-world", Money::FormatEuro(net)));
|
|||
|
|
for (const Money::CurrencyRow& row : Money::AllCurrencies()) {
|
|||
|
|
const std::int64_t rate = rates.Find(row.cur.code);
|
|||
|
|
if (rate <= 0) continue;
|
|||
|
|
const std::int64_t base = Money::IsEuCountry(row.cc) ? p.priceInclMinor : net;
|
|||
|
|
std::string name = "data-";
|
|||
|
|
for (const char c : row.cur.code) name += static_cast<char>(c - 'A' + 'a');
|
|||
|
|
attrs.push_back(Attr(name, std::format(
|
|||
|
|
"~{}{}", row.cur.symbol, Money::ConvertIndicative(base, rate))));
|
|||
|
|
}
|
|||
|
|
return Format(
|
|||
|
|
R"(<span class="price__single"{}>{}</span>)",
|
|||
|
|
Join(attrs), Escape(Money::FormatEuro(p.priceInclMinor)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SafeHtml RenderCardPrice(const Product& p, const Rates& rates) {
|
|||
|
|
// With colour variants the headline is the cheapest ("from") price; the
|
|||
|
|
// exact figure per colour lives in the selector and the live total.
|
|||
|
|
return Format(R"(<p class="price price--card">{}{}</p>)",
|
|||
|
|
p.variants.size() > 1
|
|||
|
|
? Raw(R"(<span class="price__from">from</span> )") : SafeHtml{},
|
|||
|
|
PriceSingle(p, rates));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export RenderedPage RenderShop(std::span<const Product> products, const Rates& rates) {
|
|||
|
|
std::vector<SafeHtml> cards;
|
|||
|
|
for (const Product& p : products) {
|
|||
|
|
// width/height matching the committed image keep the card from
|
|||
|
|
// reflowing as the photo arrives.
|
|||
|
|
SafeHtml thumb = p.image.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<a class="product-card__media"{} tabindex="-1" aria-hidden="true">)"
|
|||
|
|
R"(<img class="product-card__img" loading="lazy" decoding="async" alt=""{}></a>)",
|
|||
|
|
Url("href", "/shop/" + p.slug), Url("src", p.image));
|
|||
|
|
cards.push_back(Format(
|
|||
|
|
R"(<article class="product-card">)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<div class="product-card__body">)"
|
|||
|
|
R"(<h2 class="product-card__title"><a{}>{}</a></h2>)"
|
|||
|
|
R"(<p class="product-card__tagline">{}</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(</article>)",
|
|||
|
|
thumb,
|
|||
|
|
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
|
|||
|
|
p.Buyable() ? RenderCardPrice(p, rates)
|
|||
|
|
: p.ComingSoon()
|
|||
|
|
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
|
|||
|
|
RenderCardPrice(p, rates))
|
|||
|
|
: Raw(R"(<p class="product-card__status"><span class="badge badge--experiment">temporarily unavailable</span></p>)")));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Shop — Catcrafts";
|
|||
|
|
page.meta.description =
|
|||
|
|
"Fairphone 6 handsets reflashed to run postmarketOS, with the IMS/VoLTE "
|
|||
|
|
"stack Catcrafts maintains.";
|
|||
|
|
page.meta.canonical = "/shop";
|
|||
|
|
page.meta.geoPriceHint = true;
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Shop</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">Hardware that runs the software from the )"
|
|||
|
|
R"(projects page. Assembled to order and flashed. Please allow up to a )"
|
|||
|
|
R"(week before dispatch. Payment is handled by Mollie.)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="product-grid">{}</div>)",
|
|||
|
|
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The money terms, stated wherever prices invite a decision, not only in the
|
|||
|
|
// legal pages. Two claims that must be unambiguous: converted prices are
|
|||
|
|
// indicative and the charge is euros; and import costs are entirely between
|
|||
|
|
// the buyer, the courier and their government — Catcrafts neither collects
|
|||
|
|
// nor answers for them. Shared by the live checkout form and the coming-soon
|
|||
|
|
// panel so the two can never state different terms.
|
|||
|
|
SafeHtml CustomsNote() {
|
|||
|
|
return Raw(
|
|||
|
|
R"(<p class="checkout__customs">Regional prices shown in non euro currency is )"
|
|||
|
|
R"(indicative only. The final amount charged is in euros, your bank may charge conversion fees. )"
|
|||
|
|
R"(For delivery outside the EU, the price excludes import duty, )"
|
|||
|
|
R"(import VAT or tariffs. Those are )"
|
|||
|
|
R"(charged on arrival and are solely a matter between you, the courier and )"
|
|||
|
|
R"(your customs authority. Catcrafts does not collect them, cannot )"
|
|||
|
|
R"(estimate them bindingly, and is not a party to them.</p>)");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The checkout form.
|
|||
|
|
//
|
|||
|
|
// A real <form method="post">, not a JavaScript submit handler. It works with
|
|||
|
|
// the wasm module absent, blocked or still downloading — which matters more here
|
|||
|
|
// than anywhere else on the site, because a silent failure here loses a sale.
|
|||
|
|
//
|
|||
|
|
// What submitting does — and does not — commit the buyer to is stated right
|
|||
|
|
// above the button: an order is created and a payment link shown; nothing is
|
|||
|
|
// owed until it is actually paid, and an unpaid order simply lapses.
|
|||
|
|
//
|
|||
|
|
// `errors` re-renders the form with the previous values preserved. Losing a
|
|||
|
|
// filled-in form on a validation error is the fastest way to lose the person.
|
|||
|
|
// `liveShipping` is the carrier rate table (country -> cents) when the server
|
|||
|
|
// has one; empty otherwise. It feeds the data-cc blob below so the on-page
|
|||
|
|
// total preview uses the exact numbers checkout will charge.
|
|||
|
|
SafeHtml RenderCheckoutForm(const Product& product,
|
|||
|
|
std::span<const std::pair<std::string, std::int64_t>> liveShipping,
|
|||
|
|
std::span<const Form::FieldError> errors,
|
|||
|
|
const Form::Checkout& prev) {
|
|||
|
|
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
|||
|
|
for (const Form::FieldError& e : errors) {
|
|||
|
|
if (e.field == field) {
|
|||
|
|
return Format(R"(<p class="field__error">{}</p>)", Escape(e.message));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return SafeHtml{};
|
|||
|
|
};
|
|||
|
|
// Errors with no field name are form-level (the honeypot, for instance).
|
|||
|
|
SafeHtml formError;
|
|||
|
|
for (const Form::FieldError& e : errors) {
|
|||
|
|
if (e.field.empty()) {
|
|||
|
|
formError = Format(R"(<p class="notice notice--error">{}</p>)", Escape(e.message));
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The variant selector. Option labels carry the exact price so the choice
|
|||
|
|
// is priced before anything is computed. Default = cheapest = the page's
|
|||
|
|
// advertised "from" price.
|
|||
|
|
const Variant* selected = product.FindVariant(prev.color);
|
|||
|
|
if (!selected) selected = product.CheapestVariant();
|
|||
|
|
std::vector<SafeHtml> colorOpts;
|
|||
|
|
for (const Variant& v : product.variants) {
|
|||
|
|
colorOpts.push_back(Format(
|
|||
|
|
R"(<option{}{}>{} — {}</option>)",
|
|||
|
|
Attr("value", v.slug),
|
|||
|
|
(selected && selected->slug == v.slug) ? Raw(" selected") : SafeHtml{},
|
|||
|
|
Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor))));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Everything the total preview may show, pre-computed server-side into one
|
|||
|
|
// JSON attribute: per-colour unit prices, zone rates, the live carrier
|
|||
|
|
// table. The script multiplies and adds — it invents no number, so the
|
|||
|
|
// preview and the charge come from the same integers.
|
|||
|
|
std::string cc = R"({"v":{)";
|
|||
|
|
for (std::size_t i = 0; i < product.variants.size(); ++i) {
|
|||
|
|
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
|||
|
|
product.variants[i].slug, product.variants[i].priceInclMinor);
|
|||
|
|
}
|
|||
|
|
cc += std::format(R"(}},"from":{},"s":{{"nl":{},"eu":{},"w":{}}},"c":{{)",
|
|||
|
|
product.priceInclMinor, product.shipNlMinor,
|
|||
|
|
product.shipEuMinor, product.shipWorldMinor);
|
|||
|
|
for (std::size_t i = 0; i < liveShipping.size(); ++i) {
|
|||
|
|
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
|||
|
|
liveShipping[i].first, liveShipping[i].second);
|
|||
|
|
}
|
|||
|
|
cc += std::format(R"(}},"q":{}}})", Form::kMaxQuantity);
|
|||
|
|
|
|||
|
|
return Format(
|
|||
|
|
R"(<section class="checkout" id="buy">)"
|
|||
|
|
R"(<h2 class="section__title">Buy one</h2>)"
|
|||
|
|
R"(<p class="checkout__lede">Submitting creates the order and takes you )"
|
|||
|
|
R"(straight to the payment page: iDEAL, card, or a plain bank transfer, )"
|
|||
|
|
R"(handled by Mollie. Nothing is owed until you actually pay; an unpaid order )"
|
|||
|
|
R"(just lapses. The address is used to ship this order and for the invoice, )"
|
|||
|
|
R"(and for nothing else.</p>)"
|
|||
|
|
// No static rate table: real shipping is priced per country from the
|
|||
|
|
// carrier data and shown live in the total below once a country is
|
|||
|
|
// entered. A three-zone summary next to exact rates was misinformation.
|
|||
|
|
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
|
|||
|
|
R"(rates. Enter your country below and the exact total appears before )"
|
|||
|
|
R"(you order.</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<form class="form" method="post"{}{} novalidate>)"
|
|||
|
|
R"(<input type="hidden" name="product"{}>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-color">Colour</label>)"
|
|||
|
|
R"(<select id="f-color" name="color">{}</select>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
// A free number input, not a dropdown: bulk orders are welcome. The
|
|||
|
|
// min/max mirror the server's validation bounds; kMaxQuantity is a
|
|||
|
|
// technical ceiling, not a sales policy.
|
|||
|
|
R"(<label for="f-qty">Quantity</label>)"
|
|||
|
|
R"(<input id="f-qty" name="quantity" type="number" inputmode="numeric" )"
|
|||
|
|
R"(min="1"{}{}>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-email">Email <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-email" name="email" type="email" autocomplete="email" required{}>)"
|
|||
|
|
R"(<p class="field__hint">Order updates go here. No newsletter exists.</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-name">Recipient name <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-name" name="name" type="text" autocomplete="name" maxlength="120" required{}>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-street">Street and number <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-street" name="street" type="text" autocomplete="street-address" maxlength="200" required{}>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-postal">Postal code <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-postal" name="postal" type="text" autocomplete="postal-code" maxlength="20" required{}>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-city">City <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-city" name="city" type="text" autocomplete="address-level2" maxlength="120" required{}>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
R"(<div class="field">)"
|
|||
|
|
R"(<label for="f-country">Country <span class="field__req">required</span></label>)"
|
|||
|
|
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
|
|||
|
|
R"(maxlength="2" placeholder="NL" required{}>)"
|
|||
|
|
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
|
|||
|
|
R"(price includes VAT.</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</div>)"
|
|||
|
|
// Honeypot: off-screen rather than display:none, because some bots skip
|
|||
|
|
// hidden inputs. aria-hidden + tabindex keeps it away from screen
|
|||
|
|
// readers and the keyboard, so no real person can reach it.
|
|||
|
|
R"(<div class="honeypot" aria-hidden="true">)"
|
|||
|
|
R"(<label for="f-website">Leave this empty</label>)"
|
|||
|
|
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
|
|||
|
|
R"(</div>)"
|
|||
|
|
// Filled by the script once colour, quantity and country are known; the
|
|||
|
|
// exact same total appears on the order page, computed by the server.
|
|||
|
|
// Hidden without JS — the customs note already promises the total is
|
|||
|
|
// stated on the order page before paying.
|
|||
|
|
R"(<p class="checkout__total" id="cc-total" hidden></p>)"
|
|||
|
|
R"(<button class="btn btn--primary" type="submit">Order — continue to payment</button>)"
|
|||
|
|
R"(</form>)"
|
|||
|
|
R"(</section>)",
|
|||
|
|
CustomsNote(),
|
|||
|
|
formError,
|
|||
|
|
Url("action", "/shop/" + product.slug + "#buy"),
|
|||
|
|
Attr("data-cc", cc),
|
|||
|
|
Attr("value", product.slug),
|
|||
|
|
Join(colorOpts), errorFor("color"),
|
|||
|
|
Attr("max", std::to_string(Form::kMaxQuantity)),
|
|||
|
|
Attr("value", std::to_string(prev.quantity)),
|
|||
|
|
errorFor("quantity"),
|
|||
|
|
Attr("value", prev.email), errorFor("email"),
|
|||
|
|
Attr("value", prev.name), errorFor("name"),
|
|||
|
|
Attr("value", prev.street), errorFor("street"),
|
|||
|
|
Attr("value", prev.postal), errorFor("postal"),
|
|||
|
|
Attr("value", prev.city), errorFor("city"),
|
|||
|
|
Attr("value", prev.country), errorFor("country"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export RenderedPage RenderProduct(const Product& product,
|
|||
|
|
const Rates& rates,
|
|||
|
|
std::span<const std::pair<std::string, std::int64_t>> liveShipping = {},
|
|||
|
|
std::span<const Form::FieldError> errors = {},
|
|||
|
|
const Form::Checkout& prev = {}) {
|
|||
|
|
std::vector<SafeHtml> specRows;
|
|||
|
|
for (const Spec& s : product.specs) {
|
|||
|
|
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
|
|||
|
|
Escape(s.label), Escape(s.value)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = product.name + " — Catcrafts";
|
|||
|
|
page.meta.description = product.tagline;
|
|||
|
|
page.meta.canonical = "/shop/" + product.slug;
|
|||
|
|
page.meta.ogType = "product";
|
|||
|
|
page.meta.geoPriceHint = true;
|
|||
|
|
|
|||
|
|
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
|
|||
|
|
Escape(product.name), Url("src", product.image));
|
|||
|
|
|
|||
|
|
// The safety note sits between the summary and the spec sheet — above the
|
|||
|
|
// fold, before any number that might close the sale. A safety caveat as
|
|||
|
|
// small print at the bottom is the pattern this shop exists to not do.
|
|||
|
|
SafeHtml safety = product.safetyNote.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<p class="notice notice--warn product__safety">)"
|
|||
|
|
R"(<strong>Safety warning — emergency calls.</strong> {}</p>)",
|
|||
|
|
Escape(product.safetyNote));
|
|||
|
|
|
|||
|
|
SafeHtml buy;
|
|||
|
|
if (product.Buyable()) {
|
|||
|
|
buy = RenderCheckoutForm(product, liveShipping, errors, prev);
|
|||
|
|
} else if (product.ComingSoon()) {
|
|||
|
|
// The launch prices are already public, per colour, with the same
|
|||
|
|
// money terms the live form will carry. Only the form is held back,
|
|||
|
|
// so opening the shop changes nothing a visitor was already told.
|
|||
|
|
std::vector<SafeHtml> colorRows;
|
|||
|
|
for (const Variant& v : product.variants) {
|
|||
|
|
colorRows.push_back(Format(
|
|||
|
|
R"(<li>{} — {}</li>)",
|
|||
|
|
Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor))));
|
|||
|
|
}
|
|||
|
|
buy = Format(
|
|||
|
|
R"(<section class="checkout" id="buy">)"
|
|||
|
|
R"(<h2 class="section__title">Buy one</h2>)"
|
|||
|
|
R"(<p class="notice">Coming soon. Orders are not open yet; these are )"
|
|||
|
|
R"(the launch prices. Watch the <a{}>posts</a> for the opening.</p>)"
|
|||
|
|
R"(<ul class="checkout__colors">{}</ul>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</section>)",
|
|||
|
|
Url("href", "/posts"), Join(colorRows), CustomsNote());
|
|||
|
|
} else {
|
|||
|
|
buy = Raw(R"(<section class="checkout"><h2 class="section__title">Buy one</h2>)"
|
|||
|
|
R"(<p class="notice">Temporarily unavailable: sourcing or pricing )"
|
|||
|
|
R"(is in flux. Check back, or watch the posts.</p></section>)");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">{}</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">{}</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<p class="product__summary">{}</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Specifications</h2>)"
|
|||
|
|
R"(<p class="section__lede">The hardware is a stock Fairphone 6, )"
|
|||
|
|
R"(unmodified. Fairphone's spec sheet is this product's spec sheet, )"
|
|||
|
|
R"(and all of it works under postmarketOS. The one caveat is the )"
|
|||
|
|
R"(emergency-calling warning above.</p>)"
|
|||
|
|
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
|||
|
|
R"(</section>)"
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Warranty</h2>)"
|
|||
|
|
R"(<p>{}</p>)"
|
|||
|
|
R"(</section>)"
|
|||
|
|
R"({})",
|
|||
|
|
Escape(product.name), Escape(product.tagline),
|
|||
|
|
media, RenderPriceLine(product, rates), Escape(product.summary),
|
|||
|
|
safety, Join(specRows),
|
|||
|
|
Escape(product.warranty),
|
|||
|
|
buy);
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── orders ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// One line of an order's money breakdown.
|
|||
|
|
SafeHtml MoneyRow(std::string_view label, std::int64_t minor) {
|
|||
|
|
return Format(R"(<tr><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
|
|||
|
|
Escape(label), Escape(Money::FormatEuro(minor)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The order status page. The token in the URL is the whole capability: no
|
|||
|
|
// account, no login — bookmark the page. Rendered server-side only, because
|
|||
|
|
// only the server knows the order; the shared fallback below covers the app.
|
|||
|
|
//
|
|||
|
|
// `indicative` is the pre-formatted national-currency approximation ("≈ CA$920
|
|||
|
|
// · ECB rate 2026-08-04"), or empty. It arrives as a string on purpose: the
|
|||
|
|
// renderer should not know where rates come from.
|
|||
|
|
export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indicative) {
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = (o.status == "paid" ? std::string("Order confirmed ")
|
|||
|
|
: std::string("Order "))
|
|||
|
|
+ o.reference + " — Catcrafts";
|
|||
|
|
// Never indexable and never cached: the URL is a capability and the
|
|||
|
|
// content is personal.
|
|||
|
|
page.meta.noindex = true;
|
|||
|
|
|
|||
|
|
const bool awaiting = o.status == "awaiting_payment";
|
|||
|
|
|
|||
|
|
// No badge when paid: the confirmation notice below carries the state,
|
|||
|
|
// and two green "paid" pills stacked read as a rendering bug.
|
|||
|
|
SafeHtml statusLine =
|
|||
|
|
awaiting ? Raw(R"(<span class="badge badge--experiment">awaiting payment</span>)")
|
|||
|
|
: o.status == "paid" ? SafeHtml{}
|
|||
|
|
: o.status == "shipped" ? Raw(R"(<span class="badge badge--active">shipped</span>)")
|
|||
|
|
: Raw(R"(<span class="badge">cancelled</span>)");
|
|||
|
|
|
|||
|
|
// The buyer normally never sees the awaiting state: checkout sends them
|
|||
|
|
// straight to Mollie, and coming back the server has already confirmed
|
|||
|
|
// the payment on arrival. Reaching it means they abandoned the payment,
|
|||
|
|
// so it reads as "resume", not as an alarming limbo.
|
|||
|
|
SafeHtml payBlock;
|
|||
|
|
if (awaiting && !o.payUrl.empty()) {
|
|||
|
|
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
|||
|
|
R"(the euro amount above; your bank or card sets the actual conversion )"
|
|||
|
|
R"(rate.</p>)",
|
|||
|
|
Escape(indicative));
|
|||
|
|
payBlock = Format(
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Complete your payment</h2>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<p><a class="btn btn--primary" rel="noreferrer"{}>Resume payment — {}</a></p>)"
|
|||
|
|
R"(<p class="order__note">The payment page offers iDEAL, cards and a bank )"
|
|||
|
|
R"(transfer; your order reference is <strong>{}</strong>. If you just paid, )"
|
|||
|
|
R"(this page confirms it within seconds. A payment left uncompleted simply )"
|
|||
|
|
R"(lapses the order. Nothing is owed.</p>)"
|
|||
|
|
R"(</section>)",
|
|||
|
|
indicativeLine,
|
|||
|
|
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
|||
|
|
Escape(o.reference));
|
|||
|
|
} else if (o.status == "paid") {
|
|||
|
|
payBlock = Format(
|
|||
|
|
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
|||
|
|
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
|||
|
|
R"(to a week before dispatch. Updates land in your email.</p>)"
|
|||
|
|
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
|||
|
|
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
|||
|
|
R"(gpg --verify, independent of this site.</p>)"
|
|||
|
|
R"(</section>)",
|
|||
|
|
Url("href", "/order/" + o.token + "/invoice.md"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The paid state IS the success page — say so before the receipt table.
|
|||
|
|
const SafeHtml confirmation = o.status == "paid"
|
|||
|
|
? Raw(R"(<p class="notice notice--ok">Payment received. Your order is )"
|
|||
|
|
R"(confirmed. This page is your receipt.</p>)")
|
|||
|
|
: SafeHtml{};
|
|||
|
|
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Order {}</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">{} · placed {}</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<p>{}</p>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<section class="section">)"
|
|||
|
|
R"(<h2 class="section__title">Total</h2>)"
|
|||
|
|
R"(<table class="spec-table"><tbody>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"({})"
|
|||
|
|
R"(</tbody></table>)"
|
|||
|
|
R"(<p class="order__vat">{}</p>)"
|
|||
|
|
R"(</section>)"
|
|||
|
|
R"({})"
|
|||
|
|
R"(<p class="order__keep">There is no account; this link is the access. )"
|
|||
|
|
R"(Download the invoice and keep it. This page is not archived )"
|
|||
|
|
R"(forever.</p>)",
|
|||
|
|
Escape(o.reference),
|
|||
|
|
Escape(o.colorLabel.empty()
|
|||
|
|
? o.productName
|
|||
|
|
: std::format("{} — {}", o.productName, o.colorLabel)),
|
|||
|
|
Escape(o.createdAt),
|
|||
|
|
statusLine,
|
|||
|
|
confirmation,
|
|||
|
|
MoneyRow(o.quantity > 1
|
|||
|
|
? std::format("Device × {}", o.quantity)
|
|||
|
|
: std::string("Device"), o.goodsMinor),
|
|||
|
|
MoneyRow("Shipping", o.shippingMinor),
|
|||
|
|
MoneyRow("Total", o.totalMinor),
|
|||
|
|
o.vatIncluded
|
|||
|
|
? Raw("Includes 21% Dutch VAT.")
|
|||
|
|
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
|
|||
|
|
"VAT, tariffs and any carrier handling fee are charged on arrival "
|
|||
|
|
"and are solely between you, the courier and your customs "
|
|||
|
|
"authority; Catcrafts does not collect them and is not a party "
|
|||
|
|
"to them."),
|
|||
|
|
payBlock);
|
|||
|
|
// A plain <meta refresh> is the no-JavaScript way to make the page track
|
|||
|
|
// the payment: the browser refetches, the server re-reads the order. Only
|
|||
|
|
// while awaiting — a paid page has nothing to poll for.
|
|||
|
|
if (awaiting) page.meta.refreshSeconds = 15;
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── legal ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export RenderedPage RenderLegal(const LegalPage& lp) {
|
|||
|
|
std::vector<SafeHtml> sections;
|
|||
|
|
for (const LegalSection& sec : lp.sections) {
|
|||
|
|
std::vector<SafeHtml> paras;
|
|||
|
|
for (const std::string& para : sec.body) {
|
|||
|
|
paras.push_back(Format(R"(<p>{}</p>)", Escape(para)));
|
|||
|
|
}
|
|||
|
|
sections.push_back(Format(
|
|||
|
|
R"(<section class="legal__section">)"
|
|||
|
|
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
|
|||
|
|
Escape(sec.heading), Join(paras)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = lp.title + " — Catcrafts";
|
|||
|
|
page.meta.description = lp.lede;
|
|||
|
|
page.meta.canonical = "/legal/" + lp.slug;
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">{}</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">{}</p>)"
|
|||
|
|
R"(<p class="legal__updated">Last updated <time{}>{}</time></p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="legal">{}</div>)",
|
|||
|
|
Escape(lp.title), Escape(lp.lede),
|
|||
|
|
Attr("datetime", lp.updated), Escape(lp.updated),
|
|||
|
|
Join(sections));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── demos ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export RenderedPage RenderDemos(std::span<const Demo> demos) {
|
|||
|
|
std::vector<SafeHtml> cards;
|
|||
|
|
for (const Demo& d : demos) {
|
|||
|
|
cards.push_back(Format(
|
|||
|
|
R"(<article class="demo-card">)"
|
|||
|
|
R"(<h2 class="demo-card__title"><a{}>{}</a></h2>)"
|
|||
|
|
R"(<p class="demo-card__blurb">{}</p>)"
|
|||
|
|
R"(<p class="demo-card__meta"><span class="demo-card__tech">{}</span></p>)"
|
|||
|
|
R"(</article>)",
|
|||
|
|
Url("href", "/demos/" + d.slug), Escape(d.name), Escape(d.blurb),
|
|||
|
|
Escape(d.tech)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Demos — Catcrafts";
|
|||
|
|
page.meta.description =
|
|||
|
|
"Things the Crafter engine can do, running in the browser.";
|
|||
|
|
page.meta.canonical = "/demos";
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Demos</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">Things the engine does, running in your browser )"
|
|||
|
|
R"(rather than in a video of someone else's machine.</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="demo-grid">{}</div>)",
|
|||
|
|
cards.empty() ? Raw(R"(<p class="empty">No demos listed.</p>)") : Join(cards));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A single demo. The mount element is the only thing the renderer needs from
|
|||
|
|
// the page: Catcrafts:Demo reparents its canvas into it by id.
|
|||
|
|
export RenderedPage RenderDemo(const Demo& d) {
|
|||
|
|
// aspect-ratio comes from the content file, so a demo with different
|
|||
|
|
// proportions does not need a CSS change. Emitted as a style attribute
|
|||
|
|
// because it is per-demo data, not a reusable rule — and it goes through
|
|||
|
|
// Attr() so the value cannot break out of the attribute.
|
|||
|
|
SafeHtml mount = d.mountId.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<div class="demo-stage"{}{}></div>)",
|
|||
|
|
Attr("id", d.mountId),
|
|||
|
|
Attr("style", "aspect-ratio: " + d.aspect));
|
|||
|
|
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = d.name + " — Catcrafts";
|
|||
|
|
page.meta.description = d.blurb;
|
|||
|
|
page.meta.canonical = "/demos/" + d.slug;
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<p class="breadcrumb"><a{}>Demos</a> / {}</p>)"
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">{}</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">{}</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<div class="demo">{}{}</div>)"
|
|||
|
|
R"(<dl class="demo-facts">)"
|
|||
|
|
R"(<dt>Built with</dt><dd>{}</dd>)"
|
|||
|
|
R"(</dl>)",
|
|||
|
|
Url("href", "/demos"), Escape(d.name),
|
|||
|
|
Escape(d.name), Escape(d.blurb),
|
|||
|
|
mount,
|
|||
|
|
d.needs.empty() ? SafeHtml{} : Format(
|
|||
|
|
R"(<p class="demo__requires">Needs {}</p>)", Escape(d.needs)),
|
|||
|
|
Escape(d.tech));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── not found ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export RenderedPage RenderNotFound(std::string_view path) {
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Not found — Catcrafts";
|
|||
|
|
page.meta.noindex = true;
|
|||
|
|
page.status = 404;
|
|||
|
|
page.main = Format(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Not found</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">There's nothing at <code>{}</code>.</p>)"
|
|||
|
|
R"(</header>)"
|
|||
|
|
R"(<p><a class="btn btn--primary"{}>Go home</a></p>)",
|
|||
|
|
Escape(path), Url("href", "/"));
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── dispatch ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export struct SiteContent {
|
|||
|
|
std::vector<Project> projects;
|
|||
|
|
std::vector<Post> posts;
|
|||
|
|
std::vector<Product> products;
|
|||
|
|
std::vector<LegalPage> legal;
|
|||
|
|
std::vector<Demo> demos;
|
|||
|
|
// ECB reference rates, for the indicative local-currency prices. Empty is
|
|||
|
|
// fine — every price then shows in euros.
|
|||
|
|
Rates rates;
|
|||
|
|
|
|||
|
|
const Demo* FindDemo(std::string_view slug) const {
|
|||
|
|
for (const Demo& d : demos) {
|
|||
|
|
if (d.slug == slug) return &d;
|
|||
|
|
}
|
|||
|
|
return nullptr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const LegalPage* FindLegal(std::string_view slug) const {
|
|||
|
|
for (const LegalPage& p : legal) {
|
|||
|
|
if (p.slug == slug) return &p;
|
|||
|
|
}
|
|||
|
|
return nullptr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Product* FindProduct(std::string_view slug) const {
|
|||
|
|
for (const Product& p : products) {
|
|||
|
|
if (p.slug == slug) return &p;
|
|||
|
|
}
|
|||
|
|
return nullptr;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content);
|
|||
|
|
|
|||
|
|
// Single entry point both hosts call. Keeping the switch here rather than in
|
|||
|
|
// each host is what guarantees a path renders identically on the server and in
|
|||
|
|
// the app.
|
|||
|
|
export RenderedPage RenderRoute(const Route& route, const SiteContent& content) {
|
|||
|
|
// A route carrying a canonical target is a redirect, whatever it renders.
|
|||
|
|
// Handled once here rather than per-case: /blog and the retired /demo both
|
|||
|
|
// need it, and the next retired URL should not have to remember to set the
|
|||
|
|
// status itself. The body is still rendered so a client that ignores the
|
|||
|
|
// redirect sees the right content.
|
|||
|
|
if (!route.canonicalRedirect.empty()) {
|
|||
|
|
RenderedPage page = RenderRouteBody(route, content);
|
|||
|
|
page.meta.canonical = route.canonicalRedirect;
|
|||
|
|
page.status = 301;
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
return RenderRouteBody(route, content);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
|
|||
|
|
switch (route.kind) {
|
|||
|
|
case RouteKind::Home: return RenderHome(content.projects, content.posts);
|
|||
|
|
case RouteKind::Projects: return RenderProjects(content.projects);
|
|||
|
|
case RouteKind::Posts: return RenderPosts(content.posts);
|
|||
|
|
case RouteKind::Demos: return RenderDemos(content.demos);
|
|||
|
|
case RouteKind::Demo: {
|
|||
|
|
if (const Demo* d = content.FindDemo(route.slug)) return RenderDemo(*d);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
case RouteKind::Shop: return RenderShop(content.products, content.rates);
|
|||
|
|
case RouteKind::Invoice:
|
|||
|
|
case RouteKind::Order: {
|
|||
|
|
// Only the server knows order state, and the server intercepts this
|
|||
|
|
// route before shared dispatch. Reaching this case means the wasm
|
|||
|
|
// app is rendering with the backend down — say so instead of
|
|||
|
|
// guessing at a status.
|
|||
|
|
RenderedPage page;
|
|||
|
|
page.meta.title = "Order status — Catcrafts";
|
|||
|
|
page.meta.noindex = true;
|
|||
|
|
page.meta.refreshSeconds = 30;
|
|||
|
|
page.main = Raw(
|
|||
|
|
R"(<header class="page-header">)"
|
|||
|
|
R"(<h1 class="page-header__title">Order status unavailable</h1>)"
|
|||
|
|
R"(<p class="page-header__lede">The status service isn't reachable right )"
|
|||
|
|
R"(now. Nothing is wrong with your order. This page just can't read )"
|
|||
|
|
R"(it at the moment. It retries automatically; keep the link.</p>)"
|
|||
|
|
R"(</header>)");
|
|||
|
|
return page;
|
|||
|
|
}
|
|||
|
|
case RouteKind::Legal: {
|
|||
|
|
if (const LegalPage* lp = content.FindLegal(route.slug)) {
|
|||
|
|
return RenderLegal(*lp);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
case RouteKind::Product: {
|
|||
|
|
// A slug that parsed but names nothing is a 404, not an empty
|
|||
|
|
// product page — otherwise every typo becomes an indexable URL.
|
|||
|
|
if (const Product* p = content.FindProduct(route.slug)) {
|
|||
|
|
return RenderProduct(*p, content.rates);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
case RouteKind::LegacyBlog:
|
|||
|
|
return RenderPosts(content.posts);
|
|||
|
|
case RouteKind::NotFound: break;
|
|||
|
|
}
|
|||
|
|
return RenderNotFound(route.path);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Atom feed ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// Html::Escape's output is valid XML text: & < > " are shared
|
|||
|
|
// with XML, and it emits an apostrophe as the numeric reference ' rather
|
|||
|
|
// than the HTML-only '. So no separate XML escaper is needed.
|
|||
|
|
export std::string RenderAtomFeed(std::span<const Post> posts) {
|
|||
|
|
// <updated> is the newest entry's timestamp rather than "now", so an
|
|||
|
|
// unchanged post list produces a byte-identical feed and conditional
|
|||
|
|
// requests keep working.
|
|||
|
|
const std::string_view updated =
|
|||
|
|
posts.empty() ? std::string_view("1970-01-01T00:00:00Z")
|
|||
|
|
: std::string_view(posts.front().published);
|
|||
|
|
|
|||
|
|
std::string out = std::format(
|
|||
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|||
|
|
"<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
|
|||
|
|
" <title>Catcrafts</title>\n"
|
|||
|
|
" <subtitle>Posts from the fediverse</subtitle>\n"
|
|||
|
|
" <link href=\"https://catcrafts.net/feed.xml\" rel=\"self\"/>\n"
|
|||
|
|
" <link href=\"https://catcrafts.net/posts\"/>\n"
|
|||
|
|
" <id>https://catcrafts.net/</id>\n"
|
|||
|
|
" <updated>{}</updated>\n"
|
|||
|
|
" <author><name>Catcrafts</name></author>\n",
|
|||
|
|
Escape(updated).Str());
|
|||
|
|
|
|||
|
|
for (const Post& p : posts) {
|
|||
|
|
out += std::format(
|
|||
|
|
" <entry>\n"
|
|||
|
|
" <title>{}</title>\n"
|
|||
|
|
// ap_id is a stable, globally unique federated URL — exactly what
|
|||
|
|
// an Atom <id> is meant to be.
|
|||
|
|
" <id>{}</id>\n"
|
|||
|
|
" <link href=\"{}\"/>\n"
|
|||
|
|
" <updated>{}</updated>\n"
|
|||
|
|
" <summary>{}</summary>\n"
|
|||
|
|
" </entry>\n",
|
|||
|
|
Escape(p.title).Str(),
|
|||
|
|
Escape(p.permalink).Str(),
|
|||
|
|
Escape(p.permalink).Str(),
|
|||
|
|
Escape(p.published).Str(),
|
|||
|
|
Escape(p.excerpt).Str());
|
|||
|
|
}
|
|||
|
|
out += "</feed>\n";
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── full document (server-side rendering) ─────────────────────────────
|
|||
|
|
|
|||
|
|
// The timezone price hint — the only script a shop page carries.
|
|||
|
|
//
|
|||
|
|
// Why the timezone and not the IP: the question is binary (EU price or export
|
|||
|
|
// price), the browser's IANA timezone answers it locally with no permission
|
|||
|
|
// prompt, nothing leaves the device, and unlike an IP lookup it is not fooled
|
|||
|
|
// by a VPN endpoint. The mapping is "every EU timezone" — anything else,
|
|||
|
|
// including Europe/London and Europe/Zurich, correctly falls out as non-EU.
|
|||
|
|
//
|
|||
|
|
// Both outcomes are tagged (cc-eu / cc-noneu) rather than only one: a
|
|||
|
|
// CONFIRMED region hides the inapplicable price line outright, which CSS can
|
|||
|
|
// only do if it can tell "confirmed EU" apart from "nothing ran". Failure
|
|||
|
|
// mode is the default: no JS, an ancient browser, or an exotic tz leaves the
|
|||
|
|
// EU-first dual display, which is complete and honest on its own. The class
|
|||
|
|
// goes on <html> so it all happens before first paint — no flash.
|
|||
|
|
//
|
|||
|
|
// UTC is deliberately "unknown", not "non-EU": Firefox's
|
|||
|
|
// privacy.resistFingerprinting reports UTC for everyone, and privacy-hardened
|
|||
|
|
// browsers are exactly this shop's clientele. A real person is almost never
|
|||
|
|
// genuinely in UTC; a fingerprinting-resistant one frequently claims to be.
|
|||
|
|
// They get the dual display, same as no-JS.
|
|||
|
|
// Beyond the region classes, the script picks the visitor's display currency
|
|||
|
|
// from the same timezone (a currency map of the zones each supported currency
|
|||
|
|
// covers) and, once the DOM exists, swaps every .price__single's text for the
|
|||
|
|
// matching pre-formatted data attribute. It computes nothing and fetches
|
|||
|
|
// nothing — the server rendered every string it is allowed to choose from.
|
|||
|
|
inline constexpr std::string_view kGeoPriceHintScript =
|
|||
|
|
"<script>"
|
|||
|
|
"(function(){try{"
|
|||
|
|
"var z=Intl.DateTimeFormat().resolvedOptions().timeZone;"
|
|||
|
|
"if(!z||z===\"UTC\"||z.indexOf(\"Etc/\")===0)return;"
|
|||
|
|
"var eu=[\"Europe/Amsterdam\",\"Europe/Athens\",\"Europe/Berlin\",\"Europe/Bratislava\","
|
|||
|
|
"\"Europe/Brussels\",\"Europe/Bucharest\",\"Europe/Budapest\",\"Europe/Busingen\","
|
|||
|
|
"\"Europe/Copenhagen\",\"Europe/Dublin\",\"Europe/Helsinki\",\"Europe/Lisbon\","
|
|||
|
|
"\"Europe/Ljubljana\",\"Europe/Luxembourg\",\"Europe/Madrid\",\"Europe/Malta\","
|
|||
|
|
"\"Europe/Mariehamn\",\"Europe/Paris\",\"Europe/Prague\",\"Europe/Riga\","
|
|||
|
|
"\"Europe/Rome\",\"Europe/Sofia\",\"Europe/Stockholm\",\"Europe/Tallinn\","
|
|||
|
|
"\"Europe/Vatican\",\"Europe/Vienna\",\"Europe/Vilnius\",\"Europe/Warsaw\","
|
|||
|
|
"\"Europe/Zagreb\",\"Asia/Nicosia\",\"Asia/Famagusta\",\"Atlantic/Azores\","
|
|||
|
|
"\"Atlantic/Canary\",\"Atlantic/Madeira\",\"Africa/Ceuta\"];"
|
|||
|
|
"var isEu=eu.indexOf(z)>-1;"
|
|||
|
|
"document.documentElement.className+=isEu?\" cc-eu\":\" cc-noneu\";"
|
|||
|
|
"var cur={"
|
|||
|
|
"\"Europe/Stockholm\":\"sek\",\"Europe/Copenhagen\":\"dkk\",\"Europe/Warsaw\":\"pln\","
|
|||
|
|
"\"Europe/Prague\":\"czk\",\"Europe/Budapest\":\"huf\",\"Europe/Bucharest\":\"ron\","
|
|||
|
|
"\"Europe/Sofia\":\"bgn\","
|
|||
|
|
"\"Europe/London\":\"gbp\",\"Europe/Zurich\":\"chf\",\"Europe/Oslo\":\"nok\","
|
|||
|
|
"\"Atlantic/Reykjavik\":\"isk\",\"Asia/Tokyo\":\"jpy\","
|
|||
|
|
"\"America/Toronto\":\"cad\",\"America/Vancouver\":\"cad\",\"America/Edmonton\":\"cad\","
|
|||
|
|
"\"America/Winnipeg\":\"cad\",\"America/Halifax\":\"cad\",\"America/St_Johns\":\"cad\","
|
|||
|
|
"\"America/Regina\":\"cad\",\"America/Moncton\":\"cad\",\"America/Whitehorse\":\"cad\","
|
|||
|
|
"\"America/Yellowknife\":\"cad\",\"America/Iqaluit\":\"cad\","
|
|||
|
|
"\"America/New_York\":\"usd\",\"America/Chicago\":\"usd\",\"America/Denver\":\"usd\","
|
|||
|
|
"\"America/Los_Angeles\":\"usd\",\"America/Phoenix\":\"usd\",\"America/Anchorage\":\"usd\","
|
|||
|
|
"\"America/Detroit\":\"usd\",\"America/Boise\":\"usd\",\"Pacific/Honolulu\":\"usd\","
|
|||
|
|
"\"Australia/Sydney\":\"aud\",\"Australia/Melbourne\":\"aud\",\"Australia/Brisbane\":\"aud\","
|
|||
|
|
"\"Australia/Perth\":\"aud\",\"Australia/Adelaide\":\"aud\",\"Australia/Hobart\":\"aud\","
|
|||
|
|
"\"Australia/Darwin\":\"aud\",\"Pacific/Auckland\":\"nzd\"};"
|
|||
|
|
"var c=cur[z];"
|
|||
|
|
"document.addEventListener(\"DOMContentLoaded\",function(){"
|
|||
|
|
"var els=document.querySelectorAll(\".price__single\");"
|
|||
|
|
"for(var i=0;i<els.length;i++){"
|
|||
|
|
"var v=c?els[i].getAttribute(\"data-\"+c):null;"
|
|||
|
|
"if(!v&&!isEu)v=els[i].getAttribute(\"data-world\");"
|
|||
|
|
"if(v)els[i].textContent=v;"
|
|||
|
|
"}"
|
|||
|
|
// The live checkout total. Reads only the data-cc blob the server rendered
|
|||
|
|
// (unit prices per colour, zone rates, live carrier table) and mirrors
|
|||
|
|
// ComputeTotals exactly: line total, floor((x*10000+6050)/12100) for the
|
|||
|
|
// export net, shipping by country then zone. Same integers, same formula,
|
|||
|
|
// so this preview and the charged amount cannot disagree.
|
|||
|
|
"var f=document.querySelector(\"form[data-cc]\");"
|
|||
|
|
"if(f){"
|
|||
|
|
"var d=JSON.parse(f.getAttribute(\"data-cc\"));"
|
|||
|
|
"var out=document.getElementById(\"cc-total\");"
|
|||
|
|
"var ecc=[\"AT\",\"BE\",\"BG\",\"HR\",\"CY\",\"CZ\",\"DE\",\"DK\",\"EE\",\"ES\","
|
|||
|
|
"\"FI\",\"FR\",\"GR\",\"HU\",\"IE\",\"IT\",\"LT\",\"LU\",\"LV\",\"MT\",\"NL\","
|
|||
|
|
"\"PL\",\"PT\",\"RO\",\"SE\",\"SI\",\"SK\"];"
|
|||
|
|
"var fmt=function(m){return m%100?\"\\u20ac\"+Math.floor(m/100)+\".\"+(\"0\"+m%100).slice(-2):\"\\u20ac\"+m/100};"
|
|||
|
|
"var upd=function(){"
|
|||
|
|
"var ce=f.querySelector(\"[name=color]\"),qe=f.querySelector(\"[name=quantity]\"),ke=f.querySelector(\"[name=country]\");"
|
|||
|
|
"var unit=ce&&d.v[ce.value]?d.v[ce.value]:d.from;"
|
|||
|
|
"var qty=qe?parseInt(qe.value,10)||1:1;"
|
|||
|
|
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
|
|||
|
|
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
|
|||
|
|
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
|
|||
|
|
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
|
|||
|
|
"var ship=(d.c&&d.c[k])||(k===\"NL\"?d.s.nl:eu?d.s.eu:d.s.w);"
|
|||
|
|
"if(out){out.textContent=\"You pay \"+fmt(goods+ship)+\" \\u2014 \"+fmt(goods)"
|
|||
|
|
"+(qty>1?\" (\"+qty+\"\\u00d7)\":\"\")+\" + \"+fmt(ship)+\" shipping, \""
|
|||
|
|
"+(eu?\"incl. VAT\":\"ex VAT\");out.hidden=false}"
|
|||
|
|
"};"
|
|||
|
|
"f.addEventListener(\"input\",upd);f.addEventListener(\"change\",upd);upd();"
|
|||
|
|
"}"
|
|||
|
|
"});"
|
|||
|
|
"}catch(e){}})();"
|
|||
|
|
"</script>\n";
|
|||
|
|
|
|||
|
|
// Wraps a rendered page in a complete HTML document.
|
|||
|
|
//
|
|||
|
|
// `bootScripts` is the <script> block Crafter.Build generated into the wasm
|
|||
|
|
// bundle's index.html, passed through verbatim, or empty for a page that
|
|||
|
|
// should not load the wasm at all. That per-route choice is what lets the
|
|||
|
|
// shop later ship as plain HTML while /demo keeps the renderer.
|
|||
|
|
export std::string RenderDocument(const RenderedPage& page,
|
|||
|
|
const SafeHtml& nav,
|
|||
|
|
const SafeHtml& footer,
|
|||
|
|
std::string_view bootScripts,
|
|||
|
|
std::string_view cssHref) {
|
|||
|
|
const std::string canonical =
|
|||
|
|
page.meta.canonical.empty()
|
|||
|
|
? std::string{}
|
|||
|
|
: Format(R"(<link rel="canonical"{}>)",
|
|||
|
|
Url("href", "https://catcrafts.net" + page.meta.canonical)).Str();
|
|||
|
|
|
|||
|
|
return std::format(
|
|||
|
|
"<!doctype html>\n"
|
|||
|
|
"<html lang=\"en\">\n<head>\n"
|
|||
|
|
"<meta charset=\"utf-8\">\n"
|
|||
|
|
// Only on pages that boot the wasm, and load-bearing there.
|
|||
|
|
//
|
|||
|
|
// Crafter.Build's runtime.js resolves everything it needs relative to the
|
|||
|
|
// DOCUMENT url: fetch("variants.json"), fetch("files.json"), every VFS
|
|||
|
|
// entry, and the .wasm named by variants.json. At "/" that is correct; at
|
|||
|
|
// /demos/raytracer it asks for /demos/variants.json, gets Caddy's
|
|||
|
|
// try_files fallback (index.html), and the whole boot collapses — four
|
|||
|
|
// NS_ERROR_CORRUPTED_CONTENT failures and a blank demo.
|
|||
|
|
//
|
|||
|
|
// A <base> fixes all of them at once because a bare relative fetch() in a
|
|||
|
|
// module resolves against the document base URL. Safe here only because
|
|||
|
|
// every href/src/action this renderer emits is already absolute, so
|
|||
|
|
// nothing else changes meaning — there is an e2e check pinning that.
|
|||
|
|
"{}"
|
|||
|
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|||
|
|
// Tells catcrafts-head.js that the server already set the head, so it
|
|||
|
|
// does not overwrite an SSR'd title with the generic one.
|
|||
|
|
"<meta name=\"cc-ssr\" content=\"1\">\n"
|
|||
|
|
"<title>{}</title>\n"
|
|||
|
|
"{}{}{}{}{}"
|
|||
|
|
"<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Catcrafts\" href=\"/feed.xml\">\n"
|
|||
|
|
"<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n"
|
|||
|
|
"<link rel=\"stylesheet\" href=\"{}\">\n"
|
|||
|
|
"{}"
|
|||
|
|
"</head>\n<body>\n"
|
|||
|
|
"<div id=\"catcrafts-root\">\n"
|
|||
|
|
"<header id=\"cc-header\">{}</header>\n"
|
|||
|
|
"<main id=\"main\">{}</main>\n"
|
|||
|
|
"<footer id=\"cc-footer\">{}</footer>\n"
|
|||
|
|
"</div>\n"
|
|||
|
|
"</body>\n</html>\n",
|
|||
|
|
bootScripts.empty() ? "" : "<base href=\"/\">\n",
|
|||
|
|
Html::Escape(page.meta.title).Str(),
|
|||
|
|
page.meta.description.empty() ? std::string{}
|
|||
|
|
: std::format("<meta name=\"description\"{}>\n",
|
|||
|
|
Html::Attr("content", page.meta.description).Str()),
|
|||
|
|
page.meta.noindex ? "<meta name=\"robots\" content=\"noindex, nofollow\">\n" : "",
|
|||
|
|
page.meta.refreshSeconds > 0
|
|||
|
|
? std::format("<meta http-equiv=\"refresh\" content=\"{}\">\n",
|
|||
|
|
page.meta.refreshSeconds)
|
|||
|
|
: std::string{},
|
|||
|
|
page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{},
|
|||
|
|
canonical.empty() ? std::string{} : canonical + "\n",
|
|||
|
|
cssHref,
|
|||
|
|
bootScripts,
|
|||
|
|
nav.Str(),
|
|||
|
|
page.main.Str(),
|
|||
|
|
footer.Str());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} // namespace Catcrafts::Views
|