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

@ -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}"