mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(dashboard): give deployment plans more modal space
This commit is contained in:
@@ -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; },
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user