Skip to content

Commit 552c621

Browse files
committed
feat: floating banner on homepage
1 parent 5b9a07c commit 552c621

3 files changed

Lines changed: 125 additions & 7 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
2+
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
3+
import { AnimatePresence, motion } from 'framer-motion'
4+
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
5+
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
6+
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
7+
import { X, Zap } from 'lucide-react'
8+
import Link from 'next/link'
9+
import { useState } from 'react'
10+
import { Button } from 'ui'
11+
12+
export const ComputeUpgradeFloatingNotice = () => {
13+
const { ref } = useParams()
14+
const { data: project } = useSelectedProjectQuery()
15+
const { data: org } = useSelectedOrganizationQuery()
16+
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug })
17+
18+
const [isDismissed, setIsDismissed] = useLocalStorageQuery(
19+
LOCAL_STORAGE_KEYS.MICRO_UPGRADE_BANNER_DISMISSED(ref ?? ''),
20+
false
21+
)
22+
const [isHovered, setIsHovered] = useState(false)
23+
24+
const isProPlan = subscription?.plan.id === 'pro'
25+
const isOnNano = project?.infra_compute_size === 'nano'
26+
27+
if (isDismissed || !isProPlan || !isOnNano) return null
28+
29+
return (
30+
// Outer: positions above badge center + handles float animation
31+
<motion.div
32+
className="absolute bottom-full left-1/2 mb-2 z-10"
33+
style={{ x: '-50%' }}
34+
animate={isHovered ? { y: 0 } : { y: [0, -6, 0] }}
35+
transition={
36+
isHovered
37+
? { duration: 0.2, ease: 'easeOut' }
38+
: { duration: 3, repeat: Infinity, ease: 'easeInOut' }
39+
}
40+
onHoverStart={() => setIsHovered(true)}
41+
onHoverEnd={() => setIsHovered(false)}
42+
>
43+
{/* Inner: expands from center via layout animation */}
44+
<motion.div
45+
layout
46+
className={`relative flex items-center gap-2 rounded-full bg-background backdrop-blur-sm border border-brand-500 shadow-md text-brand-600 ${isHovered ? 'pl-2 pr-3 py-1.5' : 'pl-1 pr-2.5 py-1'} cursor-default overflow-hidden whitespace-nowrap`}
47+
transition={{ duration: 0.2, ease: 'easeInOut' }}
48+
>
49+
<span className="absolute inset-0 bg-brand bg-opacity-10 w-full h-full" />
50+
51+
<AnimatePresence mode="wait" initial={false}>
52+
<motion.div
53+
key={isHovered ? 'icon-expanded' : 'icon-collapsed'}
54+
layout
55+
className={`flex-shrink-0 flex items-center justify-center rounded-full bg-brand-300 text-brand ${isHovered ? 'h-6 w-6' : 'h-4 w-4'}`}
56+
initial={{ opacity: 0 }}
57+
animate={{ opacity: 1 }}
58+
exit={{ opacity: 0 }}
59+
transition={{ duration: 0.2, ease: 'easeInOut' }}
60+
>
61+
<Zap size={isHovered ? 12 : 10} />
62+
</motion.div>
63+
</AnimatePresence>
64+
65+
<AnimatePresence mode="wait" initial={false}>
66+
{isHovered ? (
67+
<motion.div
68+
key="expanded"
69+
className="flex items-center gap-3"
70+
initial={{ opacity: 0 }}
71+
animate={{ opacity: 1 }}
72+
exit={{ opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }}
73+
transition={{ duration: 0.25, delay: 0.18 }}
74+
>
75+
<div className="flex flex-col min-w-0">
76+
<span className="text-xs font-medium leading-tight">Upgrade to Micro</span>
77+
<span className="text-[11px] text-foreground leading-tight">
78+
Double the memory at no extra cost.
79+
</span>
80+
</div>
81+
<Button asChild type="primary" size="tiny" className="flex-shrink-0 !px-2 !py-1">
82+
<Link href={`/project/${ref}/settings/compute-and-disk`}>Upgrade</Link>
83+
</Button>
84+
<button
85+
type="button"
86+
onClick={() => setIsDismissed(true)}
87+
className="flex-shrink-0 text-brand/50 hover:text-foreground transition-colors h-4 w-4"
88+
aria-label="Dismiss"
89+
>
90+
<X size={12} />
91+
</button>
92+
</motion.div>
93+
) : (
94+
<motion.span
95+
key="collapsed"
96+
className="text-xs font-medium"
97+
initial={{ opacity: 0 }}
98+
animate={{ opacity: 1 }}
99+
exit={{ opacity: 0, transition: { duration: 0.2, ease: 'easeInOut' } }}
100+
transition={{ duration: 0.2, delay: 0.12 }}
101+
>
102+
Upgrade available!
103+
</motion.span>
104+
)}
105+
</AnimatePresence>
106+
</motion.div>
107+
</motion.div>
108+
)
109+
}

apps/studio/components/interfaces/ProjectHome/TopSection.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ReactFlowProvider } from 'reactflow'
1414
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
1515

1616
import { InstanceConfiguration } from '../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration'
17+
import { ComputeUpgradeFloatingNotice } from './ComputeUpgradeFloatingNotice'
1718

1819
export const TopSection = () => {
1920
const isOrioleDb = useIsOrioleDb()
@@ -73,12 +74,15 @@ export const TopSection = () => {
7374
</TooltipContent>
7475
</Tooltip>
7576
)}
76-
<ComputeBadgeWrapper
77-
projectRef={project?.ref}
78-
slug={organization?.slug}
79-
cloudProvider={project?.cloud_provider}
80-
computeSize={project?.infra_compute_size}
81-
/>
77+
<div className="relative">
78+
<ComputeUpgradeFloatingNotice />
79+
<ComputeBadgeWrapper
80+
projectRef={project?.ref}
81+
slug={organization?.slug}
82+
cloudProvider={project?.cloud_provider}
83+
computeSize={project?.infra_compute_size}
84+
/>
85+
</div>
8286
</div>
8387
</div>
8488
<ProjectConnectionHoverCard projectRef={project?.ref} />
@@ -91,7 +95,7 @@ export const TopSection = () => {
9195
<div>
9296
<div
9397
className={cn(
94-
'w-full h-[400px] md:h-[500px] border border-muted rounded-md overflow-hidden flex flex-col relative'
98+
'w-full h-[400px] md:h-[500px] border border-muted rounded-md overflow-hidden flex flex-col'
9599
)}
96100
>
97101
<ReactFlowProvider>

apps/studio/tailwind.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,14 @@ module.exports = config({
8080

8181
animation: {
8282
shimmer: 'shimmer 2s infinite linear',
83+
float: 'float 3s ease-in-out infinite',
8384
sway: 'sway 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
8485
},
8586
keyframes: {
87+
float: {
88+
'0%, 100%': { transform: 'translateY(0px)' },
89+
'50%': { transform: 'translateY(-6px)' },
90+
},
8691
shimmer: {
8792
'0%': {
8893
'background-position': '-1000px 0',

0 commit comments

Comments
 (0)