This commit is contained in:
parent
749f525f83
commit
b0666841f6
7 changed files with 139 additions and 31 deletions
|
|
@ -291,7 +291,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-14",
|
||||
.updated = "2026-08-15",
|
||||
.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",
|
||||
|
|
@ -311,6 +311,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
"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.",
|
||||
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike the paragraphs above this is not a choice: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes — a forwarding address, a reseller, or a purchase made on behalf of someone there — just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -234,22 +234,33 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
|||
export inline constexpr std::string_view kNoSaleMessage =
|
||||
"Catcrafts does not sell or ship to the United States or Canada.";
|
||||
|
||||
// The sanctions refusal, in different words on purpose: kNoSaleMessage states
|
||||
// a choice, this states a prohibition. Naming the reason here rather than only
|
||||
// on the terms page — unlike the insurance one — because "sanctions" is the
|
||||
// whole answer: nothing about the shop could change it, and a buyer told only
|
||||
// "no" would rightly ask why.
|
||||
export inline constexpr std::string_view kSanctionsMessage =
|
||||
"Catcrafts cannot sell or ship to Russia, Belarus or North Korea: "
|
||||
"EU sanctions prohibit exporting consumer electronics there.";
|
||||
|
||||
// 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.
|
||||
// a no. The uncovered-country refusal is final: no carrier service means the
|
||||
// shop does not ship there, and offering to arrange it by hand would promise
|
||||
// exactly the ad-hoc export the shop decided not to do. The too-heavy refusal
|
||||
// still names the quantity that WOULD fit, because that buyer has an order the
|
||||
// shop can take — just not in one parcel.
|
||||
//
|
||||
// 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.";
|
||||
"No carrier rate for {cc} is available, so Catcrafts can't ship there.";
|
||||
|
||||
export inline constexpr std::string_view kTooHeavyTemplate =
|
||||
"That is more than fits one parcel to {cc} — up to {n} per order. For a "
|
||||
|
|
@ -257,10 +268,11 @@ export inline constexpr std::string_view kTooHeavyTemplate =
|
|||
|
||||
// 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.
|
||||
// its own sentence — and like the uncovered country, it is a final no: what
|
||||
// the carrier can't take, the shop doesn't ship.
|
||||
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.";
|
||||
"A parcel this heavy can't be shipped to {cc} by any rate available, "
|
||||
"so this order can't be placed.";
|
||||
|
||||
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
|
||||
std::int64_t n) {
|
||||
|
|
@ -346,6 +358,11 @@ 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::IsSanctioned(r.value.country)) {
|
||||
// Checked before the general refusal because SellsTo denies both and
|
||||
// the words differ: this one says the law forbids the sale, not that
|
||||
// the shop chose not to make it.
|
||||
r.errors.push_back({ "country", std::string(kSanctionsMessage) });
|
||||
} 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.
|
||||
|
|
|
|||
|
|
@ -98,11 +98,34 @@ export std::span<const std::string_view> NoSaleCountries() {
|
|||
return blocked;
|
||||
}
|
||||
|
||||
// Destinations the law forbids, as opposed to the insurance choice above.
|
||||
//
|
||||
// EU sanctions — Regulation 833/2014 for Russia, its Belarus mirror, and the
|
||||
// North Korea embargo — prohibit exporting consumer electronics to these
|
||||
// countries, by customs code and by the luxury-goods value threshold both, and
|
||||
// the prohibition covers indirect routes (a forwarder, a reseller) as much as
|
||||
// a direct parcel. That binds every EU seller as criminal law; there is no
|
||||
// small-shop exemption and no surcharge version of compliance. A separate list
|
||||
// rather than more entries in NoSaleCountries because the refusal needs
|
||||
// different words: "does not" is a choice, "cannot" is the law, and each gets
|
||||
// its own explanation on the terms page.
|
||||
export std::span<const std::string_view> SanctionedCountries() {
|
||||
static constexpr std::array<std::string_view, 3> blocked{ "RU", "BY", "KP" };
|
||||
return blocked;
|
||||
}
|
||||
|
||||
export bool IsSanctioned(std::string_view cc) {
|
||||
return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// definition and adding a country later is a one-line change. Both lists deny:
|
||||
// most callers only need "is this destination for sale", and only the checkout
|
||||
// error message cares which refusal it is (IsSanctioned above).
|
||||
export bool SellsTo(std::string_view cc) {
|
||||
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
return !IsSanctioned(cc) &&
|
||||
std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
}
|
||||
|
||||
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
|
||||
|
|
|
|||
|
|
@ -762,10 +762,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
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 destinations checkout refuses, and the sentences that say so — the
|
||||
// policy list (x/xm) and the sanctions list (s/sm), each with its own
|
||||
// wording. 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,
|
||||
|
|
@ -782,7 +783,12 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
if (i) cc += ',';
|
||||
cc += JsonStr(Money::NoSaleCountries()[i]);
|
||||
}
|
||||
cc += std::format(R"(],"xm":{}}})", JsonStr(Form::kNoSaleMessage));
|
||||
cc += std::format(R"(],"xm":{},"s":[)", JsonStr(Form::kNoSaleMessage));
|
||||
for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) {
|
||||
if (i) cc += ',';
|
||||
cc += JsonStr(Money::SanctionedCountries()[i]);
|
||||
}
|
||||
cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage));
|
||||
|
||||
// The payment choice. A radio group rather than a <select> because both
|
||||
// options carry a sentence the buyer should read BEFORE choosing — one
|
||||
|
|
@ -830,7 +836,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
// 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>)"
|
||||
|
|
@ -882,7 +888,7 @@ 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. No US or CA — see the terms.</p>)"
|
||||
R"(price includes VAT. No US or CA, and no sanctioned countries — see the terms.</p>)"
|
||||
R"({})"
|
||||
R"(</div>)"
|
||||
R"({})"
|
||||
|
|
@ -906,6 +912,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
offerCrypto ? SafeHtml{}
|
||||
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
|
||||
Escape(Form::kNoSaleMessage),
|
||||
Escape(Form::kSanctionsMessage),
|
||||
CustomsNote(),
|
||||
formError,
|
||||
Url("action", "/shop/" + product.slug + "#buy"),
|
||||
|
|
@ -973,11 +980,12 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
// 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.
|
||||
// US and CA drop out by policy and the sanctioned countries by law,
|
||||
// not oversight (Money::SellsTo denies both): 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) {
|
||||
|
|
@ -2001,9 +2009,10 @@ 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.
|
||||
// Refused destination — policy or sanctions: 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}"
|
||||
"if(d.s&&d.s.indexOf(k)>-1){if(out){out.textContent=d.sm;out.hidden=false}return}"
|
||||
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
|
||||
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
|
||||
// No ladder for this destination: there is no price, and saying so beats
|
||||
|
|
|
|||
Loading…
Reference in a new issue