mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 08:06:13 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user