This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -38,12 +38,14 @@ import :Money;
|
|||
|
||||
namespace Catcrafts::Content {
|
||||
|
||||
// The flat markup on every variant — "whatever it costs me + 50", where the
|
||||
// €50 is what Catcrafts KEEPS. Retail is VAT-inclusive, so a markup added to
|
||||
// it is taxed with the rest of the price: a flat +5000 would net only €41.32
|
||||
// once the VAT on it is remitted. Grossed up once here (€60.50 on the
|
||||
// sticker), the walk-away margin is exactly €50 per unit — on EU sales and
|
||||
// on exports alike, since the export price is the net of the same retail.
|
||||
// The flat markup on every priced variant — "whatever it costs me + 50",
|
||||
// where the €50 is what Catcrafts KEEPS. No priced product is listed today;
|
||||
// the rule and its proof stay here for the next one. Retail is VAT-inclusive,
|
||||
// so a markup added to it is taxed with the rest of the price: a flat +5000
|
||||
// would net only €41.32 once the VAT on it is remitted. Grossed up once here
|
||||
// (€60.50 on the sticker), the walk-away margin is exactly €50 per unit — on
|
||||
// EU sales and on exports alike, since the export price is the net of the
|
||||
// same retail.
|
||||
inline constexpr std::int64_t kMarkupNetMinor = 5000;
|
||||
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
|
||||
// Exactness is not luck: NetFromGross strips a gross amount additively only
|
||||
|
|
@ -56,187 +58,36 @@ static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
|
|||
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
|
||||
"the exact net of the markup must be the €50 the rule promises");
|
||||
|
||||
// The warranty both phones carry, verbatim. Shared rather than pasted per
|
||||
// product because it is a promise about how Catcrafts handles a fault, not a
|
||||
// statement about a particular board — and two copies of a legal text are two
|
||||
// texts that can drift apart, while /legal/terms states exactly one policy.
|
||||
inline constexpr std::string_view kPhoneWarranty =
|
||||
"Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
|
||||
|
||||
// The catalogue. Goods come and go: the two handsets that opened this list
|
||||
// were withdrawn on 2026-09-05, and nothing here, in the renderers or on the
|
||||
// legal pages may assume what kind of thing is sold next. The one standing
|
||||
// entry is the donation item.
|
||||
//
|
||||
// Adding a priced product is one Product below: slug, name, brand, variants
|
||||
// (each with its VAT-inclusive price, supplier + kMarkupMinor), a boxed
|
||||
// shipping weight in grams, a photo under images/ (and in project.cpp's
|
||||
// cfg.files), a spec sheet and a warranty text. The shop grid, the product
|
||||
// page, the checkout, the sitemap and the JSON-LD all read this vector and
|
||||
// nothing else; the E2E suites find the first non-donation entry and skip
|
||||
// their goods checks while there is none.
|
||||
export const std::vector<Product>& Products() {
|
||||
static const std::vector<Product> products = [] {
|
||||
Product p;
|
||||
p.slug = "fp6-pmos";
|
||||
p.name = "The Fairphone (Gen. 6) with postmarketOS";
|
||||
p.brand = "Fairphone";
|
||||
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
|
||||
// Launch day is this one line: "coming-soon" -> "available". The page
|
||||
// shows launch prices either way; only the order form is held back.
|
||||
// The donation item. The buyer names the amount (Form::ValidateDonation
|
||||
// bounds it); nothing ships and no VAT is charged, because a gift with
|
||||
// nothing supplied in return is not a taxable supply — which is also
|
||||
// why a donation never gets an invoice.
|
||||
//
|
||||
// ── BEFORE FLIPPING THIS, TWO THINGS ARE OWED ────────────────────
|
||||
//
|
||||
// 1. THE WITHDRAWAL BUTTON (herroepingsknop). Since 2026-06-19, art.
|
||||
// 11a of the Consumer Rights Directive — art. 6:230oa BW — has
|
||||
// required a clearly labelled button in the interface for a
|
||||
// consumer to withdraw from a distance contract. Email alone is no
|
||||
// longer enough. It must stay available for the whole withdrawal
|
||||
// period, ask only for essentials, NOT require an account, and
|
||||
// confirm immediately on a durable medium.
|
||||
// **If it is missing, the 14-day withdrawal period becomes ONE
|
||||
// YEAR** — every EU phone sale unwindable for twelve months.
|
||||
// It does not bite today only because the sole purchasable item is
|
||||
// the donation, and a gift carries no withdrawal right. Flipping
|
||||
// this line is exactly the moment it starts to.
|
||||
// The order page is the natural home: it already identifies one
|
||||
// order by an unguessable link, so no account is needed.
|
||||
//
|
||||
// 2. THE SHIPPING RATE TABLE. Without SENDCLOUD_* credentials (or a
|
||||
// hand-placed cache) there is no rate table, and checkout refuses
|
||||
// every order that ships. Donations skip that branch, which is why
|
||||
// the shop looks fine today. See deploy/README.md.
|
||||
p.status = "coming-soon";
|
||||
// Boxed shipping weight, grams: the FP6 retail box (~450 g) plus the
|
||||
// outer carton and padding. Rounded UP on purpose — this number picks
|
||||
// the carrier's weight bracket, and guessing light is how an order gets
|
||||
// quoted a rate the parcel does not qualify for. Worth replacing with a
|
||||
// scale reading of a real outgoing parcel before launch.
|
||||
p.shipWeightGrams = 700;
|
||||
p.image = "/fp6-pmos.jpg";
|
||||
p.summary = "The Fairphone (Gen. 6), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||
p.warranty = kPhoneWarranty;
|
||||
// No safety note. There was one — the emergency-calling caveat — and
|
||||
// it came off on 2026-08-18, when imsd placed calls to 112 on a live
|
||||
// network in a test session approved by TB112 (the Dutch 1-1-2
|
||||
// operator). That verification is exactly what the caveat was waiting
|
||||
// for, so the claim it hedged is now a plain fact and the warning box
|
||||
// would only mislead. The field stays in the model for the next real
|
||||
// safety claim; leaving it empty is the decision, not an oversight.
|
||||
p.variants = {
|
||||
// supplier €513.30 incl VAT
|
||||
{ "green", "Forest Green", 51330 + kMarkupMinor },
|
||||
// supplier €519.30 incl VAT
|
||||
{ "black", "Black", 51930 + kMarkupMinor },
|
||||
// supplier €604.88 incl VAT
|
||||
{ "white", "White", 60488 + kMarkupMinor },
|
||||
};
|
||||
// The hardware, as Fairphone specifies it — this is a stock Fairphone
|
||||
// (Gen. 6), so its spec sheet is this product's spec sheet.
|
||||
p.specs = {
|
||||
{ "Display", "6.31″ OLED, 2484 × 1116 (FHD+), up to 120 Hz, Gorilla Glass 7i" },
|
||||
{ "Processor", "Qualcomm Snapdragon 7s Gen 3, Adreno 810 GPU" },
|
||||
{ "Memory", "8 GB RAM" },
|
||||
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
||||
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
||||
{ "Front camera", "32 MP" },
|
||||
{ "Battery", "4415 mAh, user-replaceable, 30 W fast charging" },
|
||||
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
||||
{ "SIM", "Dual: nano-SIM + eSIM" },
|
||||
{ "USB", "USB-C 2.0" },
|
||||
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
||||
{ "Durability", "IP55, 12 user-replaceable modules" },
|
||||
{ "Dimensions", "156.5 × 73.3 × 9.6 mm, 193 g" },
|
||||
};
|
||||
// Same rule the loader used to apply: the from-price is the
|
||||
// cheapest variant, derived so the two can never disagree.
|
||||
if (const Variant* cheapest = p.CheapestVariant()) {
|
||||
p.priceInclMinor = cheapest->priceInclMinor;
|
||||
}
|
||||
|
||||
// The Fairphone (Gen. 6+): Fairphone's mid-generation refresh, announced
|
||||
// 2026-08-18. A second listing rather than more colours on the entry
|
||||
// above — different silicon, a different price and its own spec sheet,
|
||||
// and the two are bought as different phones.
|
||||
//
|
||||
// It is sellable at all because the SOFTWARE is unchanged: the same
|
||||
// postmarketOS image, the same kernel patches and the same imsd run on
|
||||
// this board, confirmed on a device. The chassis, modem, cameras and
|
||||
// battery are the Gen. 6's; the SoC and the RAM are the only parts the
|
||||
// image had to be re-verified against, and it was.
|
||||
Product q;
|
||||
q.slug = "fp6plus-pmos";
|
||||
q.name = "The Fairphone (Gen. 6+) with postmarketOS";
|
||||
q.brand = "Fairphone";
|
||||
q.tagline = "The mid-generation refresh with faster chip, 12 GB of RAM, running the same mainline Linux image.";
|
||||
q.status = "coming-soon";
|
||||
// Same device weight (193 g) in the same retail box, so the outgoing
|
||||
// parcel is the Gen. 6's parcel and lands in the same carrier bracket.
|
||||
// The rounding note on the entry above applies here too: this wants a
|
||||
// scale reading of a real parcel before launch, not a second guess.
|
||||
q.shipWeightGrams = 700;
|
||||
// Fairphone's own Cobalt Blue render, cropped from the 2200 × 2200
|
||||
// original on their Flickr — same rightsholder and same CC BY-SA 4.0
|
||||
// licence as the Gen. 6 render above, credited with the adaptation
|
||||
// notice on /legal/imprint. Deliberately NOT one of the copies the tech
|
||||
// press hosts: an outlet's file carries no licence grant from the
|
||||
// outlet, and the ones in circulation show the phone running stock
|
||||
// Android with the Play Store on screen — which is the single thing
|
||||
// this listing exists to sell the absence of.
|
||||
q.image = "/fp6plus-pmos.jpg";
|
||||
q.summary = "The Fairphone (Gen. 6+), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||
q.warranty = kPhoneWarranty;
|
||||
// Supplier €649.00 incl VAT in every colour — Fairphone's own launch
|
||||
// price, which is what the device costs anywhere a day after launch and
|
||||
// the only real number that exists for it today. The Gen. 6 above is
|
||||
// sourced well below list, so expect these to come DOWN once a sourcing
|
||||
// price lands: change the three numbers here (and the pins in
|
||||
// ShouldShipContent) and the €50 net margin holds by construction, as
|
||||
// the static_asserts above guarantee for every supplier price.
|
||||
//
|
||||
// Cobalt Blue leads the list: with one price across the range,
|
||||
// CheapestVariant() returns whichever colour comes first, and that is
|
||||
// the selector's pre-selected default — so it has to be the colour the
|
||||
// photo above actually shows.
|
||||
q.variants = {
|
||||
{ "blue", "Cobalt Blue", 64900 + kMarkupMinor },
|
||||
{ "green", "Forest Green", 64900 + kMarkupMinor },
|
||||
{ "black", "Horizon Black", 64900 + kMarkupMinor },
|
||||
};
|
||||
// The hardware, as Fairphone specifies it. Five rows differ from the
|
||||
// Gen. 6 — the panel, the SoC, the RAM, the charge rate, and Fairphone's
|
||||
// own rounding of the chassis length (156.57 mm here, 156.5 there, one
|
||||
// shell). The other eight are restated rather than cross-referenced: a
|
||||
// spec sheet that says "as above" is not a spec sheet.
|
||||
q.specs = {
|
||||
{ "Display", "6.31″ LTPO AMOLED, 2484 × 1116 (FHD+), 10–120 Hz adaptive, Gorilla Glass 7i" },
|
||||
{ "Processor", "Qualcomm Snapdragon 7s Gen 4, Adreno 810 GPU" },
|
||||
{ "Memory", "12 GB RAM" },
|
||||
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
||||
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
||||
{ "Front camera", "32 MP" },
|
||||
{ "Battery", "4415 mAh, user-replaceable, 27 W fast charging" },
|
||||
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
||||
{ "SIM", "Dual: nano-SIM + eSIM" },
|
||||
{ "USB", "USB-C 2.0" },
|
||||
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
||||
{ "Durability", "IP55, 12 user-replaceable modules" },
|
||||
{ "Dimensions", "156.6 × 73.3 × 9.6 mm, 193 g" },
|
||||
};
|
||||
// Same derivation as above: the sticker is the cheapest colour, so the
|
||||
// page cannot advertise a number the default choice doesn't honour.
|
||||
if (const Variant* cheapest = q.CheapestVariant()) {
|
||||
q.priceInclMinor = cheapest->priceInclMinor;
|
||||
}
|
||||
|
||||
// The donation item: the shop's soft opening. It is "available" while
|
||||
// the phones above stay "coming-soon" on purpose — the two rails, the
|
||||
// ledger, the reconciler and the mailer all run for real money before
|
||||
// the first phone order depends on them. The buyer names the amount
|
||||
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
|
||||
// charged, because a gift with nothing supplied in return is not a
|
||||
// taxable supply — which is also why a donation never gets an invoice.
|
||||
// The order of this vector is the order of the shop grid, and it is
|
||||
// deliberate: fp6-pmos FIRST (the shop's headline, and the tests
|
||||
// address Products()[0] as the priced product), the Gen. 6+ second,
|
||||
// and the donation LAST — it is the odd entry out, not what the shop
|
||||
// is about.
|
||||
// The order of this vector is the order of the shop grid. Priced goods
|
||||
// go BEFORE this entry: the donation is the odd one out, not what the
|
||||
// shop is about, and stays last.
|
||||
Product d;
|
||||
d.slug = "donation";
|
||||
d.name = "Donation";
|
||||
d.tagline = "Fund the open-source work directly";
|
||||
d.status = "available";
|
||||
d.donation = true;
|
||||
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
|
||||
return std::vector<Product>{ std::move(p), std::move(q), std::move(d) };
|
||||
d.summary = "Thank you very much for considering to donate! Your donation contributes to the maintenance of the open-source software Catcrafts publishes and to research and development for new devices.";
|
||||
return std::vector<Product>{ std::move(d) };
|
||||
}();
|
||||
return products;
|
||||
}
|
||||
|
|
@ -317,7 +168,7 @@ export const LegalPage& AboutPage() {
|
|||
static const LegalPage page{
|
||||
.slug = "about",
|
||||
.title = "About",
|
||||
.updated = "2026-08-05",
|
||||
.updated = "2026-09-05",
|
||||
.lede = "Catcrafts is a one-person company. This page is about the person, the mission, and the name.",
|
||||
.sections = {
|
||||
{ "Who",
|
||||
|
|
@ -327,7 +178,7 @@ export const LegalPage& AboutPage() {
|
|||
{ "The mission",
|
||||
{
|
||||
"Accessible open-source software and hardware, for the benefit of everyone. Software you can read, hardware you can repair, and phones that answer to their owner rather than to an advertising department. A real alternative to big tech: not a manifesto about one, but things you can download, flash and buy today.",
|
||||
"Everything Catcrafts makes is published as open source. The shop is how the development gets funded: buying a phone here pays for the stack it runs.",
|
||||
"Everything Catcrafts makes is published as open source. The shop is how the development gets funded: every purchase and every donation there pays for the work.",
|
||||
} },
|
||||
{ "The work",
|
||||
{
|
||||
|
|
@ -337,7 +188,7 @@ export const LegalPage& AboutPage() {
|
|||
} },
|
||||
{ "The name",
|
||||
{
|
||||
"Catcrafts is not the Minecraft server. That is catcraft.net, singular, an unrelated community that happens to share almost the same name. This site sells and builds software and hardware for Linux phones, and has no cross-play, no ranks and no diamonds. The cat in the logo speaks shell, not creeper.",
|
||||
"Catcrafts is not the Minecraft server. That is catcraft.net, singular, an unrelated community that happens to share almost the same name. This site is about open-source software and hardware for Linux phones, and has no cross-play, no ranks and no diamonds. The cat in the logo speaks shell, not creeper.",
|
||||
} },
|
||||
},
|
||||
};
|
||||
|
|
@ -375,7 +226,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "privacy",
|
||||
.title = "Privacy",
|
||||
.updated = "2026-08-20",
|
||||
.updated = "2026-09-05",
|
||||
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
|
||||
.sections = {
|
||||
{ "Who is responsible",
|
||||
|
|
@ -384,7 +235,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
} },
|
||||
{ "Orders",
|
||||
{
|
||||
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
|
||||
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you an order and issuing an invoice consist of.",
|
||||
"Neither payment method involves a payment processor, so there is no third party that Catcrafts hands your data to in order to get paid. Bank payment is an ordinary transfer from your bank to the account named on the order page: your own bank and Catcrafts' bank handle it, as they would any transfer you make, and Catcrafts has engaged nobody in between. Cryptocurrency payment involves no provider either: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Only the order record described above connects a payment to you, and that record stays with Catcrafts.",
|
||||
"What Catcrafts records about a bank transfer is deliberately narrow: the payment reference you quoted, the amount, the date and the method. Your name and IBAN are visible to Catcrafts on its own bank statement, as they are to anyone who receives a transfer, but they are not copied into the shop's records and are not needed to confirm an order. The reference is what matches your payment to your order.",
|
||||
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
|
||||
|
|
@ -418,7 +269,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "imprint",
|
||||
.title = "Imprint & contact",
|
||||
.updated = "2026-08-19",
|
||||
.updated = "2026-09-05",
|
||||
.lede = "Who is behind this site and how to reach them.",
|
||||
.sections = {
|
||||
{ "Contact",
|
||||
|
|
@ -434,21 +285,12 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
|
||||
} },
|
||||
// The CC BY-SA 4.0 licence on both product renders requires
|
||||
// naming the author, linking the licence and flagging that the
|
||||
// files were changed. project.cpp pointed here for it long
|
||||
// before anything here said it, which is the gap this closes.
|
||||
{ "Image credits",
|
||||
{
|
||||
"The product renders are the manufacturer's own: © Fairphone, used under the Creative Commons Attribution-ShareAlike 4.0 licence, https://creativecommons.org/licenses/by-sa/4.0 . The Fairphone (Gen. 6) in Forest Green came through Wikimedia Commons; the Fairphone (Gen. 6+) in Cobalt Blue comes from Fairphone's own photostream at https://www.flickr.com/photos/fairphone . Both were cropped and recompressed to fit this site, and those adaptations are offered back under the same licence.",
|
||||
"They are renders of stock hardware, which is exactly what Catcrafts ships. The difference is the software on it. No manufacturer render shows postmarketOS, so treat the screens in those images as the manufacturer's artwork rather than a picture of what arrives.",
|
||||
} },
|
||||
},
|
||||
},
|
||||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-20",
|
||||
.updated = "2026-09-05",
|
||||
.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",
|
||||
|
|
@ -466,32 +308,17 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
|
||||
"A donation is not a purchase, so the returns section below does not apply to it.",
|
||||
} },
|
||||
{ "Fulfilment",
|
||||
{
|
||||
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
|
||||
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
|
||||
} },
|
||||
{ "Where Catcrafts sells",
|
||||
{
|
||||
"Catcrafts 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.",
|
||||
"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.",
|
||||
"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",
|
||||
{
|
||||
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",
|
||||
"Behind that counter the split is simple. Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere. Hardware faults are handled through the manufacturer's or supplier's warranty: Catcrafts keeps the purchase paperwork those claims depend on and runs the process end to end. What that means for a specific product is described on its own page.",
|
||||
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong, software issue caused by a relocked bootloader), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
|
||||
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide.",
|
||||
"Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere.",
|
||||
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong, or a software fault caused by your own changes), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
|
||||
"If you are an EU consumer you additionally hold the statutory conformity guarantee: under Dutch law it lasts as long as a product of this kind may reasonably be expected to last, a defect appearing in the first year is presumed to have existed at delivery, and remedies under it are free. Nothing in this section limits those rights.",
|
||||
"For warranty please contact warranty@catcrafts.net"
|
||||
} },
|
||||
{ "Returns",
|
||||
{
|
||||
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded within 14 days of your notice, though the refund can wait until the product is back or you show it has been shipped. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop; value lost through use beyond that can be deducted from the refund.",
|
||||
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded when the product has beeb retuned and inspected. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop. value lost through use beyond that can be deducted from the refund.",
|
||||
"Outside the EU, sales are final except for defects: the warranty above applies in full, but there is no change-of-mind window. Import duties and fees paid to your own authorities are between you and them and are never refunded by Catcrafts in any case.",
|
||||
"A product that arrives broken is a warranty case, not a return: the warranty section applies, and the shipping is on Catcrafts.",
|
||||
} },
|
||||
|
|
|
|||
|
|
@ -238,8 +238,8 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
|||
//
|
||||
// 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
|
||||
// quietly declining someone. The truth is duller and better: selling 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
|
||||
|
|
@ -250,9 +250,9 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
|||
// 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 "
|
||||
"Catcrafts can't ship there. Selling into a country means meeting that "
|
||||
"country's own rules first: recycling schemes, radio approval, import "
|
||||
"registration. Only a few are cleared so far. Email "
|
||||
"orders@catcrafts.net if you would like yours looked at next.";
|
||||
|
||||
// The standing note above the buy form, and the one place the policy is stated
|
||||
|
|
@ -432,7 +432,7 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
|
||||
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
||||
// no-JS form default); anything unparseable or out of range is rejected
|
||||
// rather than clamped — silently changing how many phones someone buys is
|
||||
// rather than clamped — silently changing how many units someone buys is
|
||||
// worse than asking again.
|
||||
const std::string_view qty = Trim(f.Get("quantity"));
|
||||
if (qty.empty()) {
|
||||
|
|
|
|||
|
|
@ -192,8 +192,9 @@ export struct Product {
|
|||
// Deliberately NOT accompanied by a GTIN: the device is materially
|
||||
// modified (different OS), and Google's product-data rules say a
|
||||
// customized product must not carry the original manufacturer's GTIN —
|
||||
// which also matches the legal framing that this is a Fairphone with
|
||||
// different software, not a Catcrafts-manufactured phone. Empty = omit.
|
||||
// which also matches the legal framing that this is the manufacturer's
|
||||
// device with different software, not a Catcrafts-manufactured one.
|
||||
// Empty = omit.
|
||||
std::string brand;
|
||||
// "available" — buyable now. "coming-soon" — listed with launch prices,
|
||||
// orders not open yet. "unavailable" — listed but not sellable (sourcing
|
||||
|
|
@ -220,7 +221,7 @@ export struct Product {
|
|||
// decides how many units fit one parcel. A destination the carrier has no
|
||||
// bracket for is refused at checkout rather than priced from a guess.
|
||||
std::int64_t shipWeightGrams = 0;
|
||||
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
|
||||
// Root-relative path of the product photo, e.g. "/widget.jpg". Served
|
||||
// from our own origin like every other asset — the privacy notice's
|
||||
// "everything comes from catcrafts.net" applies to product images too.
|
||||
std::string image;
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ export bool IsSanctioned(std::string_view cc) {
|
|||
// 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
|
||||
// honest posture available: the rules deciding whether a product 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
|
||||
|
|
@ -139,9 +139,10 @@ export bool IsSanctioned(std::string_view cc) {
|
|||
// 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:
|
||||
// does not reach a resident receiving a parcel. A handset certified for
|
||||
// the EU market holds no Japanese certification, and its maker will say
|
||||
// outright that it 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.
|
||||
|
|
@ -155,7 +156,7 @@ export bool IsSanctioned(std::string_view cc) {
|
|||
//
|
||||
// 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,
|
||||
// Canada is legally shut — an EU-certified handset 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
|
||||
|
|
@ -170,8 +171,8 @@ export bool IsSanctioned(std::string_view cc) {
|
|||
// 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
|
||||
// Home. Verpact wants nothing under 50,000 kg of packaging, and goods
|
||||
// bought from Dutch retail 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.
|
||||
|
|
|
|||
|
|
@ -289,8 +289,8 @@ export RenderedPage RenderHome(std::span<const Project> projects,
|
|||
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
|
||||
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
|
||||
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
||||
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone )"
|
||||
R"((Gen. 6) handsets sold with postmarketOS preinstalled.",)"
|
||||
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and mainline )"
|
||||
R"(Linux support for mobile hardware.",)"
|
||||
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]},)"
|
||||
R"({"@type":"WebSite","@id":"https://catcrafts.net/#website",)"
|
||||
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
|
||||
|
|
@ -676,8 +676,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
|||
RenderedPage page;
|
||||
page.meta.title = "Shop — Catcrafts";
|
||||
page.meta.description =
|
||||
"Fairphone (Gen. 6) and (Gen. 6+) handsets reflashed to run postmarketOS, "
|
||||
"with the IMS/VoLTE stack Catcrafts maintains.";
|
||||
"Hardware that runs the open-source software Catcrafts maintains, and a "
|
||||
"donation item that funds the work directly.";
|
||||
page.meta.canonical = "/shop";
|
||||
page.meta.geoPriceHint = true;
|
||||
// The category page as typed data: an ItemList naming each product page.
|
||||
|
|
@ -704,10 +704,9 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
|||
page.main = Format(
|
||||
R"(<header class="page-header">)"
|
||||
R"(<h1 class="page-header__title">Shop</h1>)"
|
||||
R"(<p class="page-header__lede">Hardware that runs the software from the )"
|
||||
R"(projects page. Assembled to order and flashed. Please allow up to a )"
|
||||
R"(week before dispatch. Or fund the work directly: the donation item )"
|
||||
R"(takes any amount.)"
|
||||
R"(<p class="page-header__lede">Everything sold here funds the open-source )"
|
||||
R"(work on the projects page. Or fund the work directly: the donation )"
|
||||
R"(item takes any amount.)"
|
||||
R"(</header>)"
|
||||
R"(<div class="product-grid">{}</div>)",
|
||||
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
||||
|
|
@ -1362,18 +1361,19 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
// donation has neither a spec sheet nor a warranty, and an empty table
|
||||
// under a manufacturer-specific lede would be nonsense on its page.
|
||||
//
|
||||
// The lede names the BRAND, never a model. It used to say "Fairphone
|
||||
// (Gen. 6)" in the markup, which read true while the catalogue held one
|
||||
// phone and went silently false the moment it held two — a spec sheet
|
||||
// introduced as some other device's sheet is worse than no lede at all.
|
||||
// The lede names the BRAND, never a model. It used to hardcode one model
|
||||
// name, which read true while the catalogue held one device and went
|
||||
// silently false the moment it held two — a spec sheet introduced as some
|
||||
// other device's sheet is worse than no lede at all. It also claims
|
||||
// nothing about the software on the device: that is the product's own
|
||||
// summary's job, and this renderer serves whatever the catalogue lists.
|
||||
// A spec sheet with no brand to name is a content bug, asserted against in
|
||||
// ShouldShipContent rather than papered over with a second sentence here.
|
||||
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Specifications</h2>)"
|
||||
R"(<p class="section__lede">The hardware is a stock {}, unmodified. )"
|
||||
R"({}'s spec sheet is this product's spec sheet, )"
|
||||
R"(and all of it works under postmarketOS.</p>)"
|
||||
R"({}'s spec sheet is this product's spec sheet.</p>)"
|
||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||
R"(</section>)",
|
||||
Escape(product.brand), Escape(product.brand), Join(specRows));
|
||||
|
|
@ -1671,7 +1671,7 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(</section>)")
|
||||
: Format(
|
||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
||||
R"(<p>Your order is sourced, prepared and tested, then shipped. Allow up )"
|
||||
R"(to a week before dispatch. Updates land in your email.</p>)"
|
||||
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
||||
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
||||
|
|
|
|||
Loading…
Reference in a new issue