Verify a card to continue. You will not be charged.
';
+ h += options.phone_fallback
+ ? 'Verify a card instead of your phone number. You will not be charged.
'
+ : 'Verify a card to continue. You will not be charged.
';
// Offer a friendly human fallback so verification is never a dead end —
// worded as help, not as an accusation.
h += `Card verification is temporarily unavailable. Please try again in a few minutes.
';
h += `';
h += `
${i18n('log_out')}`;
@@ -170,12 +199,38 @@ function UIWindowCardVerificationRequired(options) {
$(el_window).find(`.card-step-${name}`).show();
};
+ // Resolve at most once: `finish` and `backToPhone` are the only exits
+ // and a double close would operate on a detached window.
+ let settled = false;
+
const finish = () => {
+ if (settled) return;
+ settled = true;
$(el_window).close();
window.refresh_user_data(window.auth_token);
resolve(true);
};
+ // Fallback mode only: leave without clearing the gate. Resolving false
+ // hands the user back to whoever opened this dialog (the phone
+ // verification window), which is still the live gate on the account.
+ const backToPhone = () => {
+ if (settled) return;
+ settled = true;
+ $(el_window).close();
+ resolve(false);
+ };
+
+ // Fallback mode only: the server answered `card_verified` without
+ // lifting the phone gate — the card path can't clear this account
+ // (typically the server-side kill switch). Reporting success here would
+ // close the dialog on a still-phone-gated account, so bounce back to
+ // the phone gate with the reason instead.
+ const cardCannotClearPhoneGate = () => {
+ options.on_unavailable?.();
+ backToPhone();
+ };
+
const mountPaymentElement = async (publishable_key, client_secret) => {
await loadStripeJs();
stripe = window.Stripe(publishable_key);
@@ -220,6 +275,13 @@ function UIWindowCardVerificationRequired(options) {
// Already verified, or the feature was disabled server-side
// (kill switch) — either way the gate is satisfied.
if (res.card_verified) {
+ // ...except in fallback mode, where the phone gate is
+ // the one that has to lift and this short-circuit never
+ // lifts it.
+ if (options.phone_fallback) {
+ cardCannotClearPhoneGate();
+ return;
+ }
finish();
return;
}
@@ -321,6 +383,13 @@ function UIWindowCardVerificationRequired(options) {
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function (res) {
if (res.card_verified) {
+ // In fallback mode the card is only worth anything
+ // if it took the phone gate down with it; the
+ // server says so with `phone_verified`.
+ if (options.phone_fallback && !res.phone_verified) {
+ cardCannotClearPhoneGate();
+ return;
+ }
finish();
return;
}
@@ -365,6 +434,12 @@ function UIWindowCardVerificationRequired(options) {
$(el_window).close();
});
+ $(el_window)
+ .find('.card-back-to-phone')
+ .on('click', function () {
+ backToPhone();
+ });
+
startSetup();
});
}
diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js
index 7f11ef1b3..72d31990f 100644
--- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js
+++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js
@@ -18,6 +18,7 @@
*/
import UIWindow from './UIWindow.js';
+import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
import { get_country_list } from '../helpers/country_codes.js';
import {
format_phone_as_you_type,
@@ -32,6 +33,13 @@ import {
// The 6-digit code UX mirrors UIWindowEmailConfirmationRequired.js. Used as a
// hard gate for low-reputation signups, so by default it has no close button.
//
+// When the server reports `card_fallback_available` on a send (either a
+// successful one or a refusal), SMS is not the only way out: the backend has
+// opened a card-verification path that clears the phone gate too. This dialog
+// surfaces that as an opt-in link rather than leaving the user to retry a send
+// that keeps failing. The card dialog can be dismissed straight back here, so
+// the choice is reversible either way.
+//
// The number field combines a searchable country-code picker with the national
// number. Everything the user types is normalized to E.164 with libphonenumber
// before it's sent, so country selection and on-screen formatting are purely a
@@ -65,8 +73,13 @@ function UIWindowPhoneVerificationRequired(options) {
let is_checking_code = false;
let is_sending = false;
// Resolve the returned promise at most once. Success resolves(true);
- // a user-initiated close resolves(false). Idempotent so the close hook
- // can fire after a success without clobbering the result.
+ // a user-initiated close resolves(false). The card fallback resolves
+ // the string 'card' — truthy, so callers polling `while (!ok)` are
+ // unaffected, but distinguishable for the one caller decision it
+ // changes: a fallback card clears the card gate along with the phone
+ // gate, so the card dialog must not be opened again afterwards.
+ // Idempotent so the close hook can fire after a success without
+ // clobbering the result.
let settled = false;
const settle = (val) => {
if (settled) return;
@@ -113,6 +126,10 @@ function UIWindowPhoneVerificationRequired(options) {
select_country: i18n('phone_select_country'),
code_sent_to: i18n('phone_code_sent_to'),
code_sent_whatsapp: i18n('phone_code_sent_whatsapp'),
+ card_fallback_prompt: i18n('phone_card_fallback_prompt'),
+ card_fallback_link: i18n('phone_card_fallback_link'),
+ card_fallback_note: i18n('phone_card_fallback_note'),
+ card_fallback_unavailable: i18n('phone_card_fallback_unavailable'),
suggested: i18n('phone_suggested'),
all_countries: i18n('phone_all_countries'),
};
@@ -195,6 +212,15 @@ function UIWindowPhoneVerificationRequired(options) {
.map((c) => renderOption(c, 'cc-opt-', 'all'))
.join('');
+ // Card escape hatch, rendered into both steps and revealed only when a
+ // send response says the backend opened it.
+ const card_fallback_html =
+ '
';
+
let h = '';
// Scoped styling for this dialog.
h += `