diff --git a/favicon.svg b/favicon.svg index 541d643..f96351c 100644 --- a/favicon.svg +++ b/favicon.svg @@ -5,6 +5,14 @@ prompts are left-aligned. Replaces the bare ">_" tile, which was honest about the terminal but silent about the name. + Head geometry is the "flare" revision: the first cut had a flat crown + between wide ear tufts and a square jaw, which read horned-owl. Now the + ears stand tall over a domed skull, the cheeks taper to a rounded chin + (owl faces stay wide all the way down), and the whiskers spring OUTWARD + from the cheek line into the corners the taper frees up — outside the + face so they never crowd the prompt. Whiskers are drawn at 3.5 against + the face's 5 so they read as hair, not bone. + Drawn as paths, not . A text favicon depends on a font being available at 16px in whatever context the browser rasterises it — including contexts with no font stack at all — and falls back to a blank tile when it is not. @@ -15,17 +23,23 @@ the tab icon drift apart. --> - - + - - + + + - - + - diff --git a/images/catcrafts-logo.png b/images/catcrafts-logo.png index 9d71f12..e7f3b0b 100644 Binary files a/images/catcrafts-logo.png and b/images/catcrafts-logo.png differ diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp index f3e06da..4dc24d6 100644 --- a/server/implementations/main.cpp +++ b/server/implementations/main.cpp @@ -435,6 +435,12 @@ void RunMoneySelfTest() { } Check(ld && ld->IsObject() && ld->Str("@type") == "Product" && offersOk, "schema: product JSON-LD parses, one offer per variant"); + // Merchant-grade fields: shipping, returns, brand, sku — what + // Merchant Center's website-crawl feed reads at launch. + Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos + && pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos + && pp.meta.jsonLd.find("\"sku\"") != std::string::npos, + "schema: offers carry shipping, returns and sku"); } } Check(!Content::Projects().empty(), "content: projects present"); diff --git a/shared/interfaces/Catcrafts.Shared-Content.cppm b/shared/interfaces/Catcrafts.Shared-Content.cppm index 1f05271..3783262 100644 --- a/shared/interfaces/Catcrafts.Shared-Content.cppm +++ b/shared/interfaces/Catcrafts.Shared-Content.cppm @@ -41,6 +41,7 @@ export const std::vector& 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. diff --git a/shared/interfaces/Catcrafts.Shared-Model.cppm b/shared/interfaces/Catcrafts.Shared-Model.cppm index 32b031a..0adb8a7 100644 --- a/shared/interfaces/Catcrafts.Shared-Model.cppm +++ b/shared/interfaces/Catcrafts.Shared-Model.cppm @@ -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 diff --git a/shared/interfaces/Catcrafts.Shared-Money.cppm b/shared/interfaces/Catcrafts.Shared-Money.cppm index aef5581..b2e1efb 100644 --- a/shared/interfaces/Catcrafts.Shared-Money.cppm +++ b/shared/interfaces/Catcrafts.Shared-Money.cppm @@ -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 EuCountries() { static constexpr std::array 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 diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index 69f0e51..5dc6270 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -86,12 +86,13 @@ export SafeHtml RenderNav(RouteKind current) { R"()" @@ -223,10 +224,14 @@ export RenderedPage RenderHome(std::span 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), diff --git a/tools/e2e.sh b/tools/e2e.sh index 92dcea3..29ea754 100755 --- a/tools/e2e.sh +++ b/tools/e2e.sh @@ -212,6 +212,18 @@ else bad "Product schema" "missing, unparseable, or wrong offer count" fi body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer" +# Merchant-grade offer fields: what Merchant Center's website-crawl feed +# reads. Shipping uses the static zone rates (listing may overstate, never +# understate what checkout charges); returns mirror the terms page. +body_has /shop/fp6-pmos 'OfferShippingDetails' "offers carry shipping details" +body_has /shop/fp6-pmos 'MerchantReturnPolicy' "offers carry a return policy" +body_has /shop/fp6-pmos '"sku":"fp6-pmos-green"' "offers carry per-variant skus" +body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand" +if extract_ld /shop/fp6-pmos | jq -e '.offers[0].shippingDetails | length == 3' >/dev/null 2>&1; then + ok "shipping details cover all three zones" +else + bad "shipping zones" "expected NL + EU + world tiers in the first offer" +fi if [ "$SHOP_OPEN" = 1 ]; then body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability" else