diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts
index c7ae8d702ef..eb5bcd610cb 100644
--- a/packages/sanity/src/core/i18n/bundles/studio.ts
+++ b/packages/sanity/src/core/i18n/bundles/studio.ts
@@ -1523,7 +1523,11 @@ export const studioLocaleStrings = defineLocalesResources('studio', {
'timeline.since': 'Since: {{timestamp, datetime}}',
/** Label for missing change version for timeline menu dropdown are showing */
'timeline.since-version-missing': 'Since: unknown version',
-
+ /** Label for the button showed after trial ended */
+ 'user-menu.action.free-trial-finished': 'Upgrade from free',
+ /** Label for button showing the free trial days left */
+ 'user-menu.action.free-trial_one': '{{count}} day left in trial',
+ 'user-menu.action.free-trial_other': '{{count}} days left in trial',
/** Label for action to invite members to the current sanity project */
'user-menu.action.invite-members': 'Invite members',
/** Accessibility label for action to invite members to the current sanity project */
diff --git a/packages/sanity/src/core/studio/components/navbar/NavDrawer.tsx b/packages/sanity/src/core/studio/components/navbar/NavDrawer.tsx
index d1590c782a7..89ab515e367 100644
--- a/packages/sanity/src/core/studio/components/navbar/NavDrawer.tsx
+++ b/packages/sanity/src/core/studio/components/navbar/NavDrawer.tsx
@@ -14,6 +14,7 @@ import {StudioThemeColorSchemeKey} from '../../../theme'
import {userHasRole} from '../../../util/userHasRole'
import {useTranslation} from '../../../i18n'
import {WorkspaceMenuButton} from './workspace'
+import {FreeTrial} from './free-trial'
const ANIMATION_TRANSITION: Transition = {
duration: 0.2,
@@ -203,6 +204,7 @@ export const NavDrawer = memo(function NavDrawer(props: NavDrawerProps) {
{setScheme && }
+
-
-
-
- {!shouldRender.tools && (
-
-
+
+
+
+
+ {!shouldRender.tools && (
+
+
+
+ )}
+
+ {!shouldRender.brandingCenter && (
+
+
+
+
+
+ )}
+
+ {shouldRender.workspaces && (
+
+
+
+ )}
+
+
+
- )}
- {!shouldRender.brandingCenter && (
-
+ {/* Search */}
+
+
+
+
+ {shouldRender.searchFullscreen && (
+
+ )}
+
+ {!shouldRender.searchFullscreen && }
+
+
+
+
+ {shouldRender.tools && (
+
+
+
+ )}
+
+
+ {shouldRender.brandingCenter && (
+
)}
- {shouldRender.workspaces && (
-
-
-
- )}
-
-
-
-
-
- {/* Search */}
-
-
-
-
- {shouldRender.searchFullscreen && (
-
- )}
-
- {!shouldRender.searchFullscreen && }
-
-
-
-
- {shouldRender.tools && (
-
-
-
- )}
-
-
- {shouldRender.brandingCenter && (
-
-
-
-
-
- )}
-
-
- {(shouldRender.configIssues || shouldRender.resources) && (
-
-
- {shouldRender.configIssues && }
- {shouldRender.resources && }
-
-
- )}
-
-
-
- {shouldRender.tools && }
- {shouldRender.searchFullscreen && (
-
+
+ {(shouldRender.configIssues || shouldRender.resources || shouldRender.tools) && (
+
+
+ {shouldRender.tools && }
+ {shouldRender.configIssues && }
+ {shouldRender.resources && }
+
+
)}
+
+
+
+ {shouldRender.tools && }
+ {shouldRender.searchFullscreen && (
+
+ )}
+
-
-
+
- {!shouldRender.tools && (
-
- )}
-
+ {!shouldRender.tools && (
+
+ )}
+
+
)
}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/DescriptionSerializer.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/DescriptionSerializer.tsx
new file mode 100644
index 00000000000..a739597dbf9
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/DescriptionSerializer.tsx
@@ -0,0 +1,151 @@
+import {PortableText, PortableTextComponents} from '@portabletext/react'
+import {LinkIcon} from '@sanity/icons'
+import {PortableTextBlock} from '@sanity/types'
+import {Box, Card, Flex, Text} from '@sanity/ui'
+import styled from 'styled-components'
+import React, {useEffect, useState} from 'react'
+
+interface DescriptionSerializerProps {
+ blocks: PortableTextBlock[]
+}
+
+const Divider = styled(Box)`
+ height: 1px;
+ background: var(--card-border-color);
+ width: 100%;
+`
+
+const SerializerContainer = styled.div`
+ // Remove margin bottom to last box.
+ > [data-ui='Box']:last-child {
+ margin-bottom: 0;
+ }
+`
+
+const Link = styled.a<{useTextColor: boolean}>`
+ font-weight: 600;
+ color: ${(props) => (props.useTextColor ? 'var(--card-muted-fg-color) !important' : '')};
+`
+
+const DynamicIconContainer = styled.span`
+ > svg {
+ display: inline;
+ font-size: calc(21 / 16 * 1rem) !important;
+ margin: -0.375rem 0 !important;
+ *[stroke] {
+ stroke: currentColor;
+ }
+ }
+`
+const DynamicIcon = (props: {icon: {url: string}}) => {
+ const [ref, setRef] = useState(null)
+ useEffect(() => {
+ if (!ref) return
+
+ const controller = new AbortController()
+ const signal = controller.signal
+
+ fetch(props.icon.url, {signal})
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`)
+ }
+ return response.text()
+ })
+ .then((data) => {
+ if (!ref) return
+ ref.innerHTML = data
+ })
+ .catch((error) => {
+ if (error.name !== 'AbortError') {
+ console.error(error)
+ }
+ })
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ controller.abort()
+ }
+ }, [ref, props.icon.url])
+
+ return
+}
+
+function NormalBlock(props: {children: React.ReactNode}) {
+ const {children} = props
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const components: PortableTextComponents = {
+ block: {
+ normal: ({children}) => {children} ,
+ },
+ list: {
+ bullet: ({children}) => children,
+ number: ({children}) => <>{children}>,
+ checkmarks: ({children}) => <>{children}>,
+ },
+ listItem: {
+ bullet: ({children}) => {children} ,
+ number: ({children}) => {children} ,
+ checkmarks: ({children}) => {children} ,
+ },
+
+ marks: {
+ strong: ({children}) => {children} ,
+ link: (props) => (
+
+ {props.children}
+ {props.value.showIcon && }
+
+ ),
+ },
+ types: {
+ inlineIcon: (props) => ,
+ divider: () => (
+
+
+
+
+
+ ),
+ iconAndText: (props) => (
+
+
+
+
+
+
+ {props.value.title}
+
+
+
+
+ {props.value.text}
+
+
+ ),
+ },
+}
+
+export function DescriptionSerializer(props: DescriptionSerializerProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/DialogContent.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/DialogContent.tsx
new file mode 100644
index 00000000000..2a9728972ae
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/DialogContent.tsx
@@ -0,0 +1,111 @@
+import {Box, Button, Dialog, Flex, Heading} from '@sanity/ui'
+import styled from 'styled-components'
+import {CloseIcon} from '@sanity/icons'
+import {useColorSchemeValue} from '../../../colorScheme'
+import {FreeTrialDialog} from './types'
+import {DescriptionSerializer} from './DescriptionSerializer'
+
+/**
+ * Absolute positioned button to close the dialog.
+ */
+const StyledButton = styled(Button)`
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 20;
+ background: transparent;
+ border-radius: 9999px;
+ box-shadow: none;
+ color: white;
+ --card-fg-color: white;
+ :hover {
+ --card-fg-color: white;
+ }
+`
+
+const Image = styled.img`
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ height: 196px;
+`
+
+const StyledDialog = styled(Dialog)`
+ > [data-ui='DialogCard'] {
+ max-width: 22.5rem;
+ }
+`
+interface ModalContentProps {
+ content: FreeTrialDialog
+ handleClose: () => void
+ handleOpenNext: () => void
+ open: boolean
+}
+
+export function DialogContent({handleClose, handleOpenNext, content, open}: ModalContentProps) {
+ const schemeValue = useColorSchemeValue()
+ if (!open) return null
+ return (
+
+ {content.secondaryButton?.text && (
+
+ )}
+
+
+ }
+ >
+
+ {content.image && (
+
+ )}
+
+
+ {content.headingText}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrial.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrial.tsx
new file mode 100644
index 00000000000..1cdbe94827c
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrial.tsx
@@ -0,0 +1,89 @@
+import {useCallback, useEffect, useRef, useState} from 'react'
+import {Popover} from '@sanity/ui'
+import {useColorSchemeValue} from '../../../colorScheme'
+import {PopoverContent} from './PopoverContent'
+import {DialogContent} from './DialogContent'
+import {FreeTrialButtonTopbar, FreeTrialButtonSidebar} from './FreeTrialButton'
+import {useFreeTrialContext} from './FreeTrialContext'
+
+interface FreeTrialProps {
+ type: 'sidebar' | 'topbar'
+}
+
+export function FreeTrial({type}: FreeTrialProps) {
+ const {data, showDialog, showOnLoad, toggleShowContent} = useFreeTrialContext()
+ const scheme = useColorSchemeValue()
+ // On mobile, give it some time so the popover doesn't show up until the navbar is open.
+ const [showPopover, setShowPopover] = useState(type !== 'sidebar')
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowPopover(true)
+ }, 300)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [])
+
+ const closeAndReOpen = useCallback(() => toggleShowContent(true), [toggleShowContent])
+ const toggleDialog = useCallback(() => {
+ ref.current?.focus()
+ toggleShowContent(false)
+ }, [toggleShowContent, ref])
+
+ if (!data?.id) return null
+ const dialogToRender = showOnLoad ? data.showOnLoad : data.showOnClick
+ if (!dialogToRender) return null
+
+ const button =
+ type === 'sidebar' ? (
+
+ ) : (
+
+ )
+
+ if (dialogToRender?.dialogType === 'popover') {
+ return (
+
+ }
+ >
+ {button}
+
+ )
+ }
+
+ return (
+ <>
+ {button}
+
+ >
+ )
+}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx
new file mode 100644
index 00000000000..333aae3a3eb
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialButton.tsx
@@ -0,0 +1,116 @@
+import styled from 'styled-components'
+import {Button, Text, Card, Stack} from '@sanity/ui'
+import {BoltIcon} from '@sanity/icons'
+import {purple, yellow} from '@sanity/color'
+import {useTranslation} from 'react-i18next'
+import {forwardRef} from 'react'
+
+const StyledButton = styled(Button)<{smallIcon: boolean}>`
+ padding: ${(props) => (props.smallIcon ? '1px' : 0)};
+ position: relative;
+`
+
+const CenteredStroke = styled.div`
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+`
+
+interface OutlineProps {
+ daysLeft: number
+ totalDays: number
+}
+
+const SvgFilledOutline = ({daysLeft, totalDays}: OutlineProps) => {
+ const progress = totalDays - daysLeft
+
+ const percentage = Math.round((progress / totalDays) * 100)
+ const radius = 12.5
+ const strokeDasharray = 2 * Math.PI * radius
+ const strokeDashOffset = strokeDasharray * ((100 - percentage) / 100)
+ const strokeWidth = 1.2
+ const size = radius * 2 + strokeWidth
+
+ return (
+
+
+
+ 75 ? yellow['600'].hex : purple['400'].hex}
+ />
+
+
+
+
+ )
+}
+
+interface FreeTrialButtonProps extends OutlineProps {
+ toggleShowContent: () => void
+}
+
+export const FreeTrialButtonTopbar = forwardRef(function FreeTrialButtonTopbar(
+ {toggleShowContent, daysLeft, totalDays}: FreeTrialButtonProps,
+ ref: React.Ref,
+) {
+ return (
+
+
+
+
+ {daysLeft > 0 && }
+
+ )
+})
+
+export const FreeTrialButtonSidebar = forwardRef(function FreeTrialButtonSidebar(
+ {toggleShowContent, daysLeft}: Omit,
+ ref: React.Ref,
+) {
+ const {t} = useTranslation()
+
+ return (
+
+
+
+ )
+})
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialContext.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialContext.tsx
new file mode 100644
index 00000000000..1d16d34bea3
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/FreeTrialContext.tsx
@@ -0,0 +1,73 @@
+import React, {createContext, useContext, useState, useCallback, useEffect} from 'react'
+import {useClient} from '../../../../hooks'
+import {SANITY_VERSION} from '../../../../version'
+import {FreeTrialResponse} from './types'
+
+interface FreeTrialContextProps {
+ data: FreeTrialResponse | null
+ showDialog: boolean
+ showOnLoad: boolean
+ /**
+ * If the user is seeing the `showOnLoad` popover or modal, and clicks on the pricing button the `showOnClick` modal should be triggered.
+ */
+ toggleShowContent: (closeAndReOpen?: boolean) => void
+}
+
+const FreeTrialContext = createContext(undefined)
+
+interface FreeTrialProviderProps {
+ children: React.ReactNode
+}
+export const FreeTrialProvider = ({children}: FreeTrialProviderProps) => {
+ const [data, setData] = useState(null)
+ const [showDialog, setShowDialog] = useState(false)
+ const [showOnLoad, setShowOnLoad] = useState(false)
+ const client = useClient({apiVersion: 'vX'})
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const response = await client.request({
+ url: `/journey/trial?studioVersion=${SANITY_VERSION}`,
+ })
+
+ setData(response)
+ // Validates if the user has seen the "structure rename modal" before showing this one. To avoid multiple popovers at same time.
+ const deskRenameSeen = localStorage.getItem('sanityStudio:desk:renameDismissed') === '1'
+ if (deskRenameSeen && response?.showOnLoad) {
+ setShowOnLoad(true)
+ setShowDialog(true)
+ }
+ }
+ fetchData()
+ }, [client])
+
+ const toggleShowContent = useCallback(
+ (closeAndReOpen = false) => {
+ if (showOnLoad) {
+ setShowOnLoad(false)
+ // If the user clicks on the button, while the show on load is open, we want to trigger the modal.
+ setShowDialog(closeAndReOpen)
+ if (data?.showOnLoad?.id) {
+ client.request({url: `/journey/trial/${data?.showOnLoad.id}`, method: 'POST'})
+ }
+ } else {
+ setShowDialog((p) => !p)
+ }
+ },
+ [client, showOnLoad, data?.showOnLoad?.id],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useFreeTrialContext = (): FreeTrialContextProps => {
+ const context = useContext(FreeTrialContext)
+ if (!context) {
+ throw new Error('useFreeTrial must be used within a FreeTrialProvider')
+ }
+ return context
+}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/PopoverContent.tsx b/packages/sanity/src/core/studio/components/navbar/free-trial/PopoverContent.tsx
new file mode 100644
index 00000000000..9bcccbbed19
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/PopoverContent.tsx
@@ -0,0 +1,70 @@
+import {Card, Heading, Flex, Button, Box, Container} from '@sanity/ui'
+import styled from 'styled-components'
+import {useColorSchemeValue} from '../../../colorScheme'
+import {FreeTrialDialog} from './types'
+import {DescriptionSerializer} from './DescriptionSerializer'
+
+const Image = styled.img`
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ height: 180px;
+`
+
+interface PopoverContentProps {
+ content: FreeTrialDialog
+ handleClose: () => void
+ handleOpenNext: () => void
+}
+
+export function PopoverContent({content, handleClose, handleOpenNext}: PopoverContentProps) {
+ const schemeValue = useColorSchemeValue()
+
+ return (
+
+
+ {content.image && (
+
+ )}
+
+
+ {content.headingText}
+
+
+
+
+
+
+ {content.secondaryButton?.text && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/index.ts b/packages/sanity/src/core/studio/components/navbar/free-trial/index.ts
new file mode 100644
index 00000000000..5f6add5f881
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/index.ts
@@ -0,0 +1 @@
+export * from './FreeTrial'
diff --git a/packages/sanity/src/core/studio/components/navbar/free-trial/types.ts b/packages/sanity/src/core/studio/components/navbar/free-trial/types.ts
new file mode 100644
index 00000000000..04d104eae14
--- /dev/null
+++ b/packages/sanity/src/core/studio/components/navbar/free-trial/types.ts
@@ -0,0 +1,45 @@
+import {PortableTextBlock} from '@sanity/types'
+
+export interface FreeTrialResponse {
+ id: string
+ icon: string
+ style: string
+ showOnLoad: FreeTrialDialog | null
+ showOnClick: FreeTrialDialog | null
+ daysLeft: number
+ totalDays: number
+}
+export interface FreeTrialDialog {
+ _id: string
+ _type: 'dialog'
+ _createdAt: string
+ ctaButton?: {
+ text: string
+ action: 'openNext' | 'closeDialog' | 'openUrl'
+ url?: string
+ }
+ secondaryButton?: {
+ text: string
+ }
+ descriptionText: PortableTextBlock[]
+ dialogType: 'modal' | 'popover'
+ headingText: string
+ id: string
+ image: Image | null
+ tags?: Tag[]
+ _rev: string
+ _updatedAt: string
+}
+
+interface Tag {
+ _type: 'tag'
+ _key: string
+ tag: string
+}
+
+interface Image {
+ asset: {
+ url: string
+ altText: string | null
+ }
+}