fix(dashboard): give deployment plans more modal space

This commit is contained in:
Hydra
2026-09-30 14:56:52 +03:00
parent 95e224cc38
commit 507f469c85
3 changed files with 159 additions and 72 deletions
@@ -5,12 +5,16 @@ import { Icon as UiIcon } from "@repo/ui/icons";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useDialogFocus } from "@/hooks/useDialogFocus";
import { useI18n } from "@/components/i18n-provider";
import { Button } from "@/components/ui/button";
import { billingApi, type BillingState } from "@/lib/api/billing";
import { ApiError } from "@/lib/api/client";
import { cloudDeployRecovery, type CloudDeployRestriction } from "@/lib/cloud-deploy-pricing";
import { CloudPlanPicker } from "./CloudPlanPicker";
export function CloudDeployPlanModal({ restriction, onClose }: {
export function CloudDeployPlanModal({
restriction,
onClose,
}: {
restriction: CloudDeployRestriction;
onClose: () => void;
}) {
@@ -58,89 +62,167 @@ export function CloudDeployPlanModal({ restriction, onClose }: {
}, [refresh]);
const recovery = state ? cloudDeployRecovery(state, restriction) : "subscribe";
const planChanged = recovery === "upgrade" && state && !state.overQuota && state.tier !== initialTier.current;
const planChanged =
recovery === "upgrade" && state && !state.overQuota && state.tier !== initialTier.current;
const ready = recovery === "ready" || planChanged;
const showPlans = !ready && (recovery === "subscribe" || recovery === "upgrade");
const title = recovery === "credits" ? copy.creditsTitle
: recovery === "upgrade" ? copy.upgradeTitle
: recovery === "payment" || recovery === "paused" ? copy.blockedTitle : copy.title;
const reason = restriction.reason && Object.hasOwn(copy.reasons, restriction.reason)
? copy.reasons[restriction.reason as keyof typeof copy.reasons] : undefined;
const description = ready ? copy.ready
: recovery === "credits" ? copy.creditsDescription
: recovery === "payment" ? copy.paymentDescription
: recovery === "paused" ? copy.pausedDescription
: recovery === "upgrade" ? (reason ?? copy.upgradeDescription) : copy.description;
const title =
recovery === "credits"
? copy.creditsTitle
: recovery === "upgrade"
? copy.upgradeTitle
: recovery === "payment" || recovery === "paused"
? copy.blockedTitle
: copy.title;
const reason =
restriction.reason && Object.hasOwn(copy.reasons, restriction.reason)
? copy.reasons[restriction.reason as keyof typeof copy.reasons]
: undefined;
const description = ready
? copy.ready
: recovery === "credits"
? copy.creditsDescription
: recovery === "payment"
? copy.paymentDescription
: recovery === "paused"
? copy.pausedDescription
: recovery === "upgrade"
? (reason ?? copy.upgradeDescription)
: copy.description;
return (
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}
tabIndex={-1} onKeyDown={onKeyDown} className="flex max-h-[88vh] flex-col p-5 outline-none sm:p-8">
<div className="mb-5 flex shrink-0 items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-primary/10 p-2.5 text-primary"><UiIcon name="cloud" className="size-5" aria-hidden="true" /></div>
<div>
<h2 id={titleId} className="text-xl font-semibold tracking-tight text-foreground">{ready ? copy.readyTitle : title}</h2>
<p id={descriptionId} className="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
</div>
<button type="button" onClick={onClose} aria-label={copy.close} className="shrink-0 rounded-lg p-2 text-muted-foreground hover:bg-muted focus-visible:outline-primary">
<UiIcon name="close" className="size-5" aria-hidden="true" />
</button>
</div>
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={onKeyDown}
className="flex max-h-[calc(100dvh-2rem)] min-h-0 flex-col outline-none"
>
<header className="flex shrink-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<h2
id={titleId}
className="min-w-0 text-lg font-semibold leading-6 tracking-tight text-foreground"
>
{ready ? copy.readyTitle : title}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label={copy.close}
className="shrink-0"
>
<UiIcon name="close" className="size-4" aria-hidden="true" />
</Button>
</header>
<div className="-mx-1 min-h-0 overflow-y-auto px-1 py-1">
{loading && !state ? (
<div role="status" className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<UiIcon name="spinner" className="size-4 animate-spin" aria-hidden="true" />{copy.loading}
</div>
) : error ? (
<p role="alert" className="rounded-xl border border-border bg-muted/30 p-5 text-sm">
{error === "owner" ? copy.ownerRequired : copy.loadError}
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 pb-5 sm:px-5">
<p id={descriptionId} className="mb-5 text-sm text-muted-foreground">
{description}
</p>
) : state && (
<div className="space-y-6">
{showPlans && <CloudPlanPicker
currentPlan={state.tier}
subscription={state.subscription}
complimentary={state.complimentary}
billingEnabled={state.billing?.enabled === true}
canChangeSubscription={state.capabilities?.subscriptionChange === true}
preserveProject
onCheckoutStarted={() => { setCheckoutStarted(true); setChecked(false); }}
/>}
{(recovery === "credits" || recovery === "payment") && (
<div className="flex flex-wrap gap-3">
{recovery === "credits" && state.billing?.enabled && state.topups?.available && (
<a href="/billing/topups" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">
{copy.topups}<UiIcon name="arrow-up-right" className="size-4" aria-hidden="true" />
{loading && !state ? (
<div
role="status"
className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground"
>
<UiIcon name="spinner" className="size-4 animate-spin" aria-hidden="true" />
{copy.loading}
</div>
) : error ? (
<p role="alert" className="rounded-xl bg-muted/30 p-5 text-sm">
{error === "owner" ? copy.ownerRequired : copy.loadError}
</p>
) : (
state && (
<div className="space-y-5">
{showPlans && (
<CloudPlanPicker
currentPlan={state.tier}
subscription={state.subscription}
complimentary={state.complimentary}
billingEnabled={state.billing?.enabled === true}
canChangeSubscription={state.capabilities?.subscriptionChange === true}
preserveProject
onCheckoutStarted={() => {
setCheckoutStarted(true);
setChecked(false);
}}
/>
)}
{(recovery === "credits" || recovery === "payment") && (
<div className="flex flex-wrap gap-3">
{recovery === "credits" && state.billing?.enabled && state.topups?.available && (
<Button asChild>
<a href="/billing/topups" target="_blank" rel="noopener noreferrer">
{copy.topups}
<UiIcon name="arrow-up-right" className="size-4" aria-hidden="true" />
</a>
</Button>
)}
<Button asChild variant="secondary">
<a
href={recovery === "credits" ? "/billing/plans" : "/billing/overview"}
target="_blank"
rel="noopener noreferrer"
>
{copy.manageBilling}
<UiIcon name="arrow-up-right" className="size-4" aria-hidden="true" />
</a>
</Button>
</div>
)}
{recovery === "paused" && (
<a
href="mailto:support@openship.io"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
{t.billing.portal.supportButton}
</a>
)}
<a href={recovery === "credits" ? "/billing/plans" : "/billing/overview"} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium">
{copy.manageBilling}<UiIcon name="arrow-up-right" className="size-4" aria-hidden="true" />
</a>
{checkoutStarted && checked && !loading && !ready && (
<p role="status" className="text-sm text-muted-foreground">
{copy.pending}
</p>
)}
</div>
)}
{recovery === "paused" && <a href="mailto:support@openship.io" className="inline-flex text-sm font-medium text-primary hover:underline">{t.billing.portal.supportButton}</a>}
{checkoutStarted && checked && !loading && !ready && <p role="status" className="text-sm text-muted-foreground">{copy.pending}</p>}
</div>
)}
)
)}
<p className="mt-5 text-xs text-muted-foreground">{copy.preserved}</p>
</div>
<div className="mt-5 flex shrink-0 flex-col gap-4 border-t border-border/50 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p className="max-w-xl text-xs leading-relaxed text-muted-foreground">{copy.preserved}</p>
<div className="flex shrink-0 flex-wrap gap-2">
{!ready && error !== "owner" && <button type="button" disabled={loading}
onClick={() => { setChecked(true); void refresh(); }}
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm font-medium disabled:opacity-50">
<UiIcon name="refresh" className={`size-3.5 ${loading ? "animate-spin" : ""}`} aria-hidden="true" />
<footer className="flex shrink-0 items-stretch justify-end gap-2 border-t border-border/40 px-4 py-3 sm:px-5">
{!ready && error !== "owner" && (
<Button
type="button"
variant="secondary"
disabled={loading}
className="h-auto min-h-10 min-w-0 flex-1 whitespace-normal px-3 sm:flex-none"
onClick={() => {
setChecked(true);
void refresh();
}}
>
<UiIcon
name="refresh"
className={`size-4 shrink-0 ${loading ? "animate-spin" : ""}`}
aria-hidden="true"
/>
{error ? t.billing.plansRoute.tryAgain : copy.checkPlan}
</button>}
<button type="button" onClick={onClose} className={`rounded-lg px-3 py-2 text-sm font-medium ${ready ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}>
{copy.close}
</button>
</div>
</div>
</Button>
)}
<Button
type="button"
variant={ready ? "default" : "ghost"}
onClick={onClose}
className="h-auto min-h-10 min-w-0 flex-1 whitespace-normal px-3 sm:flex-none"
>
{copy.close}
</Button>
</footer>
</div>
);
}
@@ -25,6 +25,8 @@ export function useCloudDeployPricing() {
customContent: <CloudDeployPlanModal restriction={restriction} onClose={() => hideModal(id)} />,
width: "100%",
maxWidth: "1440px",
maxHeight: "calc(100dvh - 2rem)",
overflow: "hidden",
showCloseButton: false,
onClose: () => { openModal.current = null; },
});
+3
View File
@@ -50,6 +50,9 @@ Reuse [Input](../apps/dashboard/src/components/ui/input.tsx),
- Plan cards respond to their container: one column on phones, two at intermediate widths,
and four when readable. Offer links to each plan above a stacked comparison; never rely
on hidden horizontal overflow to reveal additional plans.
- Deployment plan dialogs use compact title and action rows. Keep explanations in the
scrollable content so the plans receive most of the available viewport height, including
on short screens. Keep actions side by side on phones, allowing long labels to wrap.
- The plans comparison (`/billing/plans`) and Scale canvas open with the desktop sidebar
collapsed. Keep the toggle available, restore the normal preference on leaving, and
keep manual expansion independent between these sections. Mobile navigation opens fully.