fix: address review findings on the upgrade surfaces — resolve a catalog plan to the server plan by exact name before falling back to an unambiguous prefix so an operator-added 'Starter Legacy' cannot capture Starter's checkout, stop a plan that does not unlock the requested feature from offering to buy it, honour the dialog's aria-modal by moving and containing focus and restoring it on close, and pin the odometer digit geometry against flex shrink

This commit is contained in:
Matthew Meszaros
2026-09-04 06:09:09 -07:00
parent a0c9d5a5b0
commit c089cdfa75
4 changed files with 63 additions and 8 deletions
+8 -1
View File
@@ -102,7 +102,10 @@ export default function PlanCard({
? "Custom volume"
: `${plan.sendsPerDay.toLocaleString()} emails / day`;
const showCta = canAct && !below && !isCurrent;
// A gated card that does not unlock the requested feature must not offer to
// buy it: checkout would succeed and leave the user still locked out of the
// thing they clicked.
const showCta = canAct && !below && !isCurrent && unlocks;
return (
<motion.div
@@ -267,6 +270,10 @@ export default function PlanCard({
<div className="h-9 rounded-md bg-slate-100 text-slate-400 text-[12.5px] font-medium inline-flex items-center justify-center w-full cursor-default">
Current plan
</div>
) : gated && !unlocks ? (
<div className="h-9 text-[11.5px] text-slate-400 inline-flex items-center justify-center w-full text-center px-2">
Does not unlock {feature}
</div>
) : below ? (
<button
type="button"
@@ -74,6 +74,43 @@ export default function UpgradeDialog({
// while this one is up. Escape here closes only this layer: any other
// floating panel or the confirm owns it.
const cardRef = React.useRef<HTMLDivElement>(null);
// The card declares aria-modal, so honour it: move focus in on open, keep
// Tab inside while it is up, and hand focus back to the trigger on close.
// Without this a keyboard or screen-reader user tabs through the page
// behind the backdrop and never reaches the plan buttons.
React.useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
cardRef.current?.focus();
const onTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const card = cardRef.current;
if (!card) return;
// A nested layer (the enterprise inquiry) owns Tab while it is open.
if (document.querySelector("[role='alertdialog']")) return;
const focusable = card.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === card)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onTab);
return () => {
document.removeEventListener("keydown", onTab);
previous?.focus?.();
};
}, [open]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -153,6 +190,7 @@ export default function UpgradeDialog({
aria-labelledby={titleId}
data-floating
ref={cardRef}
tabIndex={-1}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}
+3 -1
View File
@@ -92,7 +92,9 @@ function Wheel({ digit }: { digit: number }) {
}}
>
{DIGITS.map((d) => (
<span key={d} style={{ height: "1em", lineHeight: 1 }}>
// flexShrink pins the geometry: the -Nem offsets below
// assume each digit is exactly 1em tall.
<span key={d} style={{ height: "1em", lineHeight: 1, flexShrink: 0 }}>
{d}
</span>
))}
+14 -6
View File
@@ -48,16 +48,24 @@ export default function useUpgradeFlow() {
const [pending, setPending] = React.useState<PlanID | null>(null);
// Resolve a catalog plan ("grow") to the server Plan record so we can read
// its Stripe price ID / UUID. Matches by name; undefined when the server
// has no matching public plan configured.
// its Stripe price ID / UUID. Undefined when the server has no matching
// public plan configured.
//
// An exact name match always wins. Plans are operator-configurable, so a
// public "Starter Legacy" ordered ahead of "Starter" would otherwise be
// picked by the prefix pass and charge the wrong price. The prefix pass is
// only a fallback, and only when exactly one plan matches.
const resolveServerPlan = React.useCallback(
(catalogId: PlanID): ServerPlan | undefined => {
const label = getPlan(catalogId).label.toLowerCase().trim();
const plans = (plansQuery.data ?? []) as ServerPlan[];
return plans.find((p) => {
const n = (p.name ?? "").toLowerCase().trim();
return n === label || n.startsWith(label);
});
const name = (p: ServerPlan) => (p.name ?? "").toLowerCase().trim();
const exact = plans.find((p) => name(p) === label);
if (exact) return exact;
const prefixed = plans.filter((p) => name(p).startsWith(label));
return prefixed.length === 1 ? prefixed[0] : undefined;
},
[plansQuery.data],
);