coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -46,9 +46,12 @@ export const std::vector<Product>& Products() {
// Launch day is this one line: "coming-soon" -> "available". The page
// shows launch prices either way; only the order form is held back.
p.status = "coming-soon";
p.shipNlMinor = 1500; // zone FALLBACKS — the live
p.shipEuMinor = 2500; // Sendcloud table overrides
p.shipWorldMinor = 5500; // these per country
// Boxed shipping weight, grams: the FP6 retail box (~450 g) plus the
// outer carton and padding. Rounded UP on purpose — this number picks
// the carrier's weight bracket, and guessing light is how an order gets
// quoted a rate the parcel does not qualify for. Worth replacing with a
// scale reading of a real outgoing parcel before launch.
p.shipWeightGrams = 700;
p.image = "/fp6-pmos.jpg";
p.summary = "The Fairphone (Gen. 6), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
p.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
@ -209,7 +212,7 @@ export const std::vector<LegalPage>& LegalPages() {
{ "Orders",
{
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
"Payment happens at Mollie, a Dutch licensed payment provider, on their pages. Catcrafts never sees card numbers or bank credentials. It learns only which order was paid, for how much, and by which method. What Mollie processes about you is between you and Mollie under their own privacy policy.",
"Payment happens on the payment provider's own pages, never here. Choosing bank or card sends you to Mollie, a Dutch licensed payment institution; choosing cryptocurrency sends you to CoinGate, a Lithuanian payment institution licensed under the EU crypto-asset regulation. Either way Catcrafts never sees card numbers, bank credentials or wallet keys, and learns only which order was paid, for how much, and by which method. What the provider you choose processes about you is between you and them under their own privacy policy.",
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
} },
{ "How long it is kept",
@ -221,7 +224,7 @@ export const std::vector<LegalPage>& LegalPages() {
{ "What this site does not do",
{
"No analytics in your browser. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie or CoinGate payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
} },
{ "Server logs",
{
@ -262,14 +265,15 @@ export const std::vector<LegalPage>& LegalPages() {
{
.slug = "terms",
.title = "Terms",
.updated = "2026-08-04",
.updated = "2026-08-13",
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
.sections = {
{ "Ordering and payment",
{
"Submitting the order form creates an order and a Mollie payment link. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored.",
"Submitting the order form creates an order and a payment link with the provider you picked. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A crypto invoice lapses quickly — within hours, and within minutes once a coin is selected — so an unfinished one usually means ordering again rather than waiting.",
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
"Payment is handled by Mollie, a Dutch licensed payment institution. Catcrafts never sees your card number or bank credentials.",
"Payment is handled by a licensed provider, never on this site: bank and card payments by Mollie, a Dutch payment institution, and cryptocurrency by CoinGate, a Lithuanian payment institution authorised under the EU crypto-asset regulation. Catcrafts never sees your card number, bank credentials or wallet keys.",
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. The price is the euro amount; the provider fixes the exchange rate when you open the invoice, and that rate is what settles it. Every refund under the sections below is likewise calculated and owed in euros — if the coin has moved against you in the meantime, the euro figure is still the euro figure. A crypto payment can only be refunded to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
"For support related to orders please contact orders@catcrafts.net"
} },
{ "Fulfilment",
@ -277,6 +281,12 @@ export const std::vector<LegalPage>& LegalPages() {
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
} },
{ "Where Catcrafts sells",
{
"Catcrafts does not sell or ship to the United States or Canada. Checkout refuses a delivery address in either country, and no order can be placed from one. This is a deliberate decision about liability cover, which for a shop this size is written for the world excluding those two countries, and not a judgement about anyone living there.",
"The same applies to an order that is bound for either country by another route: if a parcel is to be forwarded there, or the delivery address belongs to a forwarding service acting for a customer there, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around this — the point is that the sale does not happen, not that the address looks European.",
"Everywhere else Catcrafts ships is served on the terms above, and the software itself remains free for anyone anywhere: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.",
} },
{ "Warranty",
{
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",

View file

@ -25,6 +25,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
export module Catcrafts.Shared:Form;
import std;
import :Money; // country policy: which destinations the shop sells to
namespace Catcrafts::Form {
@ -159,7 +160,7 @@ export bool LooksLikeEmail(std::string_view s) {
}
// ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there
// is a policy question answered elsewhere, not a validation one.
// is a policy question, answered by Money::SellsTo in ValidateCheckout below.
export bool LooksLikeCountryCode(std::string_view s) {
if (s.size() != 2) return false;
for (const char c : s) {
@ -192,14 +193,100 @@ export struct Checkout {
std::string color; // variant slug; whether it EXISTS is the handler's
// check against the catalogue, not a shape check
std::int64_t quantity = 1;
std::string payChoice; // kPayBank | kPayCrypto; empty means the form did
// not offer a choice, which the handler reads as
// bank. Whether the chosen rail is CONFIGURED is
// the handler's check, like the colour: this is a
// shape check and nothing more.
};
// A technical sanity bound, not a business cap — bulk orders are welcome.
// It exists because the integer math (here and mirrored in the preview
// script) and a bunq payment link both need SOME ceiling, and an order of a
// hundred phones deserves an email conversation more than a form submit.
// How the buyer's money moves. Two KINDS of money movement, not two brand
// names: which provider serves each is the server's configuration, and writing
// the kind (rather than "mollie"/"coingate") into the form and the ledger means
// swapping a provider cannot retroactively rewrite what a buyer picked.
//
// These strings are the wire format — they travel in the form post and land
// verbatim in the order log — so they are defined once, here, where both the
// form that emits them and the server that stores them can see them.
export inline constexpr std::string_view kPayBank = "bank";
export inline constexpr std::string_view kPayCrypto = "crypto";
// The outer sanity bound on the parsed integer — NOT the quantity a buyer can
// actually order. That limit is physical and per-destination: one order is one
// parcel, so what fits is the heaviest carrier bracket for that country
// divided by the boxed unit weight (Money::MaxUnitsFor). The form renders the
// best case across destinations as its `max`, the preview narrows it the
// moment a country is typed, and the handler enforces the real one.
//
// This constant survives because validation has to reject a hostile
// "quantity=99999999999" before any of that arithmetic runs.
export inline constexpr std::int64_t kMaxQuantity = 99;
// What a buyer in a NoSaleCountries destination is told. Stated as a standing
// fact about where the shop sells, not as an apology or an outage: someone
// there should close the tab rather than retry tomorrow or hunt for a
// workaround. The reason itself (insurance territory) is on the terms page —
// a form field is the wrong place for it.
//
// One definition, three renderings: this error, the note above the form, and
// the on-page total preview, so the page can never encourage an order the
// server will refuse.
export inline constexpr std::string_view kNoSaleMessage =
"Catcrafts does not sell or ship to the United States or Canada.";
// The two shipping refusals, worded once.
//
// Since the shop stopped carrying its own rate table, the carrier's coverage
// IS the shop's coverage: no bracket for a country means no price exists to
// charge, and a parcel above every bracket is one the carrier will not take.
// Both refuse rather than guess — a quote the shop cannot honour is worse than
// a no — and both name a way forward, because a bare "can't" makes the buyer
// guess whether to try again or give up.
//
// Kept as {cc}/{n} templates rather than format strings because they have two
// consumers: the handler fills them for its field errors, and the buy page
// hands them to the total preview verbatim to fill client-side. Same sentence
// before and after the submit, from one definition.
export inline constexpr std::string_view kNoShippingTemplate =
"No carrier rate for {cc} is available right now, so this order can't be "
"priced. Email orders@catcrafts.net and it gets arranged by hand.";
export inline constexpr std::string_view kTooHeavyTemplate =
"That is more than fits one parcel to {cc} — up to {n} per order. For a "
"larger order email orders@catcrafts.net.";
// The degenerate case: a destination whose heaviest bracket does not even carry
// one boxed unit. "Order fewer" is not advice when fewer is zero, so it gets
// its own sentence.
export inline constexpr std::string_view kTooHeavyNoneTemplate =
"A parcel this heavy can't be shipped to {cc} by any rate available. "
"Email orders@catcrafts.net.";
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
std::int64_t n) {
std::string out(tmpl);
for (const auto& [token, value] :
std::initializer_list<std::pair<std::string_view, std::string>>{
{ "{cc}", std::string(cc) }, { "{n}", std::to_string(n) } }) {
for (std::size_t at = out.find(token); at != std::string::npos;
at = out.find(token, at + value.size())) {
out.replace(at, token.size(), value);
}
}
return out;
}
export std::string NoShippingMessage(std::string_view cc) {
return FillShipMessage(kNoShippingTemplate, cc, 0);
}
// `maxUnits` is what the heaviest bracket to that country actually fits, and
// may be zero.
export std::string TooHeavyMessage(std::string_view cc, std::int64_t maxUnits) {
return FillShipMessage(maxUnits > 0 ? kTooHeavyTemplate : kTooHeavyNoneTemplate,
cc, maxUnits);
}
export struct CheckoutResult {
Checkout value;
std::vector<FieldError> errors;
@ -259,6 +346,10 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
} else if (!LooksLikeCountryCode(country)) {
r.errors.push_back({ "country", "Country must be a two-letter code." });
} else if (!Money::SellsTo(r.value.country)) {
// The refusal happens here, in validation, rather than at the payment
// step: no order record, no payment link, nothing charged to undo.
r.errors.push_back({ "country", std::string(kNoSaleMessage) });
}
// Colour: shape only (slug-ish, bounded). Whether it names a variant that
@ -269,6 +360,20 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
r.errors.push_back({ "color", "That is not one of the colours." });
}
// How they want to pay. Absent means the form did not render the choice
// (only one rail configured, or the no-JS fallback page), which the
// handler resolves to bank.
//
// An unrecognised value is rejected rather than defaulted: the two ways it
// can happen are a tampered post and a form that has drifted from this
// validator, and quietly charging someone through a rail they did not pick
// is the wrong answer to both.
const std::string_view pay = Trim(f.Get("pay"));
r.value.payChoice = std::string(pay);
if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) {
r.errors.push_back({ "pay", "Pick one of the payment methods." });
}
// Quantity: a small positive integer, nothing else. Absent means 1 (the
// no-JS form default); anything unparseable or out of range is rejected
// rather than clamped — silently changing how many phones someone buys is

View file

@ -206,12 +206,13 @@ export struct Product {
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;
// Shipping weight of ONE unit as it leaves here — device, retail box and
// outer packaging — in grams. There are no shipping prices in this record
// any more: the carrier prices by weight bracket, so this number plus the
// quantity is what selects a rate from the live Sendcloud table, and what
// decides how many units fit one parcel. A destination the carrier has no
// bracket for is refused at checkout rather than priced from a guess.
std::int64_t shipWeightGrams = 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.
@ -291,7 +292,11 @@ export struct OrderView {
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 payUrl; // the provider's hosted checkout; empty once paid
// or when cancelled
std::string payChoice; // Form::kPayBank | Form::kPayCrypto — decides what
// the "resume payment" copy promises is waiting
// there. Anything but crypto reads as bank.
std::string createdAt; // ISO 8601, shown verbatim
std::string country;
std::int64_t goodsMinor = 0;

View file

@ -46,8 +46,8 @@ export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
return (netMinor * (10000 + rateBp) + 5000) / 10000;
}
// "580.00" — the wire format bunq's amount objects use, and the unambiguous
// way to show cents. Always two decimals, no thousands separator.
// "580.00" — the wire format both payment providers quote amounts in, and the
// unambiguous way to show cents. Always two decimals, no thousands separator.
export std::string FormatMinor(std::int64_t minor) {
const bool neg = minor < 0;
if (neg) minor = -minor;
@ -82,9 +82,33 @@ 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
// distinctions than anyone wants in a checkout, and the tiers only need to be
// roughly right because the rates are set per product with margin.
// Destinations this shop refuses outright.
//
// Not a carrier problem — parcels reach both fine, and the rate tables price
// them. It is an insurance boundary: liability cover for a Dutch shop is
// written "worldwide excluding USA/Canada", and a phone carrying a lithium
// battery and a replaced OS is precisely the product-liability exposure that
// exclusion exists for. A sale into either country would therefore be
// uninsured, with the buyer's own courts deciding the damages, so checkout
// declines it instead of pricing it. Declining also keeps Catcrafts from
// having marketed into those jurisdictions at all, which is the other half of
// why the answer is no rather than a surcharge.
export std::span<const std::string_view> NoSaleCountries() {
static constexpr std::array<std::string_view, 2> blocked{ "US", "CA" };
return blocked;
}
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
// rather than comparing against "US" themselves, so the policy has exactly one
// definition and adding a country later is a one-line change.
export bool SellsTo(std::string_view cc) {
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
}
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
// (see ShipBracket below). This exists because Sendcloud's method list carries
// no transit estimate, so the "1-2 / 2-5 / 5-14 days" the listing publishes is
// ours to state, and distance is the only thing it can reasonably key on.
export enum class Zone { Nl, Eu, World };
export Zone ZoneFor(std::string_view cc) {
@ -92,6 +116,69 @@ export Zone ZoneFor(std::string_view cc) {
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
}
// ── carrier rates ─────────────────────────────────────────────────────
//
// One weight bracket of one carrier method: what a parcel up to
// `maxWeightGrams` costs to this country, in cents, already grossed up to the
// consumer price (the server does that once, when it builds the table).
//
// Brackets exist because a carrier prices by weight, and Sendcloud lists the
// same service once per band — so a country's rates arrive as a ladder, not a
// single number. Nothing here is hardcoded: an empty ladder means the shop
// cannot ship there, which is a refusal, not a fallback.
export struct ShipBracket {
std::int64_t maxWeightGrams = 0;
std::int64_t minor = 0;
};
// One destination's ladder. Kept as a flat vector of these rather than a map
// so the table is trivially serialisable and the order the carrier gave is
// preserved.
export struct ShipRates {
std::string cc;
std::vector<ShipBracket> brackets;
};
// The rate for a parcel of `grams` to a destination whose ladder this is, or 0
// when nothing covers it — too heavy, or no rates at all.
//
// The cheapest bracket that can CARRY the weight wins, which is not always the
// tightest one: a carrier's 20 kg band is occasionally priced below its 10 kg
// band, and quoting the higher of the two would overcharge for a parcel both
// accept. A band always accepts a parcel lighter than its maximum, so this
// stays bookable at whatever it quotes.
export std::int64_t RateFor(std::span<const ShipBracket> ladder, std::int64_t grams) {
std::int64_t best = 0;
for (const ShipBracket& b : ladder) {
if (b.maxWeightGrams < grams) continue;
if (best == 0 || b.minor < best) best = b.minor;
}
return best;
}
// How many units of `unitGrams` fit the heaviest bracket this destination has.
// The quantity ceiling the buy form offers and the checkout enforces: one
// order is one parcel, so anything above this is an email conversation rather
// than a quote the shop cannot honour.
export std::int64_t MaxUnitsFor(std::span<const ShipBracket> ladder,
std::int64_t unitGrams) {
if (unitGrams <= 0) return 0;
std::int64_t heaviest = 0;
for (const ShipBracket& b : ladder) heaviest = std::max(heaviest, b.maxWeightGrams);
return heaviest / unitGrams;
}
// Ladder lookup across a whole table. Linear because the table is one entry
// per country the method covers — a couple of hundred at most, walked once per
// checkout.
export std::span<const ShipBracket> LadderFor(std::span<const ShipRates> table,
std::string_view cc) {
for (const ShipRates& r : table) {
if (r.cc == cc) return r.brackets;
}
return {};
}
// One order's money, fully derived. `goods` is what the buyer pays for the
// device: the VAT-inclusive price inside the EU, the derived net outside it.
// `vatCharged` is what the total contains in Dutch VAT — zero for exports —
@ -106,8 +193,9 @@ export struct Totals {
// The single authority on what an order costs. The checkout handler calls this
// with the buyer's country; nothing about the amount ever comes from the
// client. `shippingMinor` arrives already resolved (live carrier table or the
// zone fallback — the server decides which), so this function stays pure.
// client. `shippingMinor` arrives already resolved from the carrier table (see
// RateFor) — an order with no carrier rate is refused before it gets here, so
// this function never has to invent a price and stays pure.
//
// The export net is derived from the LINE total (unit × qty), not per unit —
// rounding per line is the invoice-correct convention, and it is also the
@ -134,18 +222,10 @@ export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
return t;
}
// The zone-table shipping fallback, used when no live carrier table covers the
// destination. Exported separately so the same lookup renders the shipping
// table on the product page.
export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu,
std::int64_t shipWorld, std::string_view country) {
const Zone z = ZoneFor(country);
return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld;
}
// ── indicative currency display ───────────────────────────────────────
//
// Orders are charged in euros, always — bunq collects EUR and the invoice is
// Orders are charged in euros, always — both rails collect EUR (CoinGate
// converts the coin at a locked rate and settles euro) and the invoice is
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
// their card will actually take, so the order page also shows an INDICATIVE
// conversion in the buyer's national currency, from ECB reference rates baked
@ -168,11 +248,12 @@ export struct CurrencyRow {
// Only currencies the ECB publishes reference rates for; anywhere else shows
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
// is noise.
// is noise. So are USD and CAD: NoSaleCountries means no order can ever be
// charged from those countries, and quoting a visitor a friendly price in
// their own currency before refusing them at checkout is both a worse
// experience and the kind of localisation that reads as marketing there.
export std::span<const CurrencyRow> AllCurrencies() {
static constexpr std::array<CurrencyRow, 16> rows{{
{ "US", { "USD", "US$" } },
{ "CA", { "CAD", "CA$" } },
static constexpr std::array<CurrencyRow, 14> rows{{
{ "GB", { "GBP", "£" } },
{ "CH", { "CHF", "CHF " } },
{ "NO", { "NOK", "kr " } },

View file

@ -685,13 +685,22 @@ SafeHtml CustomsNote() {
//
// `errors` re-renders the form with the previous values preserved. Losing a
// filled-in form on a validation error is the fastest way to lose the person.
// `liveShipping` is the carrier rate table (country -> cents) when the server
// has one; empty otherwise. It feeds the data-cc blob below so the on-page
// total preview uses the exact numbers checkout will charge.
// `liveShipping` is the carrier rate table (country -> weight-bracket ladder)
// when the server has one; empty otherwise. It feeds the data-cc blob below so
// the on-page total preview picks the exact bracket checkout will charge — and
// refuses in exactly the places checkout refuses, since with no zone fallback
// left there are now destinations and quantities that have no price at all.
// `offerCrypto` renders the payment-method choice. It is false whenever the
// crypto rail is not configured — and on the wasm fallback page, which cannot
// know — so the form only ever advertises a way to pay that the server can
// actually serve. With it false the form posts no `pay` field at all and the
// handler takes the bank rail, which is exactly the behaviour that existed
// before there was anything to choose.
SafeHtml RenderCheckoutForm(const Product& product,
std::span<const std::pair<std::string, std::int64_t>> liveShipping,
std::span<const Money::ShipRates> liveShipping,
std::span<const Form::FieldError> errors,
const Form::Checkout& prev) {
const Form::Checkout& prev,
bool offerCrypto) {
auto errorFor = [&](std::string_view field) -> SafeHtml {
for (const Form::FieldError& e : errors) {
if (e.field == field) {
@ -724,37 +733,103 @@ SafeHtml RenderCheckoutForm(const Product& product,
}
// Everything the total preview may show, pre-computed server-side into one
// JSON attribute: per-colour unit prices, zone rates, the live carrier
// table. The script multiplies and adds — it invents no number, so the
// JSON attribute: per-colour unit prices, the boxed unit weight, and the
// live carrier table as country -> [[maxGrams, cents], …]. The script
// multiplies, picks a bracket and adds — it invents no number, so the
// preview and the charge come from the same integers.
std::string cc = R"({"v":{)";
for (std::size_t i = 0; i < product.variants.size(); ++i) {
cc += std::format(R"({}"{}":{})", i ? "," : "",
product.variants[i].slug, product.variants[i].priceInclMinor);
}
cc += std::format(R"(}},"from":{},"s":{{"nl":{},"eu":{},"w":{}}},"c":{{)",
product.priceInclMinor, product.shipNlMinor,
product.shipEuMinor, product.shipWorldMinor);
cc += std::format(R"(}},"from":{},"g":{},"c":{{)",
product.priceInclMinor, product.shipWeightGrams);
for (std::size_t i = 0; i < liveShipping.size(); ++i) {
cc += std::format(R"({}"{}":{})", i ? "," : "",
liveShipping[i].first, liveShipping[i].second);
cc += std::format(R"({}"{}":[)", i ? "," : "", liveShipping[i].cc);
for (std::size_t b = 0; b < liveShipping[i].brackets.size(); ++b) {
cc += std::format("{}[{},{}]", b ? "," : "",
liveShipping[i].brackets[b].maxWeightGrams,
liveShipping[i].brackets[b].minor);
}
cc += ']';
}
// The two shipping refusals, as the same {cc}/{n} templates the handler
// fills for its field errors — so the page cannot word a refusal
// differently from the one that follows a submit.
cc += std::format(R"(}},"q":{},"nm":{},"hm":{},"hm0":{})",
Form::kMaxQuantity,
JsonStr(Form::kNoShippingTemplate),
JsonStr(Form::kTooHeavyTemplate),
JsonStr(Form::kTooHeavyNoneTemplate));
// The destinations checkout refuses, and the sentence that says so. The
// preview has to refuse exactly where the server does — a page that quotes
// a total for an order the server will reject is worse than one that never
// quoted it.
// The most units any destination's heaviest bracket can carry, clamped to
// the parsing ceiling. With no table (the wasm fallback path) this stays at
// kMaxQuantity — that page cannot quote a total or reach checkout anyway,
// so narrowing its input would be theatre.
std::int64_t bestUnits = 0;
for (const Money::ShipRates& r : liveShipping) {
bestUnits = std::max(bestUnits,
Money::MaxUnitsFor(r.brackets, product.shipWeightGrams));
}
if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity;
cc += R"(,"x":[)";
for (std::size_t i = 0; i < Money::NoSaleCountries().size(); ++i) {
if (i) cc += ',';
cc += JsonStr(Money::NoSaleCountries()[i]);
}
cc += std::format(R"(],"xm":{}}})", JsonStr(Form::kNoSaleMessage));
// The payment choice. A radio group rather than a <select> because both
// options carry a sentence the buyer should read BEFORE choosing — one
// settles in euro from their bank, the other locks a euro price against a
// coin — and a collapsed dropdown hides exactly that. It also needs no
// JavaScript, like everything else in this form.
//
// Bank is pre-selected: it is what nearly every buyer wants, and an
// unselected group would let a distracted submit land on neither.
SafeHtml payFieldset;
if (offerCrypto) {
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
payFieldset = Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> &mdash; iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; Bitcoin and Lightning, )"
R"(stablecoins and the other coins CoinGate lists. You pay the euro )"
R"(total at the exchange rate CoinGate locks when you open the )"
R"(invoice; crypto invoices expire quickly, so pay soon after )"
R"(ordering or simply order again.</span></label>)"
R"({})"
R"(</fieldset>)",
Attr("value", std::string(Form::kPayBank)),
wantsCrypto ? SafeHtml{} : Raw(" checked"),
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
errorFor("pay"));
}
cc += std::format(R"(}},"q":{}}})", Form::kMaxQuantity);
return Format(
R"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Buy one</h2>)"
R"(<p class="checkout__lede">Submitting creates the order and takes you )"
R"(straight to the payment page: iDEAL, card, or a plain bank transfer, )"
R"(handled by Mollie. Nothing is owed until you actually pay; an unpaid order )"
R"(just lapses. The address is used to ship this order and for the invoice, )"
R"(and for nothing else.</p>)"
R"(straight to the payment page{}. Nothing is owed until you actually pay; )"
R"(an unpaid order just lapses. The address is used to ship this order and )"
R"(for the invoice, and for nothing else.</p>)"
// No static rate table: real shipping is priced per country from the
// carrier data and shown live in the total below once a country is
// entered. A three-zone summary next to exact rates was misinformation.
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
R"(rates. Enter your country below and the exact total appears before )"
R"(you order.</p>)"
R"(you order. {}</p>)"
R"({})"
R"({})"
R"(<form class="form" method="post"{}{} novalidate>)"
@ -765,9 +840,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
R"({})"
R"(</div>)"
R"(<div class="field">)"
// A free number input, not a dropdown: bulk orders are welcome. The
// min/max mirror the server's validation bounds; kMaxQuantity is a
// technical ceiling, not a sales policy.
// A free number input, not a dropdown. The max is the BEST case across
// destinations (see bestUnits above): the real ceiling depends on the
// country's heaviest carrier bracket, so a stricter number here would
// block orders that are perfectly shippable somewhere else. The preview
// narrows it as soon as a country is typed, and the handler enforces it.
R"(<label for="f-qty">Quantity</label>)"
R"(<input id="f-qty" name="quantity" type="number" inputmode="numeric" )"
R"(min="1"{}{}>)"
@ -804,9 +881,10 @@ SafeHtml RenderCheckoutForm(const Product& product,
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
R"(maxlength="2" placeholder="NL" required{}>)"
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
R"(price includes VAT.</p>)"
R"(price includes VAT. No US or CA — see the terms.</p>)"
R"({})"
R"(</div>)"
R"({})"
// Honeypot: off-screen rather than display:none, because some bots skip
// hidden inputs. aria-hidden + tabindex keeps it away from screen
// readers and the keyboard, so no real person can reach it.
@ -822,13 +900,18 @@ SafeHtml RenderCheckoutForm(const Product& product,
R"(<button class="btn btn--primary" type="submit">Order &mdash; continue to payment</button>)"
R"(</form>)"
R"(</section>)",
// With the choice rendered below, the fieldset lists the methods and
// the lede would only repeat half of them.
offerCrypto ? SafeHtml{}
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
Escape(Form::kNoSaleMessage),
CustomsNote(),
formError,
Url("action", "/shop/" + product.slug + "#buy"),
Attr("data-cc", cc),
Attr("value", product.slug),
Join(colorOpts), errorFor("color"),
Attr("max", std::to_string(Form::kMaxQuantity)),
Attr("max", std::to_string(bestUnits)),
Attr("value", std::to_string(prev.quantity)),
errorFor("quantity"),
Attr("value", prev.email), errorFor("email"),
@ -836,14 +919,19 @@ SafeHtml RenderCheckoutForm(const Product& product,
Attr("value", prev.street), errorFor("street"),
Attr("value", prev.postal), errorFor("postal"),
Attr("value", prev.city), errorFor("city"),
Attr("value", prev.country), errorFor("country"));
Attr("value", prev.country), errorFor("country"),
payFieldset);
}
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
// defaults to false. Only the native server passes it true, because only the
// server knows whether the crypto rail is configured.
export RenderedPage RenderProduct(const Product& product,
const Rates& rates,
std::span<const std::pair<std::string, std::int64_t>> liveShipping = {},
std::span<const Money::ShipRates> liveShipping = {},
std::span<const Form::FieldError> errors = {},
const Form::Checkout& prev = {}) {
const Form::Checkout& prev = {},
bool offerCrypto = false) {
std::vector<SafeHtml> specRows;
for (const Spec& s : product.specs) {
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
@ -871,9 +959,10 @@ export RenderedPage RenderProduct(const Product& product,
// 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 — productGroupID is what it
// maps to item_group_id. 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.
// maps to item_group_id. Shipping is published from the live carrier table
// at single-unit weight, the same integers checkout charges, so the listing
// and the till cannot disagree; a destination with no carrier rate is
// simply not advertised, because it is not for sale.
{
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
@ -881,23 +970,37 @@ export RenderedPage RenderProduct(const Product& product,
: 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);
// Every destination this listing may advertise: the carrier has a rate
// for a single boxed unit, and the shop is willing to sell there.
// US and CA drop out by policy, not oversight (Money::SellsTo):
// listing a shipping rate to a country checkout refuses would publish
// an offer that cannot be accepted, and feed it to shopping crawlers
// as an invitation to buy from there. Everywhere else drops out
// because no carrier rate exists — which is now the same sentence.
struct FeedDest { std::string cc; std::int64_t rate; Money::Zone zone; };
std::vector<FeedDest> dests;
for (const Money::ShipRates& r : liveShipping) {
if (!Money::SellsTo(r.cc)) continue;
const std::int64_t rate = Money::RateFor(r.brackets, product.shipWeightGrams);
if (rate > 0) dests.push_back({ r.cc, rate, Money::ZoneFor(r.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);
// One OfferShippingDetails per (transit tier, price) — the rates are
// real per-country carrier prices now, so the grouping is whatever the
// carrier's pricing happens to be rather than three tiers decided here.
// Transit times still key on distance because Sendcloud's method list
// carries no delivery estimate to read.
std::vector<std::pair<std::pair<Money::Zone, std::int64_t>, std::string>> groups;
for (const FeedDest& d : dests) {
const auto key = std::make_pair(d.zone, d.rate);
auto at = std::ranges::find(groups, key, &decltype(groups)::value_type::first);
if (at == groups.end()) {
groups.push_back({ key, JsonStr(d.cc) });
} else {
at->second += ',' + JsonStr(d.cc);
}
}
auto shipTier = [](std::string_view rate, const std::string& dests,
int transitMin, int transitMax) {
return std::format(
@ -909,27 +1012,60 @@ export RenderedPage RenderProduct(const Product& product,
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) + "]";
// Empty when there is no rate table — the wasm fallback render, or a
// server that has never reached Sendcloud. Publishing nothing is right:
// the alternative is inventing a shipping price for a feed, which is
// the exact claim this shop can no longer make.
std::string shippingDetails;
for (const auto& [key, list] : groups) {
const auto [zone, rate] = key;
const int tmin = zone == Money::Zone::Nl ? 1 : zone == Money::Zone::Eu ? 2 : 5;
const int tmax = zone == Money::Zone::Nl ? 2 : zone == Money::Zone::Eu ? 5 : 14;
if (!shippingDetails.empty()) shippingDetails += ',';
shippingDetails += shipTier(Money::FormatMinor(rate), list, tmin, tmax);
}
if (!shippingDetails.empty()) shippingDetails = "[" + shippingDetails + "]";
// 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);
// are final except defects, which are warranty, not returns. Both
// lists name the destinations actually being offered, so the return
// terms cover exactly the countries the shipping block advertises.
std::string euAll, worldList;
for (const FeedDest& d : dests) {
std::string& into = Money::IsEuCountry(d.cc) ? euAll : worldList;
if (!into.empty()) into += ',';
into += JsonStr(d.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);
// Each half is emitted only if some offered destination falls under it
// — an "applicableCountry":[] policy states a rule that applies to
// nobody, which is worse than staying silent.
std::string returnPolicy;
if (!euAll.empty()) {
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"}})",
euAll);
}
if (!worldList.empty()) {
if (!returnPolicy.empty()) returnPolicy += ',';
returnPolicy += std::format(
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}})",
worldList);
}
if (!returnPolicy.empty()) returnPolicy = "[" + returnPolicy + "]";
// Both blocks describe destinations, so both disappear together when
// there are none to describe.
const std::string fulfilment =
shippingDetails.empty() && returnPolicy.empty()
? std::string{}
: std::format(R"(,"shippingDetails":{},"hasMerchantReturnPolicy":{})",
shippingDetails.empty() ? "[]" : shippingDetails,
returnPolicy.empty() ? "[]" : returnPolicy);
const std::string offerTail = std::format(
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
@ -938,9 +1074,8 @@ export RenderedPage RenderProduct(const Product& product,
// what carries that registration onto the offer instead of
// leaving a bare name a consumer has to resolve by string match.
R"("url":{},"seller":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
R"("@type":"Organization","name":"Catcrafts"}}{}}})",
availability, JsonStr(productUrl), fulfilment);
const std::string brand = product.brand.empty()
? std::string{}
@ -1011,7 +1146,7 @@ export RenderedPage RenderProduct(const Product& product,
SafeHtml buy;
if (product.Buyable()) {
buy = RenderCheckoutForm(product, liveShipping, errors, prev);
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
} else if (product.ComingSoon()) {
// The launch prices are already public, per colour, with the same
// money terms the live form will carry. Only the form is held back,
@ -1107,24 +1242,35 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
// so it reads as "resume", not as an alarming limbo.
SafeHtml payBlock;
if (awaiting && !o.payUrl.empty()) {
const bool crypto = o.payChoice == Form::kPayCrypto;
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above; your bank or card sets the actual conversion )"
R"(rate.</p>)",
Escape(indicative));
// What is waiting behind the button differs by rail, and so does what
// "left uncompleted" costs the buyer: a Mollie payment can be resumed
// for a good while, a crypto invoice expires in hours or minutes. A
// page that promised the crypto buyer their link would keep would be
// lying to exactly the person most likely to come back to it late.
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Complete your payment</h2>)"
R"({})"
R"(<p><a class="btn btn--primary" rel="noreferrer"{}>Resume payment &mdash; {}</a></p>)"
R"(<p class="order__note">The payment page offers iDEAL, cards and a bank )"
R"(transfer; your order reference is <strong>{}</strong>. If you just paid, )"
R"(this page confirms it within seconds. A payment left uncompleted simply )"
R"(lapses the order. Nothing is owed.</p>)"
R"(<p class="order__note">{} your order reference is <strong>{}</strong>. )"
R"(If you just paid, this page confirms it within seconds. {} Nothing )"
R"(is owed.</p>)"
R"(</section>)",
indicativeLine,
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
Escape(o.reference));
crypto ? Raw("The invoice takes Bitcoin, Lightning, stablecoins and the "
"other coins CoinGate lists;")
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
Escape(o.reference),
crypto ? Raw("Crypto invoices expire quickly &mdash; if this one has, "
"the order simply lapses and you can order again.")
: Raw("A payment left uncompleted simply lapses the order."));
} else if (o.status == "paid") {
payBlock = Format(
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
@ -1596,13 +1742,9 @@ inline constexpr std::string_view kGeoPriceHintScript =
"\"Europe/Sofia\":\"bgn\","
"\"Europe/London\":\"gbp\",\"Europe/Zurich\":\"chf\",\"Europe/Oslo\":\"nok\","
"\"Atlantic/Reykjavik\":\"isk\",\"Asia/Tokyo\":\"jpy\","
"\"America/Toronto\":\"cad\",\"America/Vancouver\":\"cad\",\"America/Edmonton\":\"cad\","
"\"America/Winnipeg\":\"cad\",\"America/Halifax\":\"cad\",\"America/St_Johns\":\"cad\","
"\"America/Regina\":\"cad\",\"America/Moncton\":\"cad\",\"America/Whitehorse\":\"cad\","
"\"America/Yellowknife\":\"cad\",\"America/Iqaluit\":\"cad\","
"\"America/New_York\":\"usd\",\"America/Chicago\":\"usd\",\"America/Denver\":\"usd\","
"\"America/Los_Angeles\":\"usd\",\"America/Phoenix\":\"usd\",\"America/Anchorage\":\"usd\","
"\"America/Detroit\":\"usd\",\"America/Boise\":\"usd\",\"Pacific/Honolulu\":\"usd\","
// No America/* zones: USD and CAD left AllCurrencies with the sale itself,
// so a visitor there reads the plain euro export price like anywhere the
// shop has no local currency for.
"\"Australia/Sydney\":\"aud\",\"Australia/Melbourne\":\"aud\",\"Australia/Brisbane\":\"aud\","
"\"Australia/Perth\":\"aud\",\"Australia/Adelaide\":\"aud\",\"Australia/Hobart\":\"aud\","
"\"Australia/Darwin\":\"aud\",\"Pacific/Auckland\":\"nzd\"};"
@ -1615,10 +1757,17 @@ inline constexpr std::string_view kGeoPriceHintScript =
"if(v)els[i].textContent=v;"
"}"
// The live checkout total. Reads only the data-cc blob the server rendered
// (unit prices per colour, zone rates, live carrier table) and mirrors
// ComputeTotals exactly: line total, floor((x*10000+6050)/12100) for the
// export net, shipping by country then zone. Same integers, same formula,
// so this preview and the charged amount cannot disagree.
// (unit prices per colour, boxed unit weight, the carrier's per-country
// weight-bracket ladder) and mirrors ComputeTotals exactly: line total,
// floor((x*10000+6050)/12100) for the export net, and shipping from the
// cheapest bracket that carries qty × weight — the same rule Money::RateFor
// applies server-side. Same integers, same formula, so this preview and the
// charged amount cannot disagree.
//
// It also has to REFUSE where checkout refuses, which since the zone
// fallback went away is a real case rather than a theoretical one: no
// ladder for the country, or no bracket heavy enough for the quantity. The
// messages are the server's own templates, filled here.
"var f=document.querySelector(\"form[data-cc]\");"
"if(f){"
"var d=JSON.parse(f.getAttribute(\"data-cc\"));"
@ -1633,9 +1782,29 @@ inline constexpr std::string_view kGeoPriceHintScript =
"var qty=qe?parseInt(qe.value,10)||1:1;"
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
// Refused destination: say so where the total would have been, instead of
// pricing an order the server will decline.
"if(d.x&&d.x.indexOf(k)>-1){if(out){out.textContent=d.xm;out.hidden=false}return}"
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
"var ship=(d.c&&d.c[k])||(k===\"NL\"?d.s.nl:eu?d.s.eu:d.s.w);"
// No ladder for this destination: there is no price, and saying so beats
// quoting a total the submit would then reject.
"var lad=d.c&&d.c[k];"
"var say=function(m){if(out){out.textContent=m;out.hidden=false}};"
"if(!lad){say(d.nm.split(\"{cc}\").join(k));return}"
// Cheapest bracket that carries the whole order, and the heaviest bracket
// there is — the second one turns into \"up to N per order\" when nothing
// carries this many.
"var g=qty*d.g,ship=0,top=0;"
"for(var i=0;i<lad.length;i++){"
"if(lad[i][0]>=g&&(ship===0||lad[i][1]<ship))ship=lad[i][1];"
"if(lad[i][0]>top)top=lad[i][0];"
"}"
"if(!ship){"
"var fits=d.g>0?Math.floor(top/d.g):0;"
"say(fits>0?d.hm.split(\"{cc}\").join(k).split(\"{n}\").join(fits)"
":d.hm0.split(\"{cc}\").join(k));return"
"}"
"if(out){out.textContent=\"You pay \"+fmt(goods+ship)+\" \\u2014 \"+fmt(goods)"
"+(qty>1?\" (\"+qty+\"\\u00d7)\":\"\")+\" + \"+fmt(ship)+\" shipping, \""
"+(eu?\"incl. VAT\":\"ex VAT\");out.hidden=false}"