This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -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> — 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> — 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 — 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 — {}</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 — 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}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue