new logo
All checks were successful
Deploy / build-deploy (push) Successful in 5m32s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 23:52:40 +02:00
commit b49042a626
8 changed files with 143 additions and 28 deletions

View file

@ -41,6 +41,7 @@ export const std::vector<Product>& Products() {
Product p;
p.slug = "fp6-pmos";
p.name = "Fairphone 6 with postmarketOS";
p.brand = "Fairphone";
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
// Launch day is this one line: "coming-soon" -> "available". The page
// shows launch prices either way; only the order form is held back.

View file

@ -136,6 +136,13 @@ 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

View file

@ -69,13 +69,17 @@ export std::string FormatEuro(std::int64_t minor) {
// Note NIR/GB: the UK left; Northern Ireland's special goods status is not
// modelled — GB is simply non-EU here, which is the correct default for a
// consumer parcel.
export bool IsEuCountry(std::string_view cc) {
export std::span<const std::string_view> EuCountries() {
static constexpr std::array<std::string_view, 27> eu{
"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",
};
return std::ranges::find(eu, cc) != eu.end();
return eu;
}
export bool IsEuCountry(std::string_view cc) {
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
}
// Shipping zones. Three tiers is deliberate — real carrier pricing has more

View file

@ -86,12 +86,13 @@ export SafeHtml RenderNav(RouteKind current) {
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"(<path d="M9 26 L13 4 L23 15 Q32 11 41 15 L51 4 L55 26 L54 38 Q52 56 32 56 Q12 56 10 38 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
R"(<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/>)"
R"(<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32" 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"x(<path d="M23 38 L29 43 L23 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
R"x(<path class="logo-cursor" d="M34 48 L42 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
R"(</svg>)"
R"(</span>)"
R"(<span class="logo__text">Catcrafts</span></a>)"
@ -223,10 +224,14 @@ export RenderedPage RenderHome(std::span<const Project> projects,
RenderedPage page;
page.meta.title = "Catcrafts — open-source software and hardware";
// Deliberately the hero text, word for word: the mission sentence is the
// site's best self-description, and the search snippet, the og: link
// card and the page itself should all say the same thing. If the hero
// below changes, change this with it.
page.meta.description =
"Catcrafts makes accessible, open-source software and hardware: an "
"alternative to big tech. A C++ engine, build system and networking "
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
"Catcrafts makes accessible, open-source software and hardware for "
"the benefit of everyone. The goal: a real alternative to big tech "
"that anyone can use, not just Linux nerds.";
page.meta.canonical = "/";
// The identity record: name, registered VAT identity and the Forgejo
// profile separate this Catcrafts from the name-twins by type and by
@ -694,35 +699,101 @@ export RenderedPage RenderProduct(const Product& product,
// integers the checkout charges — this markup can never advertise a
// number the shop doesn't honour. Availability tracks the status field,
// so launch day flips PreOrder to InStock with no edit here.
//
// Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight. Shipping uses the STATIC
// zone rates on purpose: the checkout charges live carrier rates, which
// run at or below the zone fallbacks — a listing may overstate shipping,
// never understate it.
{
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
product.Buyable() ? "https://schema.org/InStock"
: product.ComingSoon() ? "https://schema.org/PreOrder"
: "https://schema.org/OutOfStock";
// "NL", then the other 26 EU members, as JSON string lists.
std::string euList;
for (std::string_view cc : Money::EuCountries()) {
if (cc == "NL") continue;
if (!euList.empty()) euList += ',';
euList += JsonStr(cc);
}
// The world tier can't say "everywhere else" in schema.org, so it
// names the non-EU destinations the shop actually sees demand from.
static constexpr std::string_view kWorldSample[] = {
"US", "CA", "GB", "CH", "NO", "AU", "NZ", "JP",
};
std::string worldList;
for (std::string_view cc : kWorldSample) {
if (!worldList.empty()) worldList += ',';
worldList += JsonStr(cc);
}
auto shipTier = [](std::string_view rate, const std::string& dests,
int transitMin, int transitMax) {
return std::format(
R"({{"@type":"OfferShippingDetails",)"
R"("shippingRate":{{"@type":"MonetaryAmount","value":{},"currency":"EUR"}},)"
R"("shippingDestination":{{"@type":"DefinedRegion","addressCountry":[{}]}},)"
R"("deliveryTime":{{"@type":"ShippingDeliveryTime",)"
R"("handlingTime":{{"@type":"QuantitativeValue","minValue":1,"maxValue":7,"unitCode":"DAY"}},)"
R"("transitTime":{{"@type":"QuantitativeValue","minValue":{},"maxValue":{},"unitCode":"DAY"}}}}}})",
JsonStr(rate), dests, transitMin, transitMax);
};
const std::string shippingDetails = "["
+ shipTier(Money::FormatMinor(product.shipNlMinor), JsonStr("NL"), 1, 2) + ","
+ shipTier(Money::FormatMinor(product.shipEuMinor), euList, 2, 5) + ","
+ shipTier(Money::FormatMinor(product.shipWorldMinor), worldList, 5, 14) + "]";
// Returns, matching the terms page: EU consumers get the statutory
// 14-day withdrawal (return shipping theirs); outside the EU sales
// are final except defects, which are warranty, not returns.
std::string euAll;
for (std::string_view cc : Money::EuCountries()) {
if (!euAll.empty()) euAll += ',';
euAll += JsonStr(cc);
}
const std::string returnPolicy = std::format(
R"([{{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow",)"
R"("merchantReturnDays":14,"returnMethod":"https://schema.org/ReturnByMail",)"
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}},)"
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}}])",
euAll, worldList);
const std::string offerTail = std::format(
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}},)"
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
std::string offers;
for (const Variant& v : product.variants) {
if (!offers.empty()) offers += ',';
offers += std::format(
R"({{"@type":"Offer","name":{},"price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(v.label), JsonStr(Money::FormatMinor(v.priceInclMinor)),
availability, JsonStr(productUrl));
R"({{"@type":"Offer","name":{},"sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(v.label), JsonStr(product.slug + "-" + v.slug),
JsonStr(Money::FormatMinor(v.priceInclMinor)));
offers += offerTail;
}
// A variantless product still gets its one offer from the base price.
if (offers.empty() && product.priceInclMinor > 0) {
offers += std::format(
R"({{"@type":"Offer","price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(Money::FormatMinor(product.priceInclMinor)),
availability, JsonStr(productUrl));
R"({{"@type":"Offer","sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(product.slug),
JsonStr(Money::FormatMinor(product.priceInclMinor)));
offers += offerTail;
}
const std::string brand = product.brand.empty()
? std::string{}
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
JsonStr(product.brand));
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"Product",)"
R"("name":{},"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), JsonStr(product.tagline),
R"("name":{},{}"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), brand, JsonStr(product.tagline),
JsonStr(product.image.empty()
? std::string{}
: "https://catcrafts.net" + product.image),