Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Suspense } from "react"
import { Toaster } from "@ui/components/sonner"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ThemeProvider } from "@/lib/theme-provider"
import Script from "next/script"

const font = Space_Grotesk({
subsets: ["latin"],
Expand Down Expand Up @@ -70,11 +69,6 @@ export default function RootLayout({
</QueryProvider>
</AutumnProvider>
</ThemeProvider>
<Script
src="https://lobbyside.com/widget.js"
data-widget-id="e385c52f-4dd3-4fb2-81eb-da3a78059014"
strategy="lazyOnload"
/>
</body>
</html>
)
Expand Down
223 changes: 195 additions & 28 deletions apps/web/components/next-app-research-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { Phone, Users, X as XIcon } from "lucide-react"
import { useLobbyside } from "@lobbyside/react"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { analytics } from "@/lib/analytics"
Expand All @@ -11,7 +13,15 @@ const STORAGE_KEY = "sm_next_app_research_cta_dismissed_v1"

const BOOK_CALL_HREF = "https://cal.com/supermemory/growth"

function ResearchCtaHeroGraphic() {
const LOBBYSIDE_WIDGET_ID = "e385c52f-4dd3-4fb2-81eb-da3a78059014"

function ResearchCtaHeroGraphic({
avatarUrl,
hostName,
}: {
avatarUrl?: string
hostName?: string
}) {
return (
<div
id="next-app-research-cta-hero"
Expand Down Expand Up @@ -39,7 +49,9 @@ function ResearchCtaHeroGraphic() {
/>
</div>
<div className="relative z-10 flex flex-row items-center justify-center gap-10 px-3">
<Phone className="size-[24px] text-[#7EB0FF]" strokeWidth={1.65} />
<div className="flex size-9 items-center justify-center">
<Phone className="size-[24px] text-[#7EB0FF]" strokeWidth={1.65} />
</div>
<span
className={cn(
dmSans125ClassName(),
Expand All @@ -48,7 +60,23 @@ function ResearchCtaHeroGraphic() {
>
×
</span>
<Users className="size-[24px] text-[#B49CFB]" strokeWidth={1.65} />
{avatarUrl ? (
<span className="relative inline-flex">
<img
src={avatarUrl}
alt={hostName ?? ""}
className="size-9 rounded-full object-cover ring-1 ring-[#B49CFB]/40"
/>
<span
aria-hidden
className="absolute bottom-0 right-0 size-[10px] rounded-full bg-[#22c55e] ring-2 ring-[#0D121A]"
/>
</span>
) : (
<div className="flex size-9 items-center justify-center">
<Users className="size-[24px] text-[#B49CFB]" strokeWidth={1.65} />
</div>
)}
</div>
</div>
)
Expand All @@ -58,38 +86,163 @@ export function NextAppResearchCta() {
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
const [dismissed, setDismissed] = useState(false)
const widget = useLobbyside(LOBBYSIDE_WIDGET_ID)
const { user, org } = useAuth()

useEffect(() => {
setMounted(true)
setDismissed(localStorage.getItem(STORAGE_KEY) === "1")
}, [])

const handleDismiss = useCallback(() => {
const handleDismiss = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
localStorage.setItem(STORAGE_KEY, "1")
setDismissed(true)
analytics.nextAppResearchCtaDismissed()
}, [])

const handleJoinCall = useCallback(async () => {
if (widget.status !== "online" || widget.isQueueFull) return
analytics.nextAppResearchCtaLobbysideCallClicked()
// Open the tab synchronously so Safari/iOS keep the user-activation
// gesture. We redirect it once joinCall() resolves, or fall back to
// the book-a-call URL if the host goes offline / queue fills / the
// request errors between render and click.
const pendingTab = window.open("", "_blank")
const navigate = (url: string) => {
if (pendingTab && !pendingTab.closed) {
pendingTab.location.href = url
} else {
window.open(url, "_blank", "noopener,noreferrer")
}
}
try {
const visitor: Record<string, string> = {}
if (user?.email) visitor.email = user.email
if (user?.name) visitor.name = user.name
if (org?.name) visitor.company = org.name
const github = (user as { github?: unknown } | null)?.github
if (typeof github === "string" && github) visitor.github = github
const joinArgs =
Object.keys(visitor).length > 0 ? { visitor } : undefined
const { entryUrl } = await widget.joinCall(joinArgs)
navigate(entryUrl)
} catch (err) {
console.error("[Lobbyside] joinCall failed", err)
navigate(BOOK_CALL_HREF)
}
}, [widget, user, org])

const handleCardKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (e.target !== e.currentTarget) return
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleJoinCall()
}
},
[handleJoinCall],
)

const handleBookClick = useCallback(() => {
analytics.nextAppResearchCtaBookCallClicked()
}, [])

if (!mounted || dismissed || pathname.startsWith("/onboarding")) {
if (
!mounted ||
dismissed ||
pathname.startsWith("/onboarding") ||
widget.status === "loading"
) {
return null
}

const cardBaseClasses = cn(
"fixed z-[45] bottom-4 left-4 min-w-[280px] max-w-[min(calc(100vw-2rem),22.5rem)]",
"rounded-xl border border-white/[0.08] bg-[#0D121A]/95 backdrop-blur-md",
"shadow-[0_8px_32px_rgba(0,0,0,0.35)] p-3.5",
)

if (widget.status !== "online" || widget.isQueueFull) {
return (
<section
id="next-app-research-cta"
className={cardBaseClasses}
aria-label="Research participant invitation"
>
<div className="flex flex-col gap-3">
<ResearchCtaHeroGraphic />
<div className="min-w-0 w-full">
<div className="flex items-start gap-1">
<p
className={cn(
dmSans125ClassName(),
"flex-1 min-w-0 font-medium text-[12px] text-[#FAFAFA] tracking-[-0.12px]",
)}
>
Be part of the next supermemory app
</p>
<button
type="button"
onClick={handleDismiss}
className={cn(
"shrink-0 rounded-md p-1 -mr-1 -mt-0.5",
"text-muted-foreground hover:text-foreground transition-colors",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Dismiss"
>
<XIcon className="size-4" />
</button>
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-1 text-[12px] text-[#737373] tracking-[-0.12px]",
)}
>
Share what you want next—we’d love a quick call.
</p>
<div className="mt-2.5 flex justify-end">
<a
href={BOOK_CALL_HREF}
onClick={handleBookClick}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"inline-flex text-[13px] font-medium text-[#A3A3A3]",
"tracking-[-0.13px] underline underline-offset-4 decoration-white/20",
"hover:text-[#FAFAFA] hover:decoration-white/40 transition-colors",
)}
>
Book a call
</a>
</div>
</div>
</div>
</section>
)
}

return (
<section
id="next-app-research-cta"
role="button"
tabIndex={0}
onClick={handleJoinCall}
onKeyDown={handleCardKeyDown}
className={cn(
"fixed z-[45] bottom-4 left-4 max-w-[min(calc(100vw-2rem),19rem)]",
"rounded-xl border border-white/[0.08] bg-[#0D121A]/95 backdrop-blur-md",
"shadow-[0_8px_32px_rgba(0,0,0,0.35)] p-3.5",
cardBaseClasses,
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Research participant invitation"
aria-label={`${widget.buttonText} with ${widget.hostName}`}
>
<div className="flex flex-col gap-3">
<ResearchCtaHeroGraphic />
<ResearchCtaHeroGraphic
avatarUrl={widget.avatarUrl}
hostName={widget.hostName}
/>
<div className="min-w-0 w-full">
<div className="flex items-start gap-1">
<p
Expand All @@ -98,7 +251,7 @@ export function NextAppResearchCta() {
"flex-1 min-w-0 font-medium text-[12px] text-[#FAFAFA] tracking-[-0.12px]",
)}
>
Be part of the next supermemory app
{widget.ctaText}
</p>
<button
type="button"
Expand All @@ -113,29 +266,43 @@ export function NextAppResearchCta() {
<XIcon className="size-4" />
</button>
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-1 text-[12px] text-[#737373] tracking-[-0.12px]",
)}
>
Share what you want next—we’d love a quick call.
</p>
<div className="mt-2.5 flex justify-end">
<a
href={BOOK_CALL_HREF}
onClick={handleBookClick}
target="_blank"
rel="noopener noreferrer"
<div className="mt-2.5 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[12px] font-medium text-[#FAFAFA] tracking-[-0.12px]",
)}
>
{widget.hostName}
</p>
{widget.hostTitle ? (
<p
className={cn(
dmSans125ClassName(),
"truncate text-[11px] text-[#737373] tracking-[-0.11px]",
)}
>
{widget.hostTitle}
</p>
) : null}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleJoinCall()
}}
className={cn(
dmSans125ClassName(),
"inline-flex text-[13px] font-medium text-[#A3A3A3]",
"shrink-0 inline-flex text-[13px] font-medium text-[#A3A3A3]",
"tracking-[-0.13px] underline underline-offset-4 decoration-white/20",
"hover:text-[#FAFAFA] hover:decoration-white/40 transition-colors",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
)}
>
Book a call
</a>
{widget.buttonText}
</button>
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const analytics = {
safeCapture("next_app_research_cta_dismissed"),
nextAppResearchCtaBookCallClicked: () =>
safeCapture("next_app_research_cta_book_call_clicked"),
nextAppResearchCtaLobbysideCallClicked: () =>
safeCapture("next_app_research_cta_lobbyside_call_clicked"),

mcpViewOpened: () => safeCapture("mcp_view_opened"),
mcpInstallCmdCopied: () => safeCapture("mcp_install_cmd_copied"),
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.0",
"@lobbyside/react": "0.2.0",
"@opennextjs/cloudflare": "^1.12.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
Expand Down
13 changes: 12 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading