donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -610,7 +610,11 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
R"(</article>)",
thumb,
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
p.Buyable() ? RenderCardPrice(p, rates)
// A donation has no price to quote — the card says so instead of
// rendering a €0 that the checkout would never charge.
p.donation && p.Buyable()
? Raw(R"(<p class="price price--card">any amount</p>)")
: p.Buyable() ? RenderCardPrice(p, rates)
: p.ComingSoon()
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
RenderCardPrice(p, rates))
@ -650,7 +654,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
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. Payment is handled by Mollie.)"
R"(week before dispatch. Or fund the work directly: the donation item )"
R"(takes any amount.)"
R"(</header>)"
R"(<div class="product-grid">{}</div>)",
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
@ -674,6 +679,40 @@ SafeHtml CustomsNote() {
R"(estimate them bindingly, and is not a party to them.</p>)");
}
// The payment choice, shared by the checkout form and the donation form so
// the two can never describe the same rails differently. A radio group rather
// than a <select> because both options carry a sentence the buyer should read
// BEFORE choosing — one settles in euro from their bank, the other locks a
// euro price against a coin — and a collapsed dropdown hides exactly that. It
// also needs no JavaScript, like everything else in these forms.
//
// Bank is pre-selected: it is what nearly every buyer wants, and an
// unselected group would let a distracted submit land on neither.
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
return Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> &mdash; iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; 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)),
wantsCrypto ? SafeHtml{} : Raw(" checked"),
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
payError);
}
// The checkout form.
//
// A real <form method="post">, not a JavaScript submit handler. It works with
@ -790,39 +829,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
}
cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage));
// The payment choice. A radio group rather than a <select> because both
// options carry a sentence the buyer should read BEFORE choosing — one
// settles in euro from their bank, the other locks a euro price against a
// coin — and a collapsed dropdown hides exactly that. It also needs no
// JavaScript, like everything else in this form.
//
// Bank is pre-selected: it is what nearly every buyer wants, and an
// unselected group would let a distracted submit land on neither.
SafeHtml payFieldset;
if (offerCrypto) {
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
payFieldset = Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> &mdash; iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; 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)),
wantsCrypto ? SafeHtml{} : Raw(" checked"),
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
errorFor("pay"));
}
const SafeHtml payFieldset =
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
return Format(
R"(<section class="checkout" id="buy">)"
@ -931,6 +939,76 @@ SafeHtml RenderCheckoutForm(const Product& product,
payFieldset);
}
// The donation form: the checkout form's small sibling. An amount instead of
// a price, an OPTIONAL email instead of a shipping address — nothing ships,
// so nothing more is asked for (the privacy notice's "what fulfilling it
// requires" rule, applied to a gift). Same POST target, same honeypot, same
// payment fieldset, same no-JavaScript guarantee.
SafeHtml RenderDonationForm(const Product& product,
std::span<const Form::FieldError> errors,
const Form::Checkout& prev,
bool offerCrypto) {
auto errorFor = [&](std::string_view field) -> SafeHtml {
for (const Form::FieldError& e : errors) {
if (e.field == field) {
return Format(R"(<p class="field__error">{}</p>)", Escape(e.message));
}
}
return SafeHtml{};
};
SafeHtml formError;
for (const Form::FieldError& e : errors) {
if (e.field.empty()) {
formError = Format(R"(<p class="notice notice--error">{}</p>)", Escape(e.message));
break;
}
}
return Format(
R"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Donate</h2>)"
R"(<p class="checkout__lede">Pick any amount{}. Submitting creates the )"
R"(donation and takes you straight to the payment page. Nothing is owed )"
R"(until you actually pay; an unpaid donation just lapses.</p>)"
R"(<p class="checkout__shipnote">No VAT is charged on a donation and no )"
R"(invoice is issued &mdash; the donation page is its receipt. Donations )"
R"(appear on the financials page as an aggregate total, never )"
R"(individually.</p>)"
R"({})"
R"(<form class="form" method="post"{} novalidate>)"
R"(<div class="field">)"
R"(<label for="f-amount">Amount in euros <span class="field__req">required</span></label>)"
R"(<input id="f-amount" name="amount" type="number" inputmode="decimal" )"
R"(min="1" max="10000" step="0.01" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-email">Email</label>)"
R"(<input id="f-email" name="email" type="email" autocomplete="email"{}>)"
R"(<p class="field__hint">Optional &mdash; only used to send the )"
R"(confirmation. Leave it empty and the donation page is your receipt.</p>)"
R"({})"
R"(</div>)"
R"({})"
R"(<div class="honeypot" aria-hidden="true">)"
R"(<label for="f-website">Leave this empty</label>)"
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
R"(</div>)"
R"(<button class="btn btn--primary" type="submit">Donate &mdash; continue to payment</button>)"
R"(</form>)"
R"(</section>)",
offerCrypto ? SafeHtml{}
: Raw(", paid by iDEAL, card, or a plain bank transfer, "
"handled by Mollie"),
formError,
Url("action", "/shop/" + product.slug + "#buy"),
prev.amountMinor > 0
? Attr("value", Money::FormatMinor(prev.amountMinor)) : SafeHtml{},
errorFor("amount"),
Attr("value", prev.email), errorFor("email"),
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{});
}
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
// defaults to false. Only the native server passes it true, because only the
// server knows whether the crypto rail is configured.
@ -952,7 +1030,9 @@ export RenderedPage RenderProduct(const Product& product,
page.meta.canonical = "/shop/" + product.slug;
page.meta.ogType = "product";
page.meta.ogImage = product.image;
page.meta.geoPriceHint = true;
// The donation page shows no converted prices — there is no price — so it
// ships no price-hint script either, keeping it entirely script-free.
page.meta.geoPriceHint = !product.donation;
// The commercial record: a ProductGroup with one variant Product per
// colour, each carrying its ONE offer, prices from the same integers the
@ -964,6 +1044,10 @@ export RenderedPage RenderProduct(const Product& product,
// the status field, so launch day flips PreOrder to InStock with no edit
// here.
//
// A donation emits none of it: it has no price, no shipping and no return
// policy, and a Product record whose offer names no amount is a claim
// shopping crawlers can only misread.
//
// Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight — productGroupID is what it
@ -971,7 +1055,7 @@ export RenderedPage RenderProduct(const Product& product,
// at single-unit weight, the same integers checkout charges, so the listing
// and the till cannot disagree; a destination with no carrier rate is
// simply not advertised, because it is not for sale.
{
if (!product.donation) {
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
product.Buyable() ? "https://schema.org/InStock"
@ -1154,7 +1238,9 @@ export RenderedPage RenderProduct(const Product& product,
Escape(product.safetyNote));
SafeHtml buy;
if (product.Buyable()) {
if (product.donation && product.Buyable()) {
buy = RenderDonationForm(product, errors, prev, offerCrypto);
} else if (product.Buyable()) {
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
} else if (product.ComingSoon()) {
// The launch prices are already public, per colour, with the same
@ -1181,6 +1267,26 @@ export RenderedPage RenderProduct(const Product& product,
R"(is in flux. Check back, or watch the posts.</p></section>)");
}
// The spec and warranty sections exist only where the content does: a
// donation has neither a spec sheet nor a warranty, and an empty table
// under a Fairphone-specific lede would be nonsense on its page.
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 Fairphone )"
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
R"(and all of it works under postmarketOS. The one caveat is the )"
R"(emergency-calling warning above.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)",
Join(specRows));
const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)",
Escape(product.warranty));
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
@ -1190,23 +1296,15 @@ export RenderedPage RenderProduct(const Product& product,
R"({})"
R"(<p class="product__summary">{}</p>)"
R"({})"
R"(<section class="section">)"
R"(<h2 class="section__title">Specifications</h2>)"
R"(<p class="section__lede">The hardware is a stock Fairphone )"
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
R"(and all of it works under postmarketOS. The one caveat is the )"
R"(emergency-calling warning above.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)"
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)"
R"({})"
R"({})"
R"({})",
Escape(product.name), Escape(product.tagline),
media, RenderPriceLine(product, rates), Escape(product.summary),
safety, Join(specRows),
Escape(product.warranty),
media,
product.donation ? SafeHtml{} : RenderPriceLine(product, rates),
Escape(product.summary),
safety, specsSection,
warrantySection,
buy);
return page;
}
@ -1346,15 +1444,25 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
Escape(o.reference),
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>)"
R"(<p>The device is ordered, flashed 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 )"
R"(gpg --verify, independent of this site.</p>)"
R"(</section>)",
Url("href", "/order/" + o.token + "/invoice.md"));
// A donation ships nothing and gets no invoice — a gift with nothing
// supplied in return is not a taxable supply — so its paid state is a
// thank-you, not a dispatch promise with a download button.
payBlock = o.donation
? Raw(R"(<section class="section"><h2 class="section__title">Thank you</h2>)"
R"(<p>Your donation funds the open-source work directly. It will )"
R"(appear in the running total on the financials page &mdash; as )"
R"(an aggregate, never individually. This page is your receipt; )"
R"(no invoice is issued for a donation.</p>)"
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"(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 )"
R"(gpg --verify, independent of this site.</p>)"
R"(</section>)",
Url("href", "/order/" + o.token + "/invoice.md"));
}
// The paid state IS the success page — say so before the receipt table.
@ -1363,6 +1471,18 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
R"(confirmed. This page is your receipt.</p>)")
: SafeHtml{};
// A donation's money is one line — an amount with nothing shipped adds no
// shipping row and needs no separate total. Goods orders keep the full
// breakdown.
const SafeHtml moneyRows = o.donation
? MoneyRow("Donation", o.totalMinor)
: Format(R"({}{}{})",
MoneyRow(o.quantity > 1
? std::format("Device × {}", o.quantity)
: std::string("Device"), o.goodsMinor),
MoneyRow("Shipping", o.shippingMinor),
MoneyRow("Total", o.totalMinor));
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Order {}</h1>)"
@ -1374,14 +1494,12 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
R"(<h2 class="section__title">Total</h2>)"
R"(<table class="spec-table"><tbody>)"
R"({})"
R"({})"
R"({})"
R"(</tbody></table>)"
R"(<p class="order__vat">{}</p>)"
R"(</section>)"
R"({})"
R"(<p class="order__keep">There is no account; this link is the access. )"
R"(Download the invoice and keep it. This page is not archived )"
R"({} This page is not archived )"
R"(forever.</p>)",
Escape(o.reference),
Escape(o.colorLabel.empty()
@ -1390,19 +1508,22 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
Escape(o.createdAt),
statusLine,
confirmation,
MoneyRow(o.quantity > 1
? std::format("Device × {}", o.quantity)
: std::string("Device"), o.goodsMinor),
MoneyRow("Shipping", o.shippingMinor),
MoneyRow("Total", o.totalMinor),
o.vatIncluded
? Raw("Includes 21% Dutch VAT.")
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
"VAT, tariffs and any carrier handling fee are charged on arrival "
"and are solely between you, the courier and your customs "
"authority; Catcrafts does not collect them and is not a party "
"to them."),
payBlock);
moneyRows,
o.donation
// The user's rule, stated plainly: 0% — a donation is a gift, not
// a supply, so no VAT arises and neither export wording applies.
? Raw("VAT 0%: no VAT is charged on a donation — nothing is "
"supplied in return.")
: o.vatIncluded
? Raw("Includes 21% Dutch VAT.")
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
"VAT, tariffs and any carrier handling fee are charged on arrival "
"and are solely between you, the courier and your customs "
"authority; Catcrafts does not collect them and is not a party "
"to them."),
payBlock,
o.donation ? Raw("Keep it if you want the receipt.")
: Raw("Download the invoice and keep it."));
// A plain <meta refresh> is the no-JavaScript way to make the page track
// the payment: the browser refetches, the server re-reads the order. Only
// while awaiting — a paid page has nothing to poll for.
@ -1528,9 +1649,18 @@ export RenderedPage RenderAbout(const LegalPage& about) {
// The data-fin-* attributes are the machine-readable copy of the figures —
// what the e2e suite asserts against, and what anyone scraping the page in
// good faith should read instead of parsing euro signs.
//
// Donations arrive from TWO ledgers: the bank aggregates in `fin`, and the
// shop's own order ledger (`shopDonationCount`/`shopDonationsMinor`) — the
// donation item is paid through the same rails as a sale, so its money never
// touches the bank categoriser. The page shows one Donations row summing
// both; splitting them by collection channel would be bookkeeping trivia the
// reader has no use for.
export RenderedPage RenderFinancials(std::int64_t salesCount,
std::int64_t salesTotalMinor,
const Financials& fin) {
const Financials& fin,
std::int64_t shopDonationCount = 0,
std::int64_t shopDonationsMinor = 0) {
const LegalPage& notes = Content::FinancialsPage();
// A total row is ruled off from the rows it sums, the way a ledger is.
@ -1540,20 +1670,25 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
Escape(label), Escape(Money::FormatEuro(minor)));
};
// Income. Sales are always live; the donation row exists only once the
// bank figures do — a €0 the page cannot yet know would be a lie, and so
// would an income total missing half its inputs.
// Income. Sales are always live; the donation row exists once EITHER
// source has figures — the bank aggregates, or a donation paid through
// the shop (live from the order ledger, like sales). Before both, a €0
// the page cannot yet know would be a lie, and so would an income total
// missing half its inputs.
const std::int64_t donationCount = fin.donationCount + shopDonationCount;
const std::int64_t donationsMinor = fin.donationsMinor + shopDonationsMinor;
const bool showDonations = fin.Loaded() || shopDonationCount > 0;
std::vector<SafeHtml> incomeRows;
if (fin.Loaded()) {
if (showDonations) {
incomeRows.push_back(MoneyRow(
std::format("Donations ({})", fin.donationCount),
fin.donationsMinor));
std::format("Donations ({})", donationCount),
donationsMinor));
}
incomeRows.push_back(MoneyRow(
std::format("Sales ({})", salesCount),
salesTotalMinor));
if (fin.Loaded()) {
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor));
incomeRows.push_back(totalRow("Income", donationsMinor + salesTotalMinor));
}
// Expenses: one flat table. No recurring/one-off grouping — see the note
@ -1583,7 +1718,7 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
// support. FormatEuro renders a negative as "€-12.34", which is the
// honest thing to show in a month that bought inventory.
const std::int64_t netMinor =
fin.donationsMinor + salesTotalMinor - fin.ExpensesMinor();
donationsMinor + salesTotalMinor - fin.ExpensesMinor();
SafeHtml netBlock;
if (fin.Loaded()) {
netBlock = Format(
@ -1601,10 +1736,12 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
// The freshness line keeps the page honest about its two cadences.
const SafeHtml freshness = fin.Loaded()
? Format(R"(<p class="legal__updated">Sales are live from the order ledger &middot; )"
? Format(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger &middot; )"
R"(bank figures as of <time{}>{}</time></p>)",
Attr("datetime", fin.asOf), Escape(fin.asOf))
: Raw(R"(<p class="legal__updated">Sales are live from the order ledger</p>)");
: Raw(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger</p>)");
// The methodology prose, in the legal pages' section shape and CSS.
std::vector<SafeHtml> sections;
@ -1639,10 +1776,10 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
Escape(notes.title), Escape(notes.lede), freshness,
Attr("data-fin-sales-count", std::to_string(salesCount)),
Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)),
fin.Loaded() ? Attr("data-fin-donations-count", std::to_string(fin.donationCount))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-donations-minor", std::to_string(fin.donationsMinor))
: SafeHtml{},
showDonations ? Attr("data-fin-donations-count", std::to_string(donationCount))
: SafeHtml{},
showDonations ? Attr("data-fin-donations-minor", std::to_string(donationsMinor))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},