markdown fix
All checks were successful
Deploy / build-deploy (push) Successful in 2m23s

This commit is contained in:
Jorijn van der Graaf 2026-08-18 11:12:24 +02:00
commit 4666c1995f
14 changed files with 876 additions and 346 deletions

View file

@ -350,10 +350,14 @@ export const std::vector<LegalPage>& LegalPages() {
} },
{ "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 as 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.",
"Catcrafts ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives. That is not a way of pushing costs onto you — in two of these countries it is the specific legal reason the sale is permitted at all.",
"The reason it is a list of what is allowed rather than a list of what is not, is that the rules are national and there are nearly two hundred of them. Selling a phone into a country can enrol Catcrafts in that country's recycling and battery schemes, require its own radio approval, or require the handset to be registered in a national database before any local network will carry a call. Those obligations attach before the first parcel, not after it, and a shop this size cannot pretend to have read every country's version. So a country is added when it has actually been checked, and until then the answer is no. That is a deliberately cautious default and it will sometimes say no to a country that would have been fine.",
"If your country is not on the list, please write to orders@catcrafts.net. There is no waiting list and no promised date, but which country gets looked at next is decided by who asks, so asking genuinely works.",
"Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination — a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.",
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike everything above this is not a judgement call: 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.",
"The same anti-routing rule applies to every refused country, not only the sanctioned ones: if a parcel is to be forwarded onward, or the delivery address belongs to a forwarding service acting for a customer elsewhere, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around it. The point is that the sale does not happen, not that the address looks acceptable.",
"One more thing that can refuse an address even inside the list: shipping is priced entirely from the carrier's own rates, so a destination the carrier has no rate for cannot be quoted and cannot be ordered. That is a different and much softer no than the ones above, it is about a price not existing rather than a sale being refused, and it can change whenever the carrier's tariff does.",
"The software itself remains free for anyone anywhere, and none of this touches it: 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",
{

View file

@ -228,23 +228,55 @@ export inline constexpr std::string_view kPayCrypto = "crypto";
// "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.
// What a buyer outside Money::ShippableCountries is told.
//
// 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.";
// One sentence for every refusal that is not sanctions, because from the buyer's
// side they are all one fact: their country has not been cleared. Merging them is
// also the honest shape — the old split between "we are not registered there",
// "the handset would not work there" and "our insurer excludes it" described
// Catcrafts' internal reasons, not anything the buyer can act on.
//
// Names the reason as regulatory rather than commercial, because a Dutch shop
// that plainly posts worldwide would otherwise read as arbitrary, or worse as
// quietly declining someone. The truth is duller and better: selling a phone into
// a country means meeting that country's rules first, and doing it in the wrong
// order is the illegal part.
//
// No date is promised. Which country comes next depends on which one people ask
// for, and an invented timeline is worse than none — so the address is the point
// of the last clause.
//
// 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 kRegulatoryMessage =
"Catcrafts can't ship there. Selling a phone into a country means meeting "
"that country's own rules first — recycling schemes, radio approval, import "
"registration — and only a few are cleared so far. Email "
"orders@catcrafts.net if you would like yours looked at next.";
// 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.
// The standing note above the buy form, and the one place the policy is stated
// POSITIVELY. With an allow-list that is the only useful phrasing: "we ship to
// these five" tells a visitor in one glance what "we cannot ship to two hundred
// others" never would, and it stops someone filling in a whole address before
// the field error tells them no.
//
// Prose rather than generated from Money::ShippableCountries because that array
// holds ISO codes and a buyer should not have to decode "NZ". The duplication is
// real, so ShouldComputeMoney asserts the array's LENGTH — add a country and that
// test fails, pointing here. Update both or neither.
export inline constexpr std::string_view kShipsToMessage =
"Catcrafts currently ships to the Netherlands, Switzerland, Serbia, "
"Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and "
"Singapore. Other "
"countries are being worked through one at a time; email "
"orders@catcrafts.net if you would like yours looked at next.";
// The sanctions refusal, in different words on purpose: kRegulatoryMessage
// describes paperwork that could be done, this states a prohibition that cannot.
// Naming the reason here rather than only on the terms page, 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.";
@ -365,14 +397,15 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
} 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.
// Sanctions are checked first because both gates deny and only the words
// differ: this one says the law forbids the sale, not that the shop has
// not got round to that country yet.
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.
r.errors.push_back({ "country", std::string(kNoSaleMessage) });
} else if (!Money::ShipsTo(r.value.country)) {
// Everything not on the shipping list. 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(kRegulatoryMessage) });
}
// Colour: shape only (slug-ish, bounded). Whether it names a variant that

View file

@ -18,9 +18,9 @@ No permission is granted to copy, modify, distribute, or create derivative works
// WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else:
//
// blocks ATX headings, fenced code, blockquotes (nested), ordered and
// unordered lists, thematic breaks, paragraphs
// inline links, images, code spans, ** strong **, * emphasis *, and bare
// URLs via Html::Autolink
// unordered lists, pipe tables, thematic breaks, paragraphs
// inline links, images, code spans, ** strong **, * emphasis *,
// ~~ strikethrough ~~, and bare URLs via Html::Autolink
//
// WHAT IT DELIBERATELY DOES NOT SUPPORT:
//
@ -34,8 +34,15 @@ No permission is granted to copy, modify, distribute, or create derivative works
// these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a
// symbol name into italics is a worse failure than not italicising a word
// that used the underscore form. Asterisks are unambiguous here.
// * Setext headings, reference links, tables, footnotes, HTML entities.
// None appear; adding them speculatively is parser surface with no reader.
// * Single-tilde strikethrough, for the same reason. GFM accepts one tilde or
// two; a lone `~` in these posts is a home directory (`~/.local`) or an
// approximation (`~5 minutes`), so only the doubled form strikes anything.
// * Tilde-fenced code. `~~~` would be ambiguous with the above and no body
// uses it; a fence here is written with backticks.
// * Setext headings, reference links, footnotes, HTML entities. None appear;
// adding them speculatively is parser surface with no reader. (Tables did
// appear — the carrier compatibility list and the GPU price comparison are
// written as pipe tables — which is why they are in the list above now.)
// * Trailing-double-space hard breaks. An invisible two-character difference
// is not something a reader can see in the source or a writer can rely on
// having typed; the lines of a paragraph join with a space, and a break
@ -264,6 +271,30 @@ SafeHtml RenderInline(std::string_view text, std::span<const PostMedia> media, i
}
}
// ~~struck~~, structurally the same as **strong** and sharing its
// open/close tests, so "a ~~ b ~~ c" stays literal the same way
// "a ** b ** c" does.
//
// <s> rather than GFM's <del>: nothing was removed from this document.
// <del> is a claim that an edit happened, and the one body that uses
// this is striking a joke through for effect — which is exactly what
// <s> is for ("no longer accurate or no longer relevant").
if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') {
const std::size_t inner = i + 2;
if (depth < kMaxDepth && OpensEmphasis(text, inner)) {
const std::size_t close = FindEmphasisClose(text, inner, "~~");
if (close != std::string_view::npos) {
flush(i);
out.push_back(Format(
R"(<s>{}</s>)",
RenderInline(text.substr(inner, close - inner), media, depth + 1)));
i = close + 2;
run = i;
continue;
}
}
}
++i;
}
@ -338,9 +369,123 @@ ListMarker ParseListMarker(std::string_view line) {
return m;
}
bool StartsBlock(std::string_view line) {
// ── tables ────────────────────────────────────────────────────────────
//
// GFM pipe tables. These arrived after the parser did: the carrier
// compatibility list in the camera post and the GPU price comparison in
// another are written as tables, and with no table support a table is the
// worst-degrading construct there is — the rows join into one run-on
// paragraph of pipes and dashes, which is neither the data nor prose.
//
// Recognition deliberately needs TWO lines: a row, and under it an alignment
// row agreeing about the number of columns. That is what keeps ordinary prose
// safe, because a pipe by itself is common in these posts (`dmesg | grep`, an
// or-list, a pasted command) and nothing under those lines is `|---|---|`.
// A column's alignment, as the delimiter row asked for it. Left is absent
// because it is what the CSS already does — `:---` and a bare `---` produce
// identical markup, so only the two that change something carry a class.
enum class Align { Default, Center, Right };
// A row split into cells on unescaped pipes, with the optional outer pipes
// dropped. Both are optional independently, which is not pedantry: the real
// carrier table is written `| Device | OS | Carrier` — leading pipe, no
// trailing one — and requiring both would leave it as prose.
//
// `\|` is left in the cell text for RenderInline to turn into a literal pipe;
// that is how a cell contains one.
std::vector<std::string_view> SplitRow(std::string_view line) {
std::string_view s = Trim(Undent(line));
if (s.starts_with('|')) s.remove_prefix(1);
if (s.size() > 1 && s.ends_with('|') && !s.ends_with("\\|")) s.remove_suffix(1);
std::vector<std::string_view> cells;
std::size_t start = 0;
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '\\') { ++i; continue; }
if (s[i] != '|') continue;
cells.push_back(Trim(s.substr(start, i - start)));
start = i + 1;
}
cells.push_back(Trim(s.substr(start)));
return cells;
}
// One cell of the delimiter row: `---`, `:---`, `---:`, `:---:` and nothing
// else. A single dash is enough — `| - |` is a table people write.
bool IsDelimiterCell(std::string_view cell, Align& align) {
const bool left = cell.starts_with(':');
if (left) cell.remove_prefix(1);
const bool right = cell.ends_with(':');
if (right) cell.remove_suffix(1);
if (cell.empty()) return false;
for (const char c : cell) {
if (c != '-') return false;
}
align = (left && right) ? Align::Center : right ? Align::Right : Align::Default;
return true;
}
// The alignment of each column, or an empty vector when `line` is not the
// delimiter row of a table whose header had `columns` cells.
std::vector<Align> ParseDelimiterRow(std::string_view line, std::size_t columns) {
// A pipe is required, so `---` under a one-cell row stays the thematic
// break it looks like — the same call this file already makes for `- - -`
// over a one-item list.
if (Trim(line).find('|') == std::string_view::npos) return {};
std::vector<Align> aligns;
for (const std::string_view cell : SplitRow(line)) {
Align align = Align::Default;
if (!IsDelimiterCell(cell, align)) return {};
aligns.push_back(align);
}
// GFM's rule, and a useful one: a mismatched count is far more likely to be
// prose that happens to contain pipes than a table its author miscounted.
return aligns.size() == columns ? aligns : std::vector<Align>{};
}
bool IsTableStart(std::span<const std::string_view> lines, std::size_t at) {
if (at + 1 >= lines.size()) return false;
if (Trim(Undent(lines[at])).find('|') == std::string_view::npos) return false;
return !ParseDelimiterRow(lines[at + 1], SplitRow(lines[at]).size()).empty();
}
// A row's cells, as <th> or <td>. Rendered per cell rather than per line, so a
// cell holds links, code spans and emphasis like any other prose.
//
// A row shorter than the header is padded with empty cells so the grid stays
// rectangular; a row LONGER than it keeps its extras rather than having them
// dropped, which follows the rule the rest of this file follows — content that
// confuses the parser shows up looking odd instead of disappearing.
SafeHtml RenderRow(std::span<const std::string_view> cells, std::span<const Align> aligns,
bool header, std::span<const PostMedia> media, int depth) {
std::vector<SafeHtml> out;
const std::size_t columns = std::max(cells.size(), aligns.size());
for (std::size_t c = 0; c < columns; ++c) {
const Align align = c < aligns.size() ? aligns[c] : Align::Default;
const SafeHtml cls =
align == Align::Center ? Attr("class", "post-body__cell--center")
: align == Align::Right ? Attr("class", "post-body__cell--right")
: SafeHtml{};
const SafeHtml inner =
RenderInline(c < cells.size() ? cells[c] : std::string_view{}, media, depth);
out.push_back(header ? Format("<th{}>{}</th>", cls, inner)
: Format("<td{}>{}</td>", cls, inner));
}
return Format("<tr>{}</tr>", Join(out));
}
// ── block dispatch ────────────────────────────────────────────────────
// Whether a new block begins at `at`, so a paragraph is interrupted by one
// rather than swallowing it as text. Takes the whole span because a table is
// the one construct that cannot be recognised from a single line.
bool StartsBlock(std::span<const std::string_view> lines, std::size_t at) {
const std::string_view line = lines[at];
return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line)
|| Undent(line).starts_with('>') || ParseListMarker(line).ok;
|| Undent(line).starts_with('>') || ParseListMarker(line).ok
|| IsTableStart(lines, at);
}
SafeHtml RenderBlocks(std::span<const std::string_view> lines,
@ -524,7 +669,7 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
// A non-marker line that would start some other block ends the
// list; anything else is this item's text continuing onto the
// next line.
if (StartsBlock(lines[j])) break;
if (StartsBlock(lines, j)) break;
current.push_back(lines[j]);
++j;
}
@ -545,13 +690,48 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
continue;
}
// ── table ─────────────────────────────────────────────────────
//
// Below the list branch on purpose: `- a | b` over a `|---|---|`
// satisfies both tests, and a line that opens with a list marker is a
// list item. Ordinary tables are unaffected — a pipe is never a list
// marker, so they reach here either way.
if (IsTableStart(lines, i)) {
const std::vector<std::string_view> header = SplitRow(line);
const std::vector<Align> aligns = ParseDelimiterRow(lines[i + 1], header.size());
std::vector<SafeHtml> rows;
std::size_t j = i + 2;
while (j < lines.size() && !Blank(lines[j])) {
// A row has to have a pipe in it. A line without one directly
// under a table is prose whose author forgot the blank line —
// rendering it as a lone one-column row would be worse than
// ending the table and letting it be the paragraph it is.
if (Trim(Undent(lines[j])).find('|') == std::string_view::npos) break;
if (StartsBlock(lines, j)) break;
rows.push_back(RenderRow(SplitRow(lines[j]), aligns, false, media, depth));
++j;
}
// The wrapper is what scrolls. A table cannot be narrowed below its
// content, so without a container around it a nine-column price
// comparison scrolls the PAGE sideways on a phone — the one thing
// wide content must never do (same reasoning as the code block).
out.push_back(Format(
R"(<div class="post-body__table"><table>)"
R"(<thead>{}</thead><tbody>{}</tbody></table></div>)",
RenderRow(header, aligns, true, media, depth), Join(rows)));
i = j;
continue;
}
// ── paragraph ─────────────────────────────────────────────────
{
std::size_t j = i;
while (j < lines.size() && !Blank(lines[j])) {
// A block marker on a later line interrupts the paragraph
// rather than being absorbed into it as text.
if (j > i && StartsBlock(lines[j])) break;
if (j > i && StartsBlock(lines, j)) break;
++j;
}
out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth));

View file

@ -82,33 +82,19 @@ export bool IsEuCountry(std::string_view cc) {
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
}
// 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;
}
// Destinations the law forbids, as opposed to the insurance choice above.
// Destinations the law forbids.
//
// 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.
// small-shop exemption and no surcharge version of compliance.
//
// Kept apart from the shipping allow-list below even though both refuse,
// because the refusal needs different words: not shipping somewhere yet is a
// state of the paperwork, while this is a prohibition nothing about the shop
// could change. Each gets its own sentence at checkout and 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;
@ -118,14 +104,189 @@ export bool IsSanctioned(std::string_view cc) {
return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end();
}
// Where this shop ships. An allow-list, and that inversion IS the design.
//
// Everything not named here is refused. That is not timidity, it is the only
// honest posture available: the rules deciding whether a phone may lawfully be
// sold into a country are national, they differ in kind rather than degree, and
// nobody has read all of them. A deny-list ships to every country nobody thought
// about; an allow-list refuses them. Only one of those failure modes is
// survivable, so the default is no, and every code below is a country somebody
// actually checked.
//
// What "checked" has to mean before a code goes in:
//
// * the destination does not reach the FOREIGN SELLER with producer duties of
// its own — packaging, e-waste, batteries. The EU does. The EEA (Iceland,
// Liechtenstein, Norway) does too, through the same directives, which is why
// no EEA country is here. The UK does by national law.
// * a phone posted from here is actually admitted: no national IMEI database
// to be registered against before a local network will attach it, and no
// customs practice of seizing handsets that lack local type approval.
// * the parcel crosses as the BUYER's import, so their own authority charges
// them VAT and duty and their own type-approval rules bind them rather than
// Catcrafts. This is the one thing the price buys: every foreign
// low-value-consignment regime found sits far below €600, so nothing routes
// through a seller-side registration scheme.
// * liability cover reaches it. The AVB is written worldwide EXCLUDING the
// United States and Canada — confirm that territory in the policy schedule
// before trusting this list, because every entry assumes it.
//
// Removed after verification, and NOT to be re-added on a hunch — both were on
// this list once, on the mistaken assumption that a domestic-supply approval
// regime never reaches a personal import:
//
// JP — using a handset without Japan's giteki (技適) mark on a Japanese network
// is a Radio Act offence carrying up to a year's detention or ¥1,000,000.
// The only exemption is for a short-stay visitor carrying a device in; it
// does not reach a resident receiving a parcel. Fairphone holds no Japanese
// certification and says outright that its devices cannot be used in
// countries requiring local homologation. Note where the liability lands:
// the offence is USE, so the person committing it is the customer. Selling
// someone a phone they break the law by switching on is worse than any
// paperwork gap on this page.
// NZ — the radio regulator defines a supplier to include "a seller", says the
// rules apply identically whether stock ships from inside New Zealand or
// reaches the market through a website, and requires a Licence to Supply
// for radio transmitters. The cure is then bolted shut: an overseas company
// cannot register on the compliance database, and unlike Australia there is
// no agent workaround. Structurally the same trap as Norway and the UK,
// living in radio law rather than waste law.
//
// Not here, and deliberately: the United States and Canada. The insurance
// exclusion was only ever half the reason and is no longer the interesting half.
// Canada is legally shut — the Fairphone (Gen. 6) holds no ISED certification,
// and Canadian law bars importing, distributing or selling uncertified radio
// apparatus, so no structure or policy opens it. The US adds per-parcel customs
// entry since de minimis ended, carrier certification that gates activation, and
// no emergency-call immunity of any kind for a device maker. Both are refused for
// regulatory reasons now, not commercial ones, and neither becomes available by
// buying a policy.
// Every entry must also ship DDU/DAP — the buyer as importer of record, paying
// their own authority at the border. That is not a commercial preference, it is
// load-bearing law in three of the five: it is what makes Hong Kong's
// personal-use import exemption apply, what keeps Catcrafts outside Singapore's
// producer definition, and what puts import tax on the consumer everywhere else.
// Appear as importer of record and two of these countries close.
export std::span<const std::string_view> ShippableCountries() {
static constexpr std::array<std::string_view, 10> open{
// Home. Verpact wants nothing under 50,000 kg of packaging, and the
// phones are already on the Dutch market when Catcrafts buys them, so no
// producer duty attaches. That second clause is load-bearing: source
// stock from another member state and Catcrafts becomes the Dutch
// importer, owing Stichting OPEN before this line is honest again.
"NL",
// Checked end to end against the federal texts. CE accepted under the
// bilateral MRA (its chapter 7 is radio equipment); the e-waste ordinance
// reaches only those importing for COMMERCIAL supply, with no
// distance-seller limb and no producer register; and the mail-order VAT
// duty is scoped to consignments cheap enough to be import-tax-exempt, so
// a phone goes through ordinary import with the buyer paying at the
// border. The packaging ordinance expected 1 Jan 2027 turns out not to
// matter — it was adopted 24 June 2026 and its fee covers GLASS only.
//
// The real watch item is elsewhere, and it is specific: the circular-
// economy revision of the environment act, in force since 1 Jan 2025,
// already empowers a disposal fee on "ausländische Online-
// Versandhandelsunternehmen" — defined as whoever offers products
// digitally and delivers to consumers in Switzerland without a Swiss
// seat or establishment. That is this shop, definitionally. It is dormant
// only because the power is discretionary and no ordinance names a phone,
// and the companion article is deliberately not yet in force. An ordinance
// extending the fee to electrical devices is the day Switzerland flips.
"CH",
// Read out of the Radiocommunications Equipment (General) Rules 2021
// rather than off a guidance page, because ACMA's own site is misleading
// here. The labelling and registration duties in s25 bind an importer who
// then SUPPLIES — neither limb is met when the consumer imports one phone
// for themselves. What does reach Catcrafts is s12: a person must not
// supply a device failing a prescribed general standard, extended to trade
// between Australia and places outside it. That is a SUBSTANTIVE standards
// duty, not a registration one — so no Australian establishment is needed,
// unlike New Zealand. Phones are outside the e-waste scheme (which covers
// TVs, printers and computers, and binds Australian corporations anyway).
// Two watch items: a mandatory small-electricals stewardship scheme is
// committed but not law, and the A$1,000 low-value-import line sits close
// enough to €600 that FX moves can cross it.
"AU",
// The best-documented jurisdiction of the set. Mobile phones sit outside
// the e-waste producer-responsibility list; user equipment needs no type
// approval (voluntary certification only); the import ordinance exempts
// equipment brought in for reasonable personal use; and there is no VAT or
// GST at all, so no foreign-seller registration can arise.
"HK",
// Both feared hooks miss on the facts. The e-waste producer duty requires
// importing INTO Singapore in furtherance of a Singapore supply business,
// which a DDU parcel is not, and there is no distance-selling limb of the
// kind UK law uses. IMDA states personal-use imports need no registration
// and sets no quantity limit. On tax the price helps: the low-value-goods
// regime caps at S$400, so a €600 phone is above it and the overseas-vendor
// rules cannot reach it at any turnover.
"SG",
// The Western Balkans four. All share the shape that matters: producer
// responsibility attaches to whoever places goods on the DOMESTIC market
// — the in-country importer, so the buyer — with none of the
// "regardless of sales channel" drafting that catches a distance seller in
// the EU, Norway, Iceland, Moldova and Bosnia. CE is accepted, no IMEI
// whitelist exists, and non-resident VAT reaches services only.
//
// RS — the strongest of them, because the answer comes from the customs
// authority itself: conformity documents are demanded only for
// certain drones, no radio-equipment conformity paper is required at
// import, and per the telecoms ministry's published position the
// Radio Equipment Rulebook does not apply to natural persons at all.
// ME — best-evidenced on tax: the VAT act states in terms that where
// transport begins outside Montenegro the IMPORTER makes the supply,
// and a tax representative is needed only for supplies made inside
// the country. EPR bylaws are still pending.
// AL — its WEEE decree still uses the pre-2012 three-limb producer
// definition, so the distance-selling limb simply is not there, and
// registration would need an Albanian tax number nobody can give a
// foreigner. Smartphones are also duty-free in the 2026 tariff.
// HARD DATE: Law 74/2025 takes effect 1 December 2026. Re-read its
// producer definition and any implementing acts before then, because
// that is when this entry could stop being true.
// XK — cleared on the law, with one operational caveat that is not legal:
// Kosovo is not a UPU member, so there is no treaty-based tracking
// guarantee or loss indemnity. On a €600 parcel that matters — ship
// it by courier rather than post. Note also that XK is a
// user-assigned code rather than official ISO 3166-1; if the carrier
// table spells Kosovo differently this entry simply never matches and
// the destination falls through to the no-carrier-rate refusal, which
// is the safe direction to fail.
"RS", "ME", "AL", "XK",
// Held back for a while on the dual-use crypto question rather than
// anything Georgian, and that question turned out to be a paperwork task
// rather than a gate — the mass-market exemption releases this phone for
// the same reason it releases every unlockable Pixel. On its own law
// Georgia is among the cleanest here: producer duties attach to whoever
// IMPORTS, with no distance-selling limb and no authorised-representative
// concept at all; the electronic communications act contains not one
// mention of IMEI; the product-safety code admits goods built to the
// standards of any EU or OECD country; and phones carry no duty, with the
// buyer paying import VAT at the border. Same caveat as Kosovo: the
// national post is slow with unreliable tracking, so send it by courier.
"GE",
};
return open;
}
export bool ShipsTo(std::string_view cc) {
return std::ranges::find(ShippableCountries(), cc) != ShippableCountries().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. 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).
// rather than testing the lists themselves, so the policy has exactly one
// definition and opening a country is a one-line change.
//
// Two HARD gates, in order of permanence: the law, then the shipping list. A
// destination the carrier happens not to price is a third and much softer
// refusal that lives with the rate table (Form::kNoShippingTemplate) — it says
// no price exists, not that the sale is refused, and a carrier contract can
// change it tomorrow. Most callers only need "is this destination for sale";
// only the checkout error cares which refusal it is.
export bool SellsTo(std::string_view cc) {
return !IsSanctioned(cc) &&
std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
return !IsSanctioned(cc) && ShipsTo(cc);
}
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
@ -271,10 +432,17 @@ 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. 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.
// is noise. So are USD and CAD, and that one is a policy choice rather than a
// gap in the ECB feed: those two are refused for regulatory reasons that no
// amount of demand will lift, so quoting a visitor a friendly price in their own
// currency before declining them is both a worse experience and the kind of
// localisation that reads as marketing into a market this shop cannot serve.
//
// The rest of this table deliberately runs AHEAD of ShippableCountries. It
// answers "what would this cost in my money", which stays a fair question for a
// country whose paperwork is merely pending — GB is one small registration from
// opening — and keeping the row spares a delete-and-restore cycle later. The
// refusal that must never be quoted around is enforced in SellsTo, not here.
export std::span<const CurrencyRow> AllCurrencies() {
static constexpr std::array<CurrencyRow, 14> rows{{
{ "GB", { "GBP", "£" } },

View file

@ -817,17 +817,21 @@ SafeHtml RenderCheckoutForm(const Product& product,
}
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":{},"s":[)", JsonStr(Form::kNoSaleMessage));
// Sanctions (s/sm) then the shipping allow-list (w/rm). Note w is what the
// shop CAN ship to, so the preview refuses on absence — which is why this
// payload stays five codes long while most of the world is closed, instead
// of carrying a deny-list of two hundred.
cc += R"(,"s":[)";
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));
cc += std::format(R"(],"sm":{},"w":[)", JsonStr(Form::kSanctionsMessage));
for (std::size_t i = 0; i < Money::ShippableCountries().size(); ++i) {
if (i) cc += ',';
cc += JsonStr(Money::ShippableCountries()[i]);
}
cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage));
const SafeHtml payFieldset =
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
@ -919,7 +923,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
// the lede would only repeat half of them.
offerCrypto ? SafeHtml{}
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
Escape(Form::kNoSaleMessage),
Escape(Form::kShipsToMessage),
Escape(Form::kSanctionsMessage),
CustomsNote(),
formError,
@ -2146,11 +2150,14 @@ 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 — 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}"
// Refused destination: say so where the total would have been, instead of
// pricing an order the server will decline. The same two gates as
// ValidateCheckout, in the same order, from the same sentences — sanctions
// first, then absence from the shipping list.
"var eu=ecc.indexOf(k)>-1;"
"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;"
"if(d.w&&d.w.indexOf(k)<0){if(out){out.textContent=d.rm;out.hidden=false}return}"
"var 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
// quoting a total the submit would then reject.