This commit is contained in:
parent
fe489673be
commit
b49042a626
8 changed files with 143 additions and 28 deletions
28
favicon.svg
28
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 <text>. 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.
|
||||
-->
|
||||
<rect width="64" height="64" rx="12" fill="#0b0d10"/>
|
||||
<!-- head: flat crown between two ears, rounded jaw -->
|
||||
<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"
|
||||
<!-- head: tall ears, domed crown, cheeks tapering to a rounded chin -->
|
||||
<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"
|
||||
fill="none" stroke="#4cc2ff" stroke-width="5" stroke-linejoin="round"/>
|
||||
<!-- eyes: the ^ ^ of a happy ASCII cat -->
|
||||
<path d="M17 34 L23 27 L29 34 M35 34 L41 27 L47 34"
|
||||
<!-- whiskers: outside the silhouette, anchored to the cheek line -->
|
||||
<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49"
|
||||
fill="none" stroke="#4cc2ff" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<!-- eyes: the ^ ^ of a happy ASCII cat. Two units above the first cut so
|
||||
the > keeps clear air after the prompt moved up away from the chin. -->
|
||||
<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32"
|
||||
fill="none" stroke="#4cc2ff" stroke-width="5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- mouth: the shell prompt -->
|
||||
<path d="M23 40 L29 45 L23 50"
|
||||
<!-- mouth: the shell prompt. Two units higher than the first cut — the
|
||||
tapered jaw curves inward through the y48-52 band the glyphs' stroke
|
||||
edges used to occupy, and they visibly collided with the chin. -->
|
||||
<path d="M23 38 L29 43 L23 48"
|
||||
fill="none" stroke="#e6e8ec" stroke-width="5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M34 50 L42 50"
|
||||
<path d="M34 48 L42 48"
|
||||
fill="none" stroke="#e6e8ec" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 67 KiB |
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
12
tools/e2e.sh
12
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue