fix(web): add missing loading and error states to MfaPromptBanner, Step1Identity, MobileSummaryClient
- MfaPromptBanner: silently hide on query error (non-critical advisory banner) - Step1Identity: show skeleton placeholders while blueprint list loads - MobileSummaryClient: add error state with retry button for dashboard queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,12 @@ import { MobileProjectCard } from "./MobileProjectCard.js";
|
|||||||
import { EmptyState } from "~/components/ui/EmptyState.js";
|
import { EmptyState } from "~/components/ui/EmptyState.js";
|
||||||
|
|
||||||
export function MobileSummaryClient() {
|
export function MobileSummaryClient() {
|
||||||
const { data: overview, isLoading: overviewLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
const {
|
||||||
|
data: overview,
|
||||||
|
isLoading: overviewLoading,
|
||||||
|
isError: overviewError,
|
||||||
|
refetch: refetchOverview,
|
||||||
|
} = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -16,18 +21,23 @@ export function MobileSummaryClient() {
|
|||||||
const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)(
|
const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)(
|
||||||
{ limit: 5, status: "ACTIVE" },
|
{ limit: 5, status: "ACTIVE" },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
) as { data: { projects: Array<{ id: string; shortCode: string; name: string; status: string }> } | undefined; isLoading: boolean };
|
) as {
|
||||||
|
data:
|
||||||
|
| { projects: Array<{ id: string; shortCode: string; name: string; status: string }> }
|
||||||
|
| undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)(
|
const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)(undefined, {
|
||||||
undefined,
|
staleTime: 60_000,
|
||||||
{ staleTime: 60_000 },
|
}) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined };
|
||||||
) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined };
|
|
||||||
|
|
||||||
const projects = projectsData?.projects ?? [];
|
const projects = projectsData?.projects ?? [];
|
||||||
const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0;
|
const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0;
|
||||||
|
|
||||||
const isLoading = overviewLoading || projectsLoading;
|
const isLoading = overviewLoading || projectsLoading;
|
||||||
|
const isError = overviewError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
@@ -40,7 +50,20 @@ export function MobileSummaryClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-[428px] mx-auto px-4 py-5 space-y-4">
|
<div className="max-w-[428px] mx-auto px-4 py-5 space-y-4">
|
||||||
{isLoading ? (
|
{isError ? (
|
||||||
|
<div className="rounded-2xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
Failed to load dashboard data
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refetchOverview()}
|
||||||
|
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-xs font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="h-32 shimmer-skeleton rounded-2xl" />
|
<div key={i} className="h-32 shimmer-skeleton rounded-2xl" />
|
||||||
@@ -64,7 +87,9 @@ export function MobileSummaryClient() {
|
|||||||
className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
|
className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
<div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||||
<span className="text-sm font-bold text-amber-700 dark:text-amber-300">{openDemandCount}</span>
|
<span className="text-sm font-bold text-amber-700 dark:text-amber-300">
|
||||||
|
{openDemandCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-amber-800 dark:text-amber-300">
|
<div className="text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ interface Step1Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Step1Identity({ state, onChange }: Step1Props) {
|
export function Step1Identity({ state, onChange }: Step1Props) {
|
||||||
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
const { data: blueprints, isLoading: blueprintsLoading } = trpc.blueprint.list.useQuery(
|
||||||
{ target: BlueprintTarget.PROJECT, isActive: true },
|
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||||
{ staleTime: 30_000 },
|
{ staleTime: 30_000 },
|
||||||
) as {
|
) as {
|
||||||
|
isLoading: boolean;
|
||||||
data:
|
data:
|
||||||
| Array<{
|
| Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -88,6 +89,13 @@ export function Step1Identity({ state, onChange }: Step1Props) {
|
|||||||
<div className="font-medium">No Blueprint</div>
|
<div className="font-medium">No Blueprint</div>
|
||||||
<div className="text-xs text-gray-400 mt-0.5">Start blank</div>
|
<div className="text-xs text-gray-400 mt-0.5">Start blank</div>
|
||||||
</button>
|
</button>
|
||||||
|
{blueprintsLoading &&
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-16 animate-pulse rounded-lg border border-gray-200 bg-gray-100 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{(blueprints ?? []).map((bp) => (
|
{(blueprints ?? []).map((bp) => (
|
||||||
<button
|
<button
|
||||||
key={bp.id}
|
key={bp.id}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SNOOZE_DAYS = 7;
|
|||||||
* Snooze state is scoped by userId to prevent cross-user leakage on shared browsers.
|
* Snooze state is scoped by userId to prevent cross-user leakage on shared browsers.
|
||||||
*/
|
*/
|
||||||
export function MfaPromptBanner() {
|
export function MfaPromptBanner() {
|
||||||
const { data: mfaStatus } = trpc.user.getMfaStatus.useQuery();
|
const { data: mfaStatus, isError } = trpc.user.getMfaStatus.useQuery();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const userId = (session?.user as { id?: string } | undefined)?.id ?? "";
|
const userId = (session?.user as { id?: string } | undefined)?.id ?? "";
|
||||||
const [snoozed, setSnoozed] = useState<boolean | null>(null);
|
const [snoozed, setSnoozed] = useState<boolean | null>(null);
|
||||||
@@ -48,8 +48,8 @@ export function MfaPromptBanner() {
|
|||||||
setSnoozed(true);
|
setSnoozed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't render until we know the MFA status and snooze state
|
// Don't render until we know the MFA status and snooze state; silently hide on error
|
||||||
if (mfaStatus === undefined || snoozed === null) return null;
|
if (isError || mfaStatus === undefined || snoozed === null) return null;
|
||||||
// Already enabled — no banner needed
|
// Already enabled — no banner needed
|
||||||
if (mfaStatus.totpEnabled) return null;
|
if (mfaStatus.totpEnabled) return null;
|
||||||
// Snoozed
|
// Snoozed
|
||||||
@@ -62,8 +62,8 @@ export function MfaPromptBanner() {
|
|||||||
className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50"
|
className="flex items-center justify-between gap-4 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 dark:bg-amber-900/20 dark:text-amber-200 border-b border-amber-200 dark:border-amber-700/50"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong className="font-semibold">Protect your account:</strong>{" "}
|
<strong className="font-semibold">Protect your account:</strong> Your role has elevated
|
||||||
Your role has elevated permissions. We recommend enabling multi-factor authentication (MFA).
|
permissions. We recommend enabling multi-factor authentication (MFA).
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
Reference in New Issue
Block a user