Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@
"ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.",
"ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.",
"ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.",
"ORG_IDENT_AVAILABLE": "This subdomain is available.",
"ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.",
"ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…",
"ROLE_VIEW": "View",
Expand Down
1 change: 1 addition & 0 deletions locale/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@
"ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.",
"ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.",
"ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.",
"ORG_IDENT_AVAILABLE": "This subdomain is available.",
"ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.",
"ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…",
"ROLE_VIEW": "View",
Expand Down
1 change: 1 addition & 0 deletions locale/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,7 @@
"ORG_IDENT_REQUIRED": "Saisissez le nom et le sous-domaine de votre organisation pour continuer.",
"ORG_IDENT_INVALID": "Ce sous-domaine n'est pas valide — utilisez uniquement des minuscules, des chiffres et des tirets.",
"ORG_IDENT_TAKEN": "Ce sous-domaine est déjà pris. Veuillez en choisir un autre.",
"ORG_IDENT_AVAILABLE": "Ce sous-domaine est disponible.",
"ORG_ALREADY_IN_DOMAIN": "Votre compte appartient déjà à une organisation.",
"ORG_PROVISIONED": "L'espace de votre organisation est prêt — rechargement vers votre nouvelle adresse…",
"ROLE_VIEW": "Lecture",
Expand Down
1 change: 1 addition & 0 deletions locale/km.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,7 @@
"ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.",
"ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.",
"ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.",
"ORG_IDENT_AVAILABLE": "This subdomain is available.",
"ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.",
"ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…",
"ROLE_VIEW": "View",
Expand Down
1 change: 1 addition & 0 deletions locale/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,7 @@
"ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.",
"ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.",
"ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.",
"ORG_IDENT_AVAILABLE": "This subdomain is available.",
"ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.",
"ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…",
"ROLE_VIEW": "View",
Expand Down
1 change: 1 addition & 0 deletions locale/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,7 @@
"ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.",
"ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.",
"ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.",
"ORG_IDENT_AVAILABLE": "This subdomain is available.",
"ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.",
"ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…",
"ROLE_VIEW": "View",
Expand Down
157 changes: 156 additions & 1 deletion src/drumee/builtins/widget/settings/account/billing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
// account, this is the single place that changes.
const PROMO_YEARLY_SEEN_KEY = "drumee.promo.yearly.shown-on";

// How long the subdomain field stays quiet before its availability is checked.
// Long enough that an ordinary typist produces one request per word rather
// than one per letter; short enough that the verdict is there before the hand
// reaches Proceed to Checkout. payment.validate_org_ident is a DB-only read
// (one proc + one count query), so this costs nothing Stripe-shaped.
const ORG_IDENT_DEBOUNCE_MS = 450;

const formatCurrency = (amount) => {
return `$${amount.toFixed(2)}`;
};
Expand Down Expand Up @@ -97,6 +104,7 @@
onBeforeDestroy() {
this.unbindEvent(_a.live);
clearTimeout(this._motionTimer);
clearTimeout(this._orgIdentTimer);
this._stopPromoCountdown();
if (this._onVisibility) {
document.removeEventListener("visibilitychange", this._onVisibility);
Expand Down Expand Up @@ -1064,6 +1072,20 @@
break;
case `${this.fig.family}__checkout-org-ident-input`:
this.__orgIdentInput = child;
// Check whatever the field is SHOWING, including the auto-suggested
// subdomain nobody typed — that one is derived from the username and
// is just as able to be taken. Deduped on the value in
// _checkOrgIdent, so the re-renders this page does on its own never
// re-ask the same question.
this._scheduleOrgIdentCheck(
this.state?.checkout?.orgIdent != null
? this.state.checkout.orgIdent
: this._defaultOrgIdent(),
);
break;

case `${this.fig.family}__checkout-org-ident-msg`:
this.__orgIdentMsg = child;
break;
case `${this.fig.family}__checkout-promo-code-input`:
this.__promoCodeInput = child;
Expand Down Expand Up @@ -1652,6 +1674,117 @@
.replace(/-+$/g, "");
}

/**
* Keep an org bootstrap field's typed value in state.
*
* The `watch` option on those entries fires on input/change/paste/cut, so
* this catches a mouse paste as well as typing — the keyup path the seats
* field uses would miss it. Deliberately does NOT re-render: the value is
* only read back when something ELSE rebuilds the tab, and re-rendering per
* keystroke would take the caret out of the field.
*
* @param {string} key - "orgName" or "orgIdent"
* @param {Object} args - the watch payload ({ value })
*/
_onOrgFieldTyped(key, args = {}) {
const checkout = this.state.checkout || (this.state.checkout = {});
const value = String(args.value != null ? args.value : "");
checkout[key] = value;
if (key === "orgIdent") this._scheduleOrgIdentCheck(value);
}

/**
* Ask, a short pause after the typing stops, whether this subdomain is free.
*
* Emptying the field clears the verdict rather than asking about "" — the
* server would answer IDENT_INVALID, which is not a useful thing to say
* about a field the shopper is in the middle of retyping.
*
* @param {string} raw - the field's current text
*/
_scheduleOrgIdentCheck(raw) {
clearTimeout(this._orgIdentTimer);
const ident = String(raw || "").trim().toLowerCase();
if (!ident) {
this._orgIdentChecked = "";
this._setOrgIdentMsg("", false);
return;
}
this._orgIdentTimer = setTimeout(
() => this._checkOrgIdent(ident),
ORG_IDENT_DEBOUNCE_MS,
Comment on lines +1713 to +1715

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the availability verdict when the input changes

When a user changes one nonempty subdomain to another, this schedules the new check without clearing the existing message or changing _orgIdentChecked. The UI therefore continues to label the new value with the previous value's verdict during the debounce and request, and an in-flight response for the previous value can still pass the guard at line 1738 and repaint that stale result. Clear the displayed verdict and invalidate the outstanding identifier as soon as a different value is scheduled.

Useful? React with 👍 / 👎.

);
}

/**
* Run payment.validate_org_ident and show the verdict under the field.
*
* The same call Proceed to Checkout makes, so the two can never disagree;
* this only moves the answer to where it is useful. `_orgIdentChecked` both
* de-duplicates (a re-render, or retyping the same value, asks nothing) and
* settles races: it holds the ident whose answer is still wanted, so a slow
* reply for an abandoned value is dropped instead of labelling the field
* the shopper has since changed.
*
* @param {string} ident - normalised subdomain label
*/
async _checkOrgIdent(ident) {
if (this.isDestroyed() || ident === this._orgIdentChecked) return;
this._orgIdentChecked = ident;
const v = await this.postService(SERVICE.payment.validate_org_ident, {
hub_id: Visitor.id,
ident,
}).catch(() => null);
if (this.isDestroyed() || this._orgIdentChecked !== ident) return;
if (!v) {
// A failed round trip says nothing about the subdomain. Stay silent and
// let the check on Pay be the one that blocks — claiming "taken" here
// over a dropped connection would send the shopper renaming their org
// for no reason. Forget it, so the next keystroke asks again.
this._orgIdentChecked = "";
this._setOrgIdentMsg("", false);
return;
}
const ok = v.status === "OK";
this._setOrgIdentMsg(
ok
? (LOCALE.ORG_IDENT_AVAILABLE || "")
: this._orgIdentError(v.status),
ok,
);
}

/**
* Record the subdomain verdict and repaint it.
* @param {string} msg - message to show, "" for none
* @param {boolean} ok - true when the subdomain is available
*/
_setOrgIdentMsg(msg, ok) {
const checkout = this.state.checkout || (this.state.checkout = {});
const text = msg || "";
if (checkout.orgIdentMsg === text && checkout.orgIdentOk === !!ok) return;
checkout.orgIdentMsg = text;
checkout.orgIdentOk = !!ok;
this._paintOrgIdentMsg();
}

/**
* Feed the verdict into its slot — the ONE surface that changes, so the
* inputs beside it keep their caret and their text.
*/
_paintOrgIdentMsg() {
const part = this.__orgIdentMsg;
if (!part || !part.el || !part.el.isConnected) return;

Check warning on line 1777 in src/drumee/builtins/widget/settings/account/billing/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=drumee_ui-team&issues=AaDC1GxEikRoTYFm0xv8&open=AaDC1GxEikRoTYFm0xv8&pullRequest=616
const { orgIdentMsgNote } = require("./skeleton/checkout");
if (typeof part.softClear === "function") part.softClear();
const note = orgIdentMsgNote(this);
// Keep the collapse flag in step with what is actually in the slot — see
// the skeleton: a Box with no kids still holds ui-core's `blank` widget,
// so only this attribute can tell the skin the slot has nothing to show.
part.el.dataset.empty = note ? 0 : 1;
if (note) part.feed(note);
}

// Map an org-ident validation status to its user-facing message.
/**
* Open the user's mail client addressed to sales.
Expand Down Expand Up @@ -1971,9 +2104,21 @@
ident,
}).catch(() => null);
if (!v || v.status !== "OK") {
if (Wm && Wm.alert) Wm.alert(this._orgIdentError(v && v.status));
const reason = this._orgIdentError(v && v.status);

Check warning on line 2107 in src/drumee/builtins/widget/settings/account/billing/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=drumee_ui-team&issues=AaDC1GxEikRoTYFm0xv9&open=AaDC1GxEikRoTYFm0xv9&pullRequest=616
// Leave a real verdict ON the field, not only in an alert the shopper
// has to dismiss before they can see which field it was about. A
// failed round trip (`v` null) gets the alert only — pinning "something
// went wrong" under the subdomain would blame the field for the
// network.
if (v) {
this._orgIdentChecked = ident;
this._setOrgIdentMsg(reason, false);
}
if (Wm && Wm.alert) Wm.alert(reason);

Check warning on line 2117 in src/drumee/builtins/widget/settings/account/billing/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=drumee_ui-team&issues=AaDC1GxEikRoTYFm0xv-&open=AaDC1GxEikRoTYFm0xv-&pullRequest=616
return;
}
this._orgIdentChecked = ident;
this._setOrgIdentMsg(LOCALE.ORG_IDENT_AVAILABLE || "", true);
payload.ident = v.ident;
payload.org_name = org_name;
}
Expand Down Expand Up @@ -2493,6 +2638,16 @@
case "select-bundle":
return this._handleSelectBundle(cmd, args);

// The org bootstrap fields mirror themselves into state as they are
// edited — see orgFieldValue() in skeleton/checkout for why they have to.
case "org-name-typed":
this._onOrgFieldTyped("orgName", args);
return false;

case "org-ident-typed":
this._onOrgFieldTyped("orgIdent", args);
return false;

case "input-seats":
if (/^(Backspace|)$/.test(cmd.status)) {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,52 @@
});
}

/**
* The value an org bootstrap field paints with.
*
* `saved` is what the shopper has typed — mirrored into state.checkout on
* every keystroke by the `watch` services below — and `fallback` is the
* auto-suggestion. It has to come from state because EVERY re-render of this
* tab rebuilds these inputs from `value`, and re-renders happen with nobody
* touching the page: the visibilitychange re-sync fires the moment the browser
* tab regains focus, so switching to another tab and back used to hand the
* typed name and subdomain straight back to the auto-suggested ones
* (reported 2026-09-20).
*
* An empty string is a deliberate clear, not "untouched", so it must survive
* that repaint too — hence the null check rather than `||`. Submitting an
* empty field still falls back to the same auto value in _proceedToCheckout.
*
* @param {string|undefined} saved - value mirrored from the input, if any
* @param {string} fallback - the auto-derived suggestion
* @returns {string} value to paint the input with
*/
function orgFieldValue(saved, fallback) {
return String(saved != null ? saved : (fallback || ""));
}

/**
* Inline verdict for the subdomain field — the answer to "is this one already
* taken?" while the shopper is still typing, instead of only after they press
* Proceed to Checkout.
*
* Built from state, so a full re-render repaints the same verdict; the widget
* also feeds it into the -org-ident-msg slot on its own when a check lands.
*
* @param {Object} ui - UI instance
* @returns {Object|null} a Note, or null when there is nothing to say
*/
function orgIdentMsgNote(ui) {
const pfx = `${ui.fig.family}__checkout`;
const checkout = (ui.state && ui.state.checkout) || {};

Check warning on line 75 in src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=drumee_ui-team&issues=AaDC1GjBikRoTYFm0xv7&open=AaDC1GjBikRoTYFm0xv7&pullRequest=616
const msg = checkout.orgIdentMsg || "";
if (!msg) return null;
return Skeletons.Note({
className: `${pfx}-org-ident-msg ${checkout.orgIdentOk ? "is-ok" : "is-error"}`,
content: msg,
});
}

/**
* Create checkout layout with left panel (form) and right panel (summary)
* Left panel: plan selection, seats, storage, billing cycle, storage bundles
Expand Down Expand Up @@ -81,9 +127,13 @@
placeholder: LOCALE.ORG_NAME_LABEL,
// Auto organization name ("<user> Team") — editable; the submit
// path falls back to the same default if the field is cleared.
value: String(ui.state?.checkout?.orgName || ui._defaultOrgName() || ""),
value: orgFieldValue(ui.state?.checkout?.orgName, ui._defaultOrgName()),
sys_pn: `${pfx}-org-name-input`,
interactive: 1,
// See orgFieldValue(): `watch` is what mirrors each keystroke (and
// each paste) into state.checkout, so the line above has something
// to repaint after a re-render.
watch: "org-name-typed",
}),
Skeletons.Box.X({
className: `${pfx}-org-ident-row`,
Expand All @@ -94,10 +144,12 @@
type: "text",
placeholder: LOCALE.ORG_SUBDOMAIN_LABEL,
// Auto subdomain suggestion (slugged username) — editable;
// availability is still checked by validate_org_ident.
value: String(ui.state?.checkout?.orgIdent || ui._defaultOrgIdent() || ""),
// availability is checked by validate_org_ident, now while it
// is being typed (see -org-ident-msg below) as well as on Pay.
value: orgFieldValue(ui.state?.checkout?.orgIdent, ui._defaultOrgIdent()),
sys_pn: `${pfx}-org-ident-input`,
interactive: 1,
watch: "org-ident-typed",
}),
Skeletons.Note({
className: `${pfx}-org-ident-suffix`,
Expand All @@ -109,6 +161,25 @@
className: `${pfx}-org-ident-hint`,
content: LOCALE.ORG_URL_HINT,
}),
// Slot for the live availability verdict. A Box, not the Note
// itself: a Note rebuilds its own inner .note-content, so the
// framework's update path is to feed() a fresh Note into a
// container that owns a sys_pn — the same shape the promo countdown
// uses.
//
// `data-empty` rather than a `:empty` CSS rule: ui-core puts a
// `blank` widget inside a Box with no kids, so the element is never
// actually childless and `:empty` never matches. Measured on
// drumee.in — the rule did nothing and the empty slot still drew the
// org section's 12px gap. _paintOrgIdentMsg keeps this flag in step
// on the incremental path.
Skeletons.Box.X({
className: `${pfx}-org-ident-msg-slot`,
sys_pn: `${pfx}-org-ident-msg`,
partHandler: [ui],
dataset: { empty: orgIdentMsgNote(ui) ? 0 : 1 },
kids: [orgIdentMsgNote(ui)].filter(Boolean),
}),
],
})
: null;
Expand Down Expand Up @@ -430,7 +501,20 @@
priority: "primary",
uiHandler: [ui],
bubble: false,
state: isFreePlan ? 0 : 1,
// NO `state` prop, deliberately. ui-core attaches the TOGGLE behavior
// to any widget carrying one (addons/backbone/view/behavior.js), and
// its onAlsoClick flips data-state 1 → 0 on the very click that
// submits — while the skin greys `[data-state="0"]` out with
// pointer-events:none. Nobody noticed while the click ended in a
// redirect to Stripe, but every path that STAYS on the page (the
// subdomain is taken, ALREADY_SUBSCRIBED, a network error) left the
// shopper looking at a dead grey button: fix the subdomain, and there
// was no way to press Pay again short of reloading (reported
// 2026-09-20). The entry fields escape this only because entry()
// gives them a `radio`, which wins over toggle.
//
// Disabled-ness is carried by `dataset.disabled` alone — the same skin
// rule styles it, and nothing flips it behind our back.
dataset: isFreePlan ? { disabled: 1 } : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent duplicate checkout requests while Pay is pending

When validation or checkout takes longer than a double-click, the button now remains enabled and every click invokes _proceedToCheckout() independently; there is no pending guard in that handler, so each invocation can issue its own payment.checkout request and create a separate hosted Checkout session (and repeat any promo reservation attempt). Keep the button retriable after an error, but disable or guard it only while the current submission is in flight and restore it on every non-redirect path.

Useful? React with 👍 / 👎.

}),
].filter(Boolean);
Expand Down Expand Up @@ -461,4 +545,4 @@
}

export default checkout;
export { rightPanel, rightPanelContent, promoCodeSection };
export { rightPanel, rightPanelContent, promoCodeSection, orgIdentMsgNote };
Loading
Loading