catcrafts.net/shared/interfaces/Catcrafts.Shared-Model.cppm
Jorijn van der Graaf fca089c20d
Some checks failed
Deploy / build-deploy (push) Failing after 7m0s
SEO
2026-08-06 23:42:25 +02:00

350 lines
15 KiB
C++

/*
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.
*/
// Content types, and loaders for the two files that stay data.
//
// The split: content a person authors (products, projects, legal pages,
// demos) is compiled in — see :Content — so a typo in it is a build error,
// not a silently blank page. Content a machine writes at build time stays
// JSON under content/: posts.json (fetched from the fediverse by CI) and
// rates.json (ECB rates). Those change without anyone editing C++, so their
// loaders live here.
//
// Both hosts call the same loaders on the same bytes; only where the bytes
// come from differs. The wasm build gets the files through Crafter.Build's
// VFS (cfg.files -> files.json -> fetched before _start, readable at the
// bundle root); the native server reads content/ off disk. So the loaders
// take text, not paths.
//
// Loading never fails hard. These files are generated by our own CI, so a
// missing or wrong-typed field is a generator bug, and a page rendering with
// one bad card beats a page that refuses to render at all. Malformed JSON is
// the one exception — that yields an empty list, because a half-parsed
// document is worse than none.
export module Catcrafts.Shared:Model;
import std;
import :Json;
namespace Catcrafts {
// One image or video belonging to a post.
//
// `src` is a local path under /media once tools/fetch-media.sh has mirrored it.
// It can still be an absolute URL if that download failed, which is why the
// renderer puts it through Url() rather than assuming it is site-relative.
//
// Width and height come from ffprobe at mirror time and exist to stop layout
// shift: without them the browser cannot reserve space, and the text below every
// card jumps as each file arrives.
export struct PostMedia {
std::string src;
std::string kind; // "image" | "video"
// Still frame for a video, from the instance's own thumbnail. Without it a
// <video preload="metadata"> shows a black box until the visitor presses
// play — and these posts are their video, so the black box is the page.
// Empty for images, and empty when the instance generated no thumbnail.
std::string poster;
std::int64_t width = 0;
std::int64_t height = 0;
};
// A post mirrored from the fediverse (Lemmy). Deliberately not the full post:
// the body stays on the community's instance, where the comments are. We show
// enough to be worth clicking and then hand off — no crawling, no comment
// mirroring, no markdown pipeline.
//
// `permalink` is resolved at fetch time to the COMMUNITY's instance, not the
// author's. Both host a federated copy; the community's is where the discussion
// actually is, and it is what the site should be pointing readers at.
export struct Post {
std::string title;
std::string permalink; // canonical ap_id on the instance; where "discuss" goes
std::string linkUrl; // for link posts, the linked target; empty otherwise
std::string community; // e.g. "linux@lemmy.ml"
std::string published; // ISO-8601, as emitted by the API
std::string excerpt; // plain text, truncated by CI — never markdown
std::int64_t score = 0;
std::int64_t comments = 0;
std::vector<PostMedia> media;
};
// An entry on the projects page. Repo content, not a database — these change
// on the order of months.
//
// Deliberately shallow: name, one sentence, a link. The Forgejo page is the
// canonical home for details, so anything richer here (status, feature lists)
// just drifts out of date against it.
export struct Project {
std::string name;
std::string blurb;
std::string url;
std::string language;
bool featured = false;
};
// Everything the document <head> needs. Emitted server-side once SSR lands;
// until then catcrafts-head.js sets the title and the rest is unused but
// carried so the renderers don't need changing later.
export struct PageMeta {
std::string title;
std::string description;
std::string canonical;
std::string ogType = "website";
std::string ogImage;
bool noindex = false;
// >0 emits <meta http-equiv="refresh"> — the no-JavaScript way for a page
// to track changing server state. Used by the order page while a payment
// is pending; leave 0 everywhere content is static.
int refreshSeconds = 0;
// Emits the inline timezone hint script (see RenderDocument): pages that
// show the dual EU/export price set this so the browser can move the
// emphasis onto whichever price applies locally. Presentation only — both
// prices are always in the markup, the HTML is identical for every
// visitor, and nothing is detected server-side.
bool geoPriceHint = false;
// schema.org JSON-LD for this page, already serialized. Emitted verbatim
// inside <script type="application/ld+json"> — inert data, not code, so
// it does not count against the shop pages' one-executable-script rule.
// Set by RenderHome (an @graph of Organization + WebSite: who Catcrafts is
// and what this domain is, versus the two name-twins), RenderAbout
// (ProfilePage: who the founder is), RenderShop (ItemList: which product
// pages exist) and RenderProduct (ProductGroup: what is sold, per colour,
// at which prices — the same integers the checkout charges). About and
// the offers reference the home page's Organization by @id rather than
// restating it — and home's founder references about's Person the same
// way — so every page describes one entity; see the identity graph in
// RenderHome for why that join carries the weight it does.
std::string jsonLd;
};
export struct Spec {
std::string label;
std::string value;
};
// A buyable variation of a product — currently colour. Each carries its own
// VAT-inclusive price because the supplier prices them differently (white
// costs ~€90 more wholesale than green). The ex-VAT export price is always
// DERIVED (Money::NetFromGross), never stored.
export struct Variant {
std::string slug; // "green" — form value and order-record field
std::string label; // "Green" — what the buyer reads
std::int64_t priceInclMinor = 0;
};
export struct Product {
std::string slug;
std::string name;
std::string tagline;
// The hardware manufacturer, for the Product schema's brand field.
// Deliberately NOT accompanied by a GTIN: the device is materially
// modified (different OS), and Google's product-data rules say a
// customized product must not carry the original manufacturer's GTIN —
// which also matches the legal framing that this is a Fairphone with
// different software, not a Catcrafts-manufactured phone. Empty = omit.
std::string brand;
// "available" — buyable now. "coming-soon" — listed with launch prices,
// orders not open yet. "unavailable" — listed but not sellable (sourcing
// gap, price swing). In both closed states the page stays up, the buy
// form does not, and the checkout POST is refused server-side.
std::string status;
// The EU consumer price, VAT-inclusive, in cents. With variants present
// this is the FROM price (cheapest variant) and is kept in sync by the
// loader; without variants it is simply the price.
std::int64_t priceInclMinor = 0;
std::vector<Variant> variants;
std::string currency = "EUR";
// Flat shipping per zone, in cents, consumer-facing (VAT-inclusive where
// VAT applies). The fallback when Sendcloud has no rate for a country —
// live carrier rates take precedence wherever they exist.
std::int64_t shipNlMinor = 0;
std::int64_t shipEuMinor = 0;
std::int64_t shipWorldMinor = 0;
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
// from our own origin like every other asset — the privacy notice's
// "everything comes from catcrafts.net" applies to product images too.
std::string image;
std::string summary;
std::string warranty;
// Rendered as a prominent warning box when non-empty. Exists for exactly
// one thing today: the emergency-calling caveat — the single claim on the
// page that is about safety rather than features, which is why it gets a
// warning box instead of a table row.
std::string safetyNote;
std::vector<Spec> specs;
bool Buyable() const { return status == "available" && priceInclMinor > 0; }
bool ComingSoon() const { return status == "coming-soon"; }
// nullptr for a colour we never listed — the checkout rejects rather than
// guessing, so a tampered form value cannot buy an unpriced variant.
const Variant* FindVariant(std::string_view vslug) const {
for (const Variant& v : variants) {
if (v.slug == vslug) return &v;
}
return nullptr;
}
// The default selection: the cheapest variant, which is also what the
// "from" price shows — so the page never advertises a number the default
// choice doesn't honour.
const Variant* CheapestVariant() const {
const Variant* best = nullptr;
for (const Variant& v : variants) {
if (!best || v.priceInclMinor < best->priceInclMinor) best = &v;
}
return best;
}
};
// ECB euro reference rates, baked in at build time by tools/fetch-rates.sh.
// Values are integer micro-units of target currency per euro (1 EUR = 1.0834
// USD -> 1'083'400) — the script does the decimal-to-integer conversion so no
// float ever touches a money path here. Used ONLY for the indicative national-
// currency line on the order page; every charge is in euros.
export struct Rates {
std::string date; // ECB publication date
std::vector<std::pair<std::string, std::int64_t>> microPerEur;
std::int64_t Find(std::string_view code) const {
for (const auto& [k, v] : microPerEur) {
if (k == code) return v;
}
return 0;
}
};
export Rates LoadRates(std::string_view json) {
Rates out;
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return out;
out.date = std::string(doc->Str("date"));
if (const Json::Value* m = doc->Find("micro_per_eur"); m && m->IsObject()) {
for (const auto& [k, v] : m->object) {
if (v.type == Json::Type::Number && v.number > 0) {
out.microPerEur.emplace_back(k, static_cast<std::int64_t>(v.number));
}
}
}
return out;
}
// Everything the order status page needs to render — a projection of the
// server's order record, not the record itself. The renderer stays a pure
// function in Shared; the server owns storage and fills this in.
export struct OrderView {
std::string token; // the capability that IS the URL — never logged
std::string reference; // short human code, quoted in the bank transfer
std::string status; // "awaiting_payment" | "paid" | "shipped" | "cancelled"
std::string productName;
std::string colorLabel; // "Forest Green", empty for variantless products
std::int64_t quantity = 1;
std::int64_t unitMinor = 0;
std::string payUrl; // bunq.me link; empty once paid or when cancelled
std::string createdAt; // ISO 8601, shown verbatim
std::string country;
std::int64_t goodsMinor = 0;
std::int64_t shippingMinor = 0;
std::int64_t totalMinor = 0;
bool vatIncluded = false;
};
// An entry on the demos page.
//
// `needsWasm` is what decides whether a page ships the ~239 KB module, so it is
// data rather than a hardcoded route check: adding a demo that needs the
// renderer, or one that does not, should not require touching the server.
export struct Demo {
std::string slug;
std::string name;
std::string blurb;
std::string tech;
std::string needs; // what the browser must support, in plain words ("requires" is a keyword)
std::string mountId; // element id the renderer reparents its canvas into
std::string aspect; // CSS aspect-ratio for the mount box
bool needsWasm = false;
};
// A legal / informational page. Sections of headed paragraphs rather than
// markdown: these are written once and read rarely, and a prose format would
// mean carrying a markdown renderer for four pages.
export struct LegalSection {
std::string heading;
std::vector<std::string> body;
};
export struct LegalPage {
std::string slug;
std::string title;
std::string updated; // ISO date, shown to the reader
std::string lede;
std::vector<LegalSection> sections;
};
// ── loaders ───────────────────────────────────────────────────────────
export std::vector<Post> LoadPosts(std::string_view json) {
std::vector<Post> out;
auto doc = Json::Parse(json);
if (!doc || !doc->IsArray()) return out;
out.reserve(doc->array.size());
for (const Json::Value& item : doc->array) {
if (!item.IsObject()) continue;
Post p;
p.title = std::string(item.Str("title"));
p.permalink = std::string(item.Str("permalink"));
p.linkUrl = std::string(item.Str("url"));
p.community = std::string(item.Str("community"));
p.published = std::string(item.Str("published"));
p.excerpt = std::string(item.Str("excerpt"));
p.score = item.Int("score");
p.comments = item.Int("comments");
if (const Json::Value* m = item.Find("media"); m && m->IsArray()) {
for (const Json::Value& mv : m->array) {
if (!mv.IsObject()) continue;
PostMedia pm;
pm.src = std::string(mv.Str("src"));
pm.kind = std::string(mv.Str("kind", "image"));
pm.poster = std::string(mv.Str("poster"));
pm.width = mv.Int("w");
pm.height = mv.Int("h");
// Only the two kinds the renderer knows how to emit. Anything
// else would fall through to an <img> for a file that is not an
// image, so treat it as image only when it says so.
if (pm.kind != "image" && pm.kind != "video") pm.kind = "image";
if (pm.src.empty()) continue;
p.media.push_back(std::move(pm));
}
}
// A post with no title and nowhere to click is not renderable; drop it
// rather than emit an empty card.
if (p.title.empty() && p.permalink.empty()) continue;
out.push_back(std::move(p));
}
return out;
}
// ── formatting helpers ────────────────────────────────────────────────
// "2026-07-18T14:03:22.123456Z" -> "2026-07-18".
//
// Deliberately not a date parser. The only thing the UI needs is the day, the
// API always emits ISO-8601, and pulling in civil-time handling to render ten
// characters would be the wrong trade. Anything unexpected is passed through
// unchanged so a format change shows up as visibly odd text rather than a
// silently wrong date.
export std::string_view DateOnly(std::string_view iso) {
if (iso.size() >= 10 && iso[4] == '-' && iso[7] == '-') return iso.substr(0, 10);
return iso;
}
} // namespace Catcrafts