This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
|
|
@ -228,7 +228,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "privacy",
|
||||
.title = "Privacy",
|
||||
.updated = "2026-08-08",
|
||||
.updated = "2026-08-14",
|
||||
.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",
|
||||
|
|
@ -238,7 +238,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.",
|
||||
"Payment happens on the payment provider's own pages, never here. Choosing bank or card sends you to Mollie, a Dutch licensed payment institution; choosing cryptocurrency sends you to CoinGate, a Lithuanian payment institution licensed under the EU crypto-asset regulation. Either way Catcrafts never sees card numbers, bank credentials or wallet keys, and learns only which order was paid, for how much, and by which method. What the provider you choose processes about you is between you and them under their own privacy policy.",
|
||||
"Bank and card payment happens on Mollie's own pages, never here — Mollie is a Dutch licensed payment institution, and Catcrafts never sees card numbers or bank credentials, only which order was paid, for how much, and by which method. What Mollie processes about you is between you and them under their own privacy policy. Cryptocurrency payment involves no provider at all: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Catcrafts never sees your wallet keys, and learns nothing beyond what the chain itself records — but note that a blockchain is public and permanent by nature, so the paying address, amount and time of your transaction are visible to anyone; only the order record described above connects them to you, and that record stays with Catcrafts.",
|
||||
"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.",
|
||||
} },
|
||||
{ "How long it is kept",
|
||||
|
|
@ -250,7 +250,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{ "What this site does not do",
|
||||
{
|
||||
"No analytics in your browser. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
|
||||
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie or CoinGate payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||
} },
|
||||
{ "Server logs",
|
||||
{
|
||||
|
|
@ -291,15 +291,15 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-13",
|
||||
.updated = "2026-08-14",
|
||||
.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",
|
||||
{
|
||||
"Submitting the order form creates an order and a payment link with the provider you picked. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A crypto invoice lapses quickly — within hours, and within minutes once a coin is selected — so an unfinished one usually means ordering again rather than waiting.",
|
||||
"Submitting the order form creates an order — with a Mollie payment link for bank and card, or with a receiving address of its own for cryptocurrency. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A cryptocurrency order's address stays reserved for about a day; after that the order lapses like any other unpaid one.",
|
||||
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
|
||||
"Payment is handled by a licensed provider, never on this site: bank and card payments by Mollie, a Dutch payment institution, and cryptocurrency by CoinGate, a Lithuanian payment institution authorised under the EU crypto-asset regulation. Catcrafts never sees your card number, bank credentials or wallet keys.",
|
||||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. The price is the euro amount; the provider fixes the exchange rate when you open the invoice, and that rate is what settles it. Every refund under the sections below is likewise calculated and owed in euros — if the coin has moved against you in the meantime, the euro figure is still the euro figure. A crypto payment can only be refunded to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
|
||||
"Bank and card payments are handled by Mollie, a Dutch licensed payment institution, on its own pages — Catcrafts never sees your card number or bank credentials. Cryptocurrency payments involve no provider: they are made in EURC, a euro-denominated stablecoin, directly to a wallet address held by Catcrafts and shown on the order page. Catcrafts never sees your wallet keys.",
|
||||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists — a payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
|
||||
"For support related to orders please contact orders@catcrafts.net"
|
||||
} },
|
||||
{ "Fulfilment",
|
||||
|
|
|
|||
|
|
@ -201,9 +201,9 @@ export struct Checkout {
|
|||
};
|
||||
|
||||
// How the buyer's money moves. Two KINDS of money movement, not two brand
|
||||
// names: which provider serves each is the server's configuration, and writing
|
||||
// the kind (rather than "mollie"/"coingate") into the form and the ledger means
|
||||
// swapping a provider cannot retroactively rewrite what a buyer picked.
|
||||
// names: which rail serves each is the server's configuration, and writing
|
||||
// the kind (rather than "mollie"/"eurc") into the form and the ledger means
|
||||
// swapping a rail cannot retroactively rewrite what a buyer picked.
|
||||
//
|
||||
// These strings are the wire format — they travel in the form post and land
|
||||
// verbatim in the order log — so they are defined once, here, where both the
|
||||
|
|
|
|||
|
|
@ -158,6 +158,10 @@ export SafeHtml Url(std::string_view attrName, std::string_view href) {
|
|||
StartsWithNoCase(cleaned, "https://")
|
||||
|| StartsWithNoCase(cleaned, "http://")
|
||||
|| StartsWithNoCase(cleaned, "mailto:")
|
||||
// EIP-681 payment links on the order page. Like mailto:, a pure
|
||||
// protocol-handler scheme: the browser hands it to a wallet or does
|
||||
// nothing. No script context is reachable through it.
|
||||
|| StartsWithNoCase(cleaned, "ethereum:")
|
||||
// Site-relative, but NOT protocol-relative ("//evil.example" would
|
||||
// leave the origin while looking like a path).
|
||||
|| (cleaned.size() >= 1 && cleaned[0] == '/'
|
||||
|
|
|
|||
|
|
@ -344,6 +344,24 @@ export Financials LoadFinancials(std::string_view json) {
|
|||
// Everything the order status page needs to render — a projection of the
|
||||
// server's order record, not the record itself. The renderer stays a pure
|
||||
// function in Shared; the server owns storage and fills this in.
|
||||
// Self-hosted crypto payment instructions, rendered on the order page when the
|
||||
// rail has no hosted checkout to send the buyer to. Everything is pre-formatted
|
||||
// text: the renderer should not know what a chain id or a token decimal is, any
|
||||
// more than it knows where ECB rates come from.
|
||||
export struct OrderCryptoPay {
|
||||
std::string address; // where the money goes, "0x…"
|
||||
std::string amount; // decimal EURC amount ("570.43") — equals the
|
||||
// euro total; EURC is euro-denominated at par
|
||||
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
|
||||
struct Chain {
|
||||
std::string name; // "base"
|
||||
std::string contract; // token contract, shown so the buyer can verify
|
||||
std::string link; // EIP-681 wallet URI; empty = no link rendered
|
||||
std::string note; // short hint ("lowest fees"), may be empty
|
||||
};
|
||||
std::vector<Chain> chains; // in display order — first is the recommended one
|
||||
};
|
||||
|
||||
export struct OrderView {
|
||||
std::string token; // the capability that IS the URL — never logged
|
||||
std::string reference; // short human code, quoted in the bank transfer
|
||||
|
|
@ -363,6 +381,9 @@ export struct OrderView {
|
|||
std::int64_t shippingMinor = 0;
|
||||
std::int64_t totalMinor = 0;
|
||||
bool vatIncluded = false;
|
||||
// Present only for an awaiting order on a rail without a hosted checkout;
|
||||
// the page then renders instructions instead of a "resume payment" button.
|
||||
std::optional<OrderCryptoPay> cryptoPay;
|
||||
};
|
||||
|
||||
// An entry on the demos page.
|
||||
|
|
|
|||
|
|
@ -224,9 +224,9 @@ export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
|
|||
|
||||
// ── indicative currency display ───────────────────────────────────────
|
||||
//
|
||||
// Orders are charged in euros, always — both rails collect EUR (CoinGate
|
||||
// converts the coin at a locked rate and settles euro) and the invoice is
|
||||
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
||||
// Orders are charged in euros, always — both rails collect EUR (crypto is
|
||||
// accepted in EURC, a euro stablecoin, so the token amount IS the euro
|
||||
// amount) and the invoice is EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
||||
// their card will actually take, so the order page also shows an INDICATIVE
|
||||
// conversion in the buyer's national currency, from ECB reference rates baked
|
||||
// in at build time. Indicative is the whole contract: the buyer's bank sets
|
||||
|
|
|
|||
|
|
@ -804,11 +804,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong> — Bitcoin and Lightning, )"
|
||||
R"(stablecoins and the other coins CoinGate lists. You pay the euro )"
|
||||
R"(total at the exchange rate CoinGate locks when you open the )"
|
||||
R"(invoice; crypto invoices expire quickly, so pay soon after )"
|
||||
R"(ordering or simply order again.</span></label>)"
|
||||
R"(<span><strong>Cryptocurrency</strong> — EURC, a euro )"
|
||||
R"(stablecoin, paid from your own wallet. The amount to send is the )"
|
||||
R"(euro total exactly, no exchange rate; the receiving address and )"
|
||||
R"(the networks it takes appear on the order page, and stay reserved )"
|
||||
R"(for about a day.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
|
|
@ -1242,18 +1242,86 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// the payment on arrival. Reaching it means they abandoned the payment,
|
||||
// so it reads as "resume", not as an alarming limbo.
|
||||
SafeHtml payBlock;
|
||||
if (awaiting && !o.payUrl.empty()) {
|
||||
// Self-hosted crypto first: its payUrl is this very page, so the button
|
||||
// branch would render a link to where the buyer already stands.
|
||||
if (awaiting && o.cryptoPay) {
|
||||
const OrderCryptoPay& pay = *o.cryptoPay;
|
||||
|
||||
// One list item per network, in the given order — the first entry is
|
||||
// the recommendation, which is how "cheapest chain first" reaches the
|
||||
// buyer as layout rather than as a lecture.
|
||||
std::vector<SafeHtml> items;
|
||||
for (const OrderCryptoPay::Chain& c : pay.chains) {
|
||||
std::string label = c.name;
|
||||
if (!label.empty() && label[0] >= 'a' && label[0] <= 'z') {
|
||||
label[0] = static_cast<char>(label[0] - 'a' + 'A');
|
||||
}
|
||||
items.push_back(Format(
|
||||
R"(<li><strong>{}</strong>{}{}<br>)"
|
||||
R"(<small>EURC contract <code>{}</code></small></li>)",
|
||||
Escape(label),
|
||||
c.note.empty() ? SafeHtml{} : Format(" — {}", Escape(c.note)),
|
||||
c.link.empty() ? SafeHtml{}
|
||||
: Format(R"( · <a{}>open in your wallet</a>)",
|
||||
Url("href", c.link)),
|
||||
Escape(c.contract)));
|
||||
}
|
||||
|
||||
// The window line tells the truth for both signs of minutesLeft. A
|
||||
// closed window does NOT mean sent money is gone — the address stays
|
||||
// ours — so the copy says where it went instead of leaving a buyer
|
||||
// staring at money that "vanished".
|
||||
const SafeHtml windowLine = pay.minutesLeft > 0
|
||||
? Format(
|
||||
R"(<p class="order__note">This address is reserved for this order )"
|
||||
R"(for about {} more {}. This page checks automatically and )"
|
||||
R"(confirms once the full amount has arrived.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
||||
: Raw(R"(<p class="order__note">The payment window for this order has )"
|
||||
R"(closed and the order will lapse. If you already sent EURC it )"
|
||||
R"(is not lost — it arrived at the address above; contact )"
|
||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||
R"(and it will be settled by hand.</p>)");
|
||||
|
||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
||||
R"(the euro amount above.</p>)",
|
||||
Escape(indicative));
|
||||
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Pay with EURC</h2>)"
|
||||
R"(<p>Send <strong>{} EURC</strong> to this address — one )"
|
||||
R"(network, one payment:</p>)"
|
||||
R"(<p><code class="order__address">{}</code></p>)"
|
||||
R"(<ul class="order__chains">{}</ul>)"
|
||||
R"({})"
|
||||
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
|
||||
R"(is exactly the euro total — no exchange rate. Send EURC only, )"
|
||||
R"(and only on a network listed above. If your exchange deducts a )"
|
||||
R"(withdrawal fee, send the missing rest to the same address; the )"
|
||||
R"(order confirms once the full amount sits on one network. Your )"
|
||||
R"(order reference is <strong>{}</strong>.</p>)"
|
||||
R"({})"
|
||||
R"(</section>)",
|
||||
Escape(pay.amount),
|
||||
Escape(pay.address),
|
||||
Join(items),
|
||||
indicativeLine,
|
||||
Escape(o.reference),
|
||||
windowLine);
|
||||
} else if (awaiting && !o.payUrl.empty()) {
|
||||
const bool crypto = o.payChoice == Form::kPayCrypto;
|
||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
||||
R"(the euro amount above; your bank or card sets the actual conversion )"
|
||||
R"(rate.</p>)",
|
||||
Escape(indicative));
|
||||
// What is waiting behind the button differs by rail, and so does what
|
||||
// "left uncompleted" costs the buyer: a Mollie payment can be resumed
|
||||
// for a good while, a crypto invoice expires in hours or minutes. A
|
||||
// page that promised the crypto buyer their link would keep would be
|
||||
// lying to exactly the person most likely to come back to it late.
|
||||
// What is waiting behind the button differs by rail. The real crypto
|
||||
// rail never reaches this branch (its instructions render above); a
|
||||
// crypto choice here means a hosted stand-in — the fake rail in tests
|
||||
// — so its copy stays generic rather than naming coins.
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Complete your payment</h2>)"
|
||||
|
|
@ -1265,13 +1333,10 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(</section>)",
|
||||
indicativeLine,
|
||||
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
||||
crypto ? Raw("The invoice takes Bitcoin, Lightning, stablecoins and the "
|
||||
"other coins CoinGate lists;")
|
||||
crypto ? Raw("The payment page completes your crypto payment;")
|
||||
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
|
||||
Escape(o.reference),
|
||||
crypto ? Raw("Crypto invoices expire quickly — if this one has, "
|
||||
"the order simply lapses and you can order again.")
|
||||
: Raw("A payment left uncompleted simply lapses the order."));
|
||||
Raw("A payment left uncompleted simply lapses the order."));
|
||||
} else if (o.status == "paid") {
|
||||
payBlock = Format(
|
||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue