mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): pause Google Drive connect with a notify-me fallback (#1602)
 Google is not approving new authorizations while it re-reviews our app, so every path that starts a new Google connection now shows a PAUSED badge and a "Notify me" button instead of Connect. - Existing connections are untouched: sync, file picking and history still work. - Notify me fires a connector_paused_clicked PostHog event and remembers the choice in localStorage, so we can pull who to email when the review clears. - One list in lib/connector-availability.ts drives every surface; removing the two entries turns Google back on.
This commit is contained in:
parent
f11d8c4620
commit
29c43984fe
8 changed files with 248 additions and 11 deletions
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import type { z } from "zod"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -505,7 +507,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
},
|
||||
})
|
||||
|
||||
const notify = useConnectorNotify()
|
||||
|
||||
// Every connect path funnels here; a `disabled` button would swallow the click.
|
||||
const handleConnect = (provider: ConnectorProvider) => {
|
||||
if (connectorPause(provider)) {
|
||||
notify.request(provider)
|
||||
return
|
||||
}
|
||||
setConnectingProvider(provider)
|
||||
addConnectionMutation.mutate({
|
||||
provider,
|
||||
|
|
@ -548,14 +557,32 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="size-6 text-[#737373]" />
|
||||
<div className="space-y-[6px] flex-1">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
{connectorPause(provider) && (
|
||||
<span className="shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{provider === "google-drive" ? (
|
||||
{connectorPause(provider) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notify.request(provider)}
|
||||
title={connectorPause(provider)?.message}
|
||||
className="bg-[#14161A] text-[#FAFAFA] text-[14px] font-medium px-3 h-8 rounded-md border border-[rgba(82,89,102,0.3)] hover:bg-[#1B1F24] transition-colors"
|
||||
>
|
||||
{notify.isRequested(provider)
|
||||
? "We'll email you"
|
||||
: "Notify me"}
|
||||
</button>
|
||||
) : provider === "google-drive" ? (
|
||||
<div className="flex items-center rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -721,6 +748,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex flex-col">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (connectorPause("google-drive")) {
|
||||
notify.request("google-drive")
|
||||
return
|
||||
}
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
|
|
@ -741,6 +772,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (connectorPause("google-drive")) {
|
||||
notify.request("google-drive")
|
||||
return
|
||||
}
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { isConnectorPaused } from "@/lib/connector-availability"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import {
|
||||
useQuickNoteDraftReset,
|
||||
|
|
@ -603,6 +604,10 @@ export function AppExperience() {
|
|||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
if (integration && isConnectorPaused(integration)) {
|
||||
void setViewMode("integrations")
|
||||
return
|
||||
}
|
||||
if (integration === "notion" || integration === "google-drive") {
|
||||
void setAddDoc("connect")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -37,13 +37,17 @@ import {
|
|||
FileText,
|
||||
Globe,
|
||||
Info,
|
||||
Bell,
|
||||
Loader,
|
||||
Pause,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
|
@ -599,6 +603,52 @@ export function DetailWrapper({
|
|||
)
|
||||
}
|
||||
|
||||
function NotifyMeButton({
|
||||
provider,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
provider: string
|
||||
title?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { isRequested, request } = useConnectorNotify()
|
||||
const requested = isRequested(provider)
|
||||
return (
|
||||
<PillButton
|
||||
className={requested ? "cursor-default opacity-60" : undefined}
|
||||
onClick={() => {
|
||||
onClick?.()
|
||||
if (!requested) request(provider)
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{requested ? (
|
||||
<>
|
||||
<Check className="size-3.5" /> We'll email you
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="size-3.5" /> Notify me
|
||||
</>
|
||||
)}
|
||||
</PillButton>
|
||||
)
|
||||
}
|
||||
|
||||
function PausedChip() {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]",
|
||||
)}
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -2276,6 +2326,7 @@ function ItemCard({
|
|||
leftIndicator,
|
||||
statusSlot,
|
||||
layoutClassName,
|
||||
paused,
|
||||
}: {
|
||||
actionSlot: ReactNode
|
||||
infoActionSlot?: ReactNode
|
||||
|
|
@ -2291,6 +2342,7 @@ function ItemCard({
|
|||
leftIndicator?: ReactNode
|
||||
statusSlot?: ReactNode
|
||||
layoutClassName?: string
|
||||
paused?: boolean
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
return (
|
||||
|
|
@ -2345,7 +2397,8 @@ function ItemCard({
|
|||
>
|
||||
{name}
|
||||
</span>
|
||||
{isNew && <NewChip />}
|
||||
{paused && <PausedChip />}
|
||||
{isNew && !paused && <NewChip />}
|
||||
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -2979,6 +3032,12 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const handlePausedConnector = useCallback((provider: string) => {
|
||||
const pause = connectorPause(provider)
|
||||
if (!pause) return
|
||||
toast.info(pause.message)
|
||||
}, [])
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
async (planId?: unknown) => {
|
||||
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
||||
|
|
@ -3084,6 +3143,10 @@ export function IntegrationsView({
|
|||
if (["notion", "google-drive", "onedrive"].includes(target)) {
|
||||
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
|
||||
void setConnectTarget(null)
|
||||
if (connectorPause(target)) {
|
||||
handlePausedConnector(target)
|
||||
return
|
||||
}
|
||||
void setAddDoc("connect")
|
||||
}
|
||||
}, 0)
|
||||
|
|
@ -3103,6 +3166,7 @@ export function IntegrationsView({
|
|||
setAddDoc,
|
||||
handleUpgrade,
|
||||
openPluginSetup,
|
||||
handlePausedConnector,
|
||||
])
|
||||
|
||||
const closeMcpModal = () => {
|
||||
|
|
@ -3551,15 +3615,20 @@ export function IntegrationsView({
|
|||
const count = connectionsByProvider[item.provider].length
|
||||
const isGranola = item.provider === "granola"
|
||||
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
|
||||
const pause = connectorPause(item.provider)
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another knowledge source"
|
||||
title="Add another knowledge source"
|
||||
aria-label={pause ? "Paused" : "Add another knowledge source"}
|
||||
title={pause ? pause.message : "Add another knowledge source"}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (pause) {
|
||||
handlePausedConnector(item.provider)
|
||||
return
|
||||
}
|
||||
if (isGranola) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
|
|
@ -3573,13 +3642,27 @@ export function IntegrationsView({
|
|||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
pause && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{pause ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (pause) {
|
||||
return (
|
||||
<NotifyMeButton
|
||||
onClick={() => trackCard(item)}
|
||||
provider={item.provider}
|
||||
title={pause.message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (needsPlanUpgrade) {
|
||||
return (
|
||||
<PillButton onClick={() => handleUpgrade("api_pro")}>
|
||||
|
|
@ -3795,6 +3878,7 @@ export function IntegrationsView({
|
|||
leftIndicator={renderLeftIndicator(item)}
|
||||
statusSlot={renderStatus(item)}
|
||||
layoutClassName={layoutClassName}
|
||||
paused={item.kind === "connector" && !!connectorPause(item.provider)}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,17 +16,22 @@ export function PillButton({
|
|||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: "button" | "submit"
|
||||
className?: string
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
|
|
@ -34,6 +39,7 @@ export function PillButton({
|
|||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ import {
|
|||
} from "@lib/constants"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { toast } from "sonner"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
|
@ -374,6 +376,8 @@ export function StepSources({
|
|||
return !connectorAccess
|
||||
}
|
||||
|
||||
const notify = useConnectorNotify()
|
||||
|
||||
const setState = (id: SourceId, state: SourceState) => {
|
||||
onChange({ ...values, connected: { ...values.connected, [id]: state } })
|
||||
}
|
||||
|
|
@ -383,6 +387,11 @@ export function StepSources({
|
|||
id: SourceId,
|
||||
) => {
|
||||
analytics.onboardingIntegrationClicked({ integration: provider })
|
||||
if (connectorPause(provider)) {
|
||||
notify.request(provider)
|
||||
setState(id, "waitlist")
|
||||
return
|
||||
}
|
||||
setState(id, "connecting")
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
|
|
@ -422,12 +431,18 @@ export function StepSources({
|
|||
setState(id, "waitlist")
|
||||
}
|
||||
|
||||
// Paused beats locked: upgrading cannot unlock a connector nobody can connect.
|
||||
const guard = (
|
||||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => {
|
||||
return () => {
|
||||
if (provider && connectorPause(provider)) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
if (isLocked(plan) && plan) {
|
||||
setRequestedPlan(plan)
|
||||
setRequestedConnector(title)
|
||||
|
|
@ -948,19 +963,22 @@ function GoogleDriveSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
connectRealProvider: (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
) => void
|
||||
}) {
|
||||
const pause = connectorPause("google-drive")
|
||||
|
||||
return (
|
||||
<SourceCard
|
||||
title="Google Drive"
|
||||
blurb="Docs, sheets, slides — the working memory of your team."
|
||||
icon={<GoogleDrive className="size-7" />}
|
||||
state={values.connected.drive ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
ctaLabel={pause ? "Notify me" : "Connect"}
|
||||
locked={isLocked("pro")}
|
||||
requiredPlan="pro"
|
||||
perks={[
|
||||
|
|
@ -968,11 +986,19 @@ function GoogleDriveSourceCard({
|
|||
"Stays in sync as files change",
|
||||
"You pick what to share at sign-in",
|
||||
]}
|
||||
onConnect={guard("pro", "Google Drive", () =>
|
||||
connectRealProvider("google-drive", "drive"),
|
||||
onConnect={guard(
|
||||
"pro",
|
||||
"Google Drive",
|
||||
() => connectRealProvider("google-drive", "drive"),
|
||||
"google-drive",
|
||||
)}
|
||||
headerNote={
|
||||
values.driveScope === "full" ? (
|
||||
pause ? (
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#F5A524] font-medium">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
{pause.message}
|
||||
</p>
|
||||
) : values.driveScope === "full" ? (
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
Full Drive can exhaust your monthly usage.
|
||||
|
|
@ -1006,6 +1032,7 @@ function NotionSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
connectRealProvider: (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
|
|
@ -1048,6 +1075,7 @@ function GranolaSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
|
|
@ -1092,6 +1120,7 @@ function MoreSourcesGrid({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
openExternal: (id: SourceId, url: string) => void
|
||||
requestWaitlist: (id: SourceId) => void
|
||||
|
|
@ -1283,7 +1312,9 @@ function SourceCard({
|
|||
{isDone ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
||||
<Check className="size-3.5" />
|
||||
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
|
||||
{state === "waitlist"
|
||||
? "We'll email you"
|
||||
: (doneLabel ?? "Connected")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export const analytics = {
|
|||
connectionDeleted: () => safeCapture("connection_deleted"),
|
||||
connectionAuthStarted: (props: { provider: string }) =>
|
||||
safeCapture("connection_auth_started", props),
|
||||
connectorPausedClicked: (props: { provider: string; reason: string }) =>
|
||||
safeCapture("connector_paused_clicked", props),
|
||||
|
||||
// integrations surface (main Nova page)
|
||||
integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
|
||||
|
|
|
|||
30
apps/web/lib/connector-availability.ts
Normal file
30
apps/web/lib/connector-availability.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type ConnectorPauseReason = "google_verification"
|
||||
|
||||
export interface ConnectorPause {
|
||||
reason: ConnectorPauseReason
|
||||
label: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const PAUSED_CONNECTORS: Record<string, ConnectorPause> = {
|
||||
"google-drive": {
|
||||
reason: "google_verification",
|
||||
label: "Google Drive",
|
||||
message:
|
||||
"New Google Drive connections are paused while Google reviews our app. Already connected? Your files keep syncing.",
|
||||
},
|
||||
gmail: {
|
||||
reason: "google_verification",
|
||||
label: "Gmail",
|
||||
message:
|
||||
"New Gmail connections are paused while Google reviews our app. Already connected? Your email keeps syncing.",
|
||||
},
|
||||
}
|
||||
|
||||
export function connectorPause(provider: string): ConnectorPause | undefined {
|
||||
return PAUSED_CONNECTORS[provider]
|
||||
}
|
||||
|
||||
export function isConnectorPaused(provider: string): boolean {
|
||||
return provider in PAUSED_CONNECTORS
|
||||
}
|
||||
44
apps/web/lib/connector-notify.ts
Normal file
44
apps/web/lib/connector-notify.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
|
||||
const key = (provider: string) => `connector_notify:${provider}`
|
||||
|
||||
// Per-browser only. The PostHog event is the record of truth for who to email.
|
||||
export function useConnectorNotify() {
|
||||
const [requested, setRequested] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const seen: Record<string, boolean> = {}
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith("connector_notify:")) {
|
||||
seen[k.slice("connector_notify:".length)] = true
|
||||
}
|
||||
}
|
||||
setRequested(seen)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const isRequested = useCallback(
|
||||
(provider: string) => requested[provider] === true,
|
||||
[requested],
|
||||
)
|
||||
|
||||
const request = useCallback((provider: string) => {
|
||||
const pause = connectorPause(provider)
|
||||
if (!pause) return
|
||||
analytics.connectorPausedClicked({ provider, reason: pause.reason })
|
||||
try {
|
||||
localStorage.setItem(key(provider), "1")
|
||||
} catch {}
|
||||
setRequested((prev) => ({ ...prev, [provider]: true }))
|
||||
toast.success(`We'll email you when ${pause.label} is back.`)
|
||||
}, [])
|
||||
|
||||
return { isRequested, request }
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue