mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
fbd9f5e4cf
commit
6448849f77
4 changed files with 181 additions and 55 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type AccountMembership = {
|
|||
slug: string
|
||||
role: string
|
||||
memberCount: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
export function useAccountMemberships() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue