This commit is contained in:
parent
fe489673be
commit
b49042a626
8 changed files with 143 additions and 28 deletions
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue