fix(web): stabilize home recents and improve org plan switcher (#967)

- Keep home Recently saved and suggestions in a stable two-column layout
- Show skeleton while recents load and empty state when none exist
- Cap org switcher dropdown height with scroll for long org lists
- Show plan tier badges on all orgs, sorted by plan rank
This commit is contained in:
MaheshtheDev 2026-05-18 19:36:44 +00:00
parent fbd9f5e4cf
commit 6448849f77
4 changed files with 181 additions and 55 deletions

View file

@ -655,7 +655,7 @@ export function DashboardView({
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData } = useQuery({
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
@ -894,25 +894,43 @@ export function DashboardView({
transition={{ ...fadeUp.transition, delay: 0.15 }}
className="space-y-2"
>
{recents.length > 0 ? (
<>
{/* Shared header row — both labels aligned */}
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recently saved
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recently saved
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
{/* Content row */}
<div className="flex gap-4 items-start">
<ul className="flex-[3] min-w-0 space-y-0.5">
<div className="flex gap-4 items-start">
<div className="flex-[3] min-w-0">
{isRecentsLoading ? (
<ul
className="space-y-0.5"
aria-busy="true"
aria-label="Loading recently saved"
>
{[
"recent-skeleton-1",
"recent-skeleton-2",
"recent-skeleton-3",
].map((skeletonKey) => (
<li
key={skeletonKey}
className="flex items-center gap-3 rounded-lg px-2.5 py-2"
>
<div className="size-6 shrink-0 rounded-md bg-surface-skeleton animate-pulse" />
<div className="h-3.5 min-w-0 flex-1 rounded bg-surface-skeleton animate-pulse" />
</li>
))}
</ul>
) : recents.length > 0 ? (
<ul className="space-y-0.5">
{recents.map((doc) => {
const isLink = !!doc.url
return (
@ -938,22 +956,27 @@ export function DashboardView({
)
})}
</ul>
) : (
<p className="px-2.5 py-2 text-sm text-fg-subtle">
No recently saved
</p>
)}
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
</>
) : (
/* No recents yet — show suggestions full-width */
<>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
{(isRecentsLoading || recents.length === 0) && (
<div className="space-y-2 sm:hidden">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
@ -967,7 +990,7 @@ export function DashboardView({
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</>
</div>
)}
</motion.section>
</div>

View file

@ -8,7 +8,13 @@ import {
useDeleteUserAccount,
} from "@/hooks/use-account-settings"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useTokenUsage } from "@/hooks/use-token-usage"
import {
normalizePlanType,
PLAN_DISPLAY_NAMES,
PLAN_RANK,
useTokenUsage,
type PlanType,
} from "@/hooks/use-token-usage"
import {
Dialog,
DialogContent,
@ -187,6 +193,43 @@ function formatOrgRole(role: string): string {
: "Member"
}
/** Matches ACTIVE / RECOMMENDED pills in billing settings. */
const orgPlanBadgeBase = cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
)
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
function resolveOrgPlan(
orgId: string,
organization: { metadata?: unknown },
isCurrent: boolean,
currentPlan: PlanType,
membershipPlanByOrgId: Map<string, PlanType>,
): PlanType {
if (isCurrent) return currentPlan
const fromMembership = membershipPlanByOrgId.get(orgId)
if (fromMembership) return fromMembership
const metadata = organization.metadata as Record<string, unknown> | null
return normalizePlanType(metadata?.plan ?? metadata?.subscriptionPlan)
}
export default function Account() {
const {
user,
@ -261,12 +304,40 @@ export default function Account() {
maximumFractionDigits: 2,
})
const planDisplayNames: Record<string, string> = {
free: "Free",
pro: "Pro",
scale: "Scale",
enterprise: "Enterprise",
}
const planDisplayNames = PLAN_DISPLAY_NAMES
const membershipPlanByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const membership of memberships ?? []) {
if (membership.plan) {
map.set(membership.orgId, normalizePlanType(membership.plan))
}
}
return map
}, [memberships])
const sortedOrgsForMenu = useMemo(() => {
if (!allOrgs?.length) return []
return [...allOrgs].sort((a, b) => {
const planA = resolveOrgPlan(
a.id,
a,
a.id === org?.id,
currentPlan,
membershipPlanByOrgId,
)
const planB = resolveOrgPlan(
b.id,
b,
b.id === org?.id,
currentPlan,
membershipPlanByOrgId,
)
const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA]
if (rankDiff !== 0) return rankDiff
return a.name.localeCompare(b.name)
})
}, [allOrgs, org?.id, currentPlan, membershipPlanByOrgId])
// Handlers
const handleUpgrade = async () => {
@ -430,11 +501,18 @@ export default function Account() {
{canSwitchOrg && (
<PopoverContent
align="start"
className="w-72 bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
className="w-80 max-h-80 overflow-y-auto bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
>
{allOrgs?.map((organization) => {
{sortedOrgsForMenu.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
organization,
isCurrent,
currentPlan,
membershipPlanByOrgId,
)
return (
<button
key={organization.id}
@ -456,17 +534,17 @@ export default function Account() {
)}
>
<Building2 className="size-4 text-[#737373] shrink-0" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<p className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate">
{organization.name}
</p>
{isCurrent && (
<Check className="size-4 text-[#4BA0FA] shrink-0" />
)}
{isSwitching && (
<LoaderIcon className="size-4 text-[#4BA0FA] shrink-0 animate-spin" />
)}
</div>
<p className="min-w-0 flex-1 truncate text-[14px] tracking-[-0.14px] text-[#FAFAFA]">
{organization.name}
</p>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : (
<span className="size-4 shrink-0" aria-hidden />
)}
<OrgPlanBadge plan={plan} />
</button>
)
})}

View file

@ -10,6 +10,7 @@ export type AccountMembership = {
slug: string
role: string
memberCount: number
plan?: string
}
export function useAccountMemberships() {

View file

@ -4,6 +4,30 @@ import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils"
export type PlanType = "free" | "pro" | "scale" | "enterprise"
export const PLAN_DISPLAY_NAMES: Record<PlanType, string> = {
free: "Free",
pro: "Pro",
scale: "Scale",
enterprise: "Enterprise",
}
/** Higher rank sorts first in org lists (enterprise at top). */
export const PLAN_RANK: Record<PlanType, number> = {
free: 0,
pro: 1,
scale: 2,
enterprise: 3,
}
export function normalizePlanType(raw: unknown): PlanType {
if (typeof raw !== "string" || !raw.trim()) return "free"
const normalized = raw.toLowerCase().replace(/^api_/, "")
if (normalized === "enterprise") return "enterprise"
if (normalized === "scale") return "scale"
if (normalized === "pro") return "pro"
return "free"
}
const TOKEN_METER_IDS = [
"sm_tokens_text",
"sm_tokens_rich",