This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
|
|
@ -738,17 +738,47 @@ SafeHtml CustomsNote() {
|
|||
// 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;
|
||||
// Bank is pre-selected WHEN IT IS OFFERED: it is what nearly every buyer
|
||||
// wants, and an unselected group would let a distracted submit land on
|
||||
// neither.
|
||||
//
|
||||
// `offerBank` is the mirror of the `offerCrypto` that gates this whole
|
||||
// fieldset, and it defaults to true so every caller that cannot know (the
|
||||
// wasm fallback page, the suites) keeps the two-option form it always had.
|
||||
// It exists because the asymmetry was a real outage: when the bank rail went
|
||||
// away on 2026-08-20 the form went on rendering a pre-selected "Bank or card"
|
||||
// option that checkout could only answer with a 503, which is the majority of
|
||||
// buyers walking into a wall. With it false the bank option is not rendered at
|
||||
// all, so the crypto radio is the only one present AND is checked — the form
|
||||
// must still POST a `pay` value, because an absent one resolves to the bank
|
||||
// rail by design.
|
||||
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError,
|
||||
bool offerBank = true) {
|
||||
// With one option left there is nothing to choose, so it is pre-selected
|
||||
// regardless of what the buyer picked on a previous, rejected submit.
|
||||
const bool wantsCrypto = !offerBank || prev.payChoice == Form::kPayCrypto;
|
||||
// This copy has to describe whatever rail is actually in the bank slot,
|
||||
// and today that is the self-hosted transfer rail: a plain SEPA transfer
|
||||
// to the shop's own account, with the details on the order page. It
|
||||
// deliberately does NOT promise iDEAL or cards, which is what it said
|
||||
// while a hosted provider served the slot. Promising a method the rail
|
||||
// cannot take is the same class of bug as offering a rail that is not
|
||||
// configured: the buyer finds out at the last step.
|
||||
const SafeHtml bankOption = offerBank
|
||||
? Format(
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Bank transfer</strong><br>A normal transfer from )"
|
||||
R"(your own bank. Works with any bank, there is nothing to sign )"
|
||||
R"(up for, and the account details and reference appear on the )"
|
||||
R"(order page.</span></label>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"))
|
||||
: SafeHtml{};
|
||||
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><br>iDEAL, card, or a plain )"
|
||||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"({})"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong><br>EURC, a euro )"
|
||||
|
|
@ -758,8 +788,7 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
|||
R"(reserved for about a day.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"),
|
||||
bankOption,
|
||||
Attr("value", std::string(Form::kPayCrypto)),
|
||||
wantsCrypto ? Raw(" checked") : SafeHtml{},
|
||||
payError);
|
||||
|
|
@ -788,11 +817,15 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
|||
// actually serve. With it false the form posts no `pay` field at all and the
|
||||
// handler takes the bank rail, which is exactly the behaviour that existed
|
||||
// before there was anything to choose.
|
||||
// `offerBank` is the same promise for the other slot, and defaults to true so
|
||||
// the callers that cannot know keep their previous behaviour. See
|
||||
// RenderPayFieldset for why the asymmetry had to be closed.
|
||||
SafeHtml RenderCheckoutForm(const Product& product,
|
||||
std::span<const Money::ShipRates> liveShipping,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
bool offerCrypto,
|
||||
bool offerBank = true) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
|
|
@ -886,7 +919,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage));
|
||||
|
||||
const SafeHtml payFieldset =
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
|
||||
: SafeHtml{};
|
||||
|
||||
return Format(
|
||||
R"(<section class="checkout" id="buy">)"
|
||||
|
|
@ -974,7 +1008,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
// With the choice rendered below, the fieldset lists the methods and
|
||||
// the lede would only repeat half of them.
|
||||
offerCrypto ? SafeHtml{}
|
||||
: Raw(": iDEAL, card, or a bank transfer, handled by Mollie"),
|
||||
: Raw(", paid by bank transfer"),
|
||||
Escape(Form::kShipsToMessage),
|
||||
Escape(Form::kSanctionsMessage),
|
||||
CustomsNote(),
|
||||
|
|
@ -1003,7 +1037,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
SafeHtml RenderDonationForm(const Product& product,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
bool offerCrypto,
|
||||
bool offerBank = true) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
|
|
@ -1054,26 +1089,30 @@ SafeHtml RenderDonationForm(const Product& product,
|
|||
R"(</form>)"
|
||||
R"(</section>)",
|
||||
offerCrypto ? SafeHtml{}
|
||||
: Raw(", paid by iDEAL, card, or a plain bank transfer, "
|
||||
"handled by Mollie"),
|
||||
: Raw(", paid by a plain bank transfer"),
|
||||
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 ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
|
||||
: 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.
|
||||
// server knows whether the crypto rail is configured. `offerBank` is the same
|
||||
// fact about the other slot and defaults to TRUE rather than false, because a
|
||||
// caller that cannot know must keep advertising the rail that has always been
|
||||
// there — see RenderPayFieldset.
|
||||
export RenderedPage RenderProduct(const Product& product,
|
||||
const Rates& rates,
|
||||
std::span<const Money::ShipRates> liveShipping = {},
|
||||
std::span<const Form::FieldError> errors = {},
|
||||
const Form::Checkout& prev = {},
|
||||
bool offerCrypto = false) {
|
||||
bool offerCrypto = false,
|
||||
bool offerBank = true) {
|
||||
std::vector<SafeHtml> specRows;
|
||||
for (const Spec& s : product.specs) {
|
||||
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
|
||||
|
|
@ -1295,9 +1334,10 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
SafeHtml buy;
|
||||
if (product.donation && product.Buyable()) {
|
||||
buy = RenderDonationForm(product, errors, prev, offerCrypto);
|
||||
buy = RenderDonationForm(product, errors, prev, offerCrypto, offerBank);
|
||||
} else if (product.Buyable()) {
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto,
|
||||
offerBank);
|
||||
} else if (product.ComingSoon()) {
|
||||
// The launch prices are already public, per colour, with the same
|
||||
// money terms the live form will carry. Only the form is held back,
|
||||
|
|
@ -1404,18 +1444,28 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// badge itself, not only in the note below: the badge is where a buyer
|
||||
// who just paid looks first, and "awaiting payment" there reads as "your
|
||||
// money did not arrive" no matter what a paragraph underneath explains.
|
||||
// `seen` means different things on the two rails, so the badge must not
|
||||
// use one wording for both. On the crypto rail it means the transfer is
|
||||
// visible on chain but not yet in a finalized block. On the bank rail it
|
||||
// means money arrived that does NOT cover the order — a part payment —
|
||||
// and telling that buyer about "network confirmation" would be nonsense
|
||||
// about a mechanism their bank transfer never touches. What they need to
|
||||
// know is that their money landed and what is still outstanding.
|
||||
const bool inFlight = awaiting && o.cryptoPay && o.cryptoPay->seen;
|
||||
const bool inFlightBank = inFlight && !o.cryptoPay->beneficiary.empty();
|
||||
SafeHtml statusLine =
|
||||
inFlight ? Raw(R"(<span class="badge badge--experiment">payment detected, awaiting network confirmation</span>)")
|
||||
inFlightBank ? Raw(R"(<span class="badge badge--experiment">part payment received, waiting for the balance</span>)")
|
||||
: inFlight ? Raw(R"(<span class="badge badge--experiment">payment detected, awaiting network confirmation</span>)")
|
||||
: awaiting ? Raw(R"(<span class="badge badge--experiment">awaiting payment</span>)")
|
||||
: o.status == "paid" ? SafeHtml{}
|
||||
: o.status == "shipped" ? Raw(R"(<span class="badge badge--active">shipped</span>)")
|
||||
: Raw(R"(<span class="badge">cancelled</span>)");
|
||||
|
||||
// The buyer normally never sees the awaiting state: checkout sends them
|
||||
// straight to Mollie, and coming back the server has already confirmed
|
||||
// the payment on arrival. Reaching it means they abandoned the payment,
|
||||
// so it reads as "resume", not as an alarming limbo.
|
||||
// to the payment instructions, and both live rails render those on this
|
||||
// very page. Reaching the awaiting state therefore just means the money
|
||||
// has not arrived yet, so it reads as "here is how to pay", not as an
|
||||
// alarming limbo.
|
||||
SafeHtml payBlock;
|
||||
// Self-hosted crypto first: its payUrl is this very page, so the button
|
||||
// branch would render a link to where the buyer already stands.
|
||||
|
|
@ -1450,20 +1500,45 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// says "success" within seconds, this shop only believes finalized
|
||||
// blocks, and a buyer left to discover that ~15-minute gap alone
|
||||
// discovers the support address instead.
|
||||
// Same two truths for either rail, in each rail's own words. The
|
||||
// lapsed branch matters more than it looks: in BOTH cases the
|
||||
// destination stays ours, so money already sent is not lost, and a
|
||||
// buyer told only "the window closed" would reasonably conclude it
|
||||
// was. The transfer wording also has to survive the case where the
|
||||
// money is simply slow — a non-instant transfer from outside the euro
|
||||
// area can arrive after the window on its own.
|
||||
const bool bank = !pay.beneficiary.empty();
|
||||
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 and the network has )"
|
||||
R"(finalized it: your wallet will report success well before then, )"
|
||||
R"(and confirmation here typically follows in 10 to 25 minutes.</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>)");
|
||||
? (bank
|
||||
? Format(
|
||||
R"(<p class="order__note">This order is held for about {} )"
|
||||
R"(more {}. Transfers inside the Netherlands usually arrive )"
|
||||
R"(within seconds, elsewhere in Europe it can take a )"
|
||||
R"(business day. This page checks automatically and confirms )"
|
||||
R"(as soon as the money lands, so there is nothing to send )"
|
||||
R"(us and nothing to wait for here.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
||||
: 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 and the network has )"
|
||||
R"(finalized it: your wallet will report success well before then, )"
|
||||
R"(and confirmation here typically follows in 10 to 25 minutes.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes")))
|
||||
: (bank
|
||||
? Raw(R"(<p class="order__note">The payment window for this order has )"
|
||||
R"(closed and the order will lapse. If you already sent the )"
|
||||
R"(transfer it is not lost: the account above is ours and the )"
|
||||
R"(money arrived there. Contact )"
|
||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||
R"(with your order reference and it will be settled by hand.</p>)")
|
||||
: 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>)"));
|
||||
|
||||
// The in-flight state renders as the status badge up top
|
||||
// ("confirming payment"), where a buyer who just paid looks first;
|
||||
|
|
@ -1473,6 +1548,66 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(the euro amount above.</p>)",
|
||||
Escape(indicative));
|
||||
|
||||
// A non-empty beneficiary marks a BANK TRANSFER: the address is an
|
||||
// IBAN, there are no networks, and none of the token copy applies.
|
||||
// Both live rails are self-hosted and so both land in this branch;
|
||||
// what separates them is which of these two blocks renders.
|
||||
if (!pay.beneficiary.empty()) {
|
||||
// Three fields, in the order a banking app asks for them, so the
|
||||
// buyer can work straight down the page instead of hunting.
|
||||
//
|
||||
// The name comes FIRST and is labelled as exact on purpose. Every
|
||||
// euro-area transfer is now name-checked against the IBAN, and a
|
||||
// payer who types anything else gets a mismatch warning at the
|
||||
// moment of paying. Telling them why the spelling looks odd is
|
||||
// cheaper than losing the payment to a scary red banner.
|
||||
//
|
||||
// Both reference forms are offered because banks disagree about
|
||||
// where a reference goes: those with a dedicated payment-reference
|
||||
// field validate the RF form's check digits and refuse a mistyped
|
||||
// one before the money moves, which is the safer path; the rest
|
||||
// only have a free-text description, where the short code is what
|
||||
// a human will actually copy correctly.
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Pay by bank transfer</h2>)"
|
||||
R"(<p>Transfer <strong>{}</strong> to this account:)"
|
||||
R"(<dl class="order__bank">)"
|
||||
R"(<dt>Account holder</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"(<dt>IBAN</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"({})"
|
||||
R"(<dt>Payment reference</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"(</dl>)"
|
||||
R"({})"
|
||||
R"(<p class="order__note">Please copy the the fields )"
|
||||
R"(exactly as written above. If your bank )"
|
||||
R"(has a separate field for a payment reference, use )"
|
||||
R"(<strong>{}</strong> there, it is checked for typing errors. )"
|
||||
R"(Otherwise put <strong>{}</strong> in the description. )"
|
||||
R"(Without the reference the payment cannot be matched to your )"
|
||||
R"(order. If you send too little, transfer the rest the same )"
|
||||
R"(way and the order confirms once the total arrives.</p>)"
|
||||
R"({})"
|
||||
R"(</section>)",
|
||||
Escape(Money::FormatEuro(o.totalMinor)),
|
||||
Escape(pay.beneficiary),
|
||||
Escape(pay.address),
|
||||
// Only for the payer who actually needs it. Inside SEPA the
|
||||
// IBAN is enough, so this row is absent rather than being a
|
||||
// field every Dutch buyer feels obliged to fill in.
|
||||
pay.bic.empty() ? SafeHtml{} : Format(
|
||||
R"(<dt>BIC <span class="order__bank-hint">(only if your bank )"
|
||||
R"(asks for it, usually outside Europe)</span></dt>)"
|
||||
R"(<dd><code class="order__address">{}</code></dd>)",
|
||||
Escape(pay.bic)),
|
||||
Escape(pay.structuredReference.empty() ? o.reference
|
||||
: pay.structuredReference),
|
||||
indicativeLine,
|
||||
Escape(pay.structuredReference.empty() ? o.reference
|
||||
: pay.structuredReference),
|
||||
Escape(o.reference),
|
||||
windowLine);
|
||||
} else {
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Pay with EURC</h2>)"
|
||||
|
|
@ -1495,6 +1630,7 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
indicativeLine,
|
||||
Escape(o.reference),
|
||||
windowLine);
|
||||
}
|
||||
} else if (awaiting && !o.payUrl.empty()) {
|
||||
const bool crypto = o.payChoice == Form::kPayCrypto;
|
||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||
|
|
@ -1517,8 +1653,14 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(</section>)",
|
||||
indicativeLine,
|
||||
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
||||
// Both live rails render their instructions above rather than a
|
||||
// button, so reaching this branch means a HOSTED stand-in is in
|
||||
// the slot: the fake rails in the suites, or the reference
|
||||
// a hosted rail, if one is ever added again. The copy therefore
|
||||
// stays generic about methods instead of naming any, since what
|
||||
// waits behind the button is exactly what this branch cannot know.
|
||||
crypto ? Raw("The payment page completes your crypto payment;")
|
||||
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
|
||||
: Raw("The payment page completes your payment;"),
|
||||
Escape(o.reference),
|
||||
Raw("A payment left uncompleted simply lapses the order."));
|
||||
} else if (o.status == "paid") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue