diff --git a/src/components/app/hooks/useSubscriptionPlan.ts b/src/components/app/hooks/useSubscriptionPlan.ts new file mode 100644 index 00000000..8ec6be81 --- /dev/null +++ b/src/components/app/hooks/useSubscriptionPlan.ts @@ -0,0 +1,68 @@ +import { Subscription, SubscriptionPlan } from '@/application/types'; +import { isOfficialHost } from '@/utils/subscription'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * Hook to manage subscription plan loading and Pro feature detection + * Only loads subscription for official hosts (self-hosted instances have Pro features enabled by default) + * + * @param getSubscriptions - Function to fetch subscriptions (can be undefined) + * @returns Object containing activeSubscriptionPlan and isPro flag + */ +export function useSubscriptionPlan( + getSubscriptions?: () => Promise +): { + activeSubscriptionPlan: SubscriptionPlan | null; + isPro: boolean; +} { + const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); + // Pro features are enabled by default on self-hosted instances + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + + const loadSubscription = useCallback(async () => { + try { + if (!getSubscriptions) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscriptions = await getSubscriptions(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); + } catch (e: any) { + // Silently handle expected errors (API not initialized, no response data, etc.) + // These are normal scenarios when the service isn't available or there's no subscription data + const isExpectedError = + e?.code === -1 && + (e?.message === 'No response data received' || + e?.message === 'No response received from server' || + e?.message === 'API service not initialized'); + + if (!isExpectedError) { + console.error(e); + } + + setActiveSubscriptionPlan(SubscriptionPlan.Free); + } + }, [getSubscriptions]); + + useEffect(() => { + // Only load subscription for official host (self-hosted instances have Pro features enabled by default) + if (isOfficialHost()) { + void loadSubscription(); + } + }, [loadSubscription]); + + return { + activeSubscriptionPlan, + isPro, + }; +} + diff --git a/src/components/database/components/property/select/OptionMenu.tsx b/src/components/database/components/property/select/OptionMenu.tsx index 134c07d3..f9375c59 100644 --- a/src/components/database/components/property/select/OptionMenu.tsx +++ b/src/components/database/components/property/select/OptionMenu.tsx @@ -1,24 +1,23 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectOption, SelectOptionColor, useDatabaseContext } from '@/application/database-yjs'; import { useDeleteSelectOption, useUpdateSelectOption } from '@/application/database-yjs/dispatch'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { ColorTile } from '@/components/_shared/color-picker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const'; import { DropdownMenu, + DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuContent, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { isOfficialHost } from '@/utils/subscription'; function OptionMenu({ open, @@ -37,31 +36,7 @@ function OptionMenu({ const onDelete = useDeleteSelectOption(fieldId); const onUpdate = useUpdateSelectOption(fieldId); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const colors = useMemo(() => { const baseColors = [ @@ -282,10 +257,10 @@ function OptionMenu({ className='mx-1.5 mb-1.5' {...(editing ? { - onPointerMove: (e) => e.preventDefault(), - onPointerEnter: (e) => e.preventDefault(), - onPointerLeave: (e) => e.preventDefault(), - } + onPointerMove: (e) => e.preventDefault(), + onPointerEnter: (e) => e.preventDefault(), + onPointerLeave: (e) => e.preventDefault(), + } : undefined)} > diff --git a/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx b/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx index 8b97f4de..bd23aef7 100644 --- a/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx +++ b/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx @@ -4,16 +4,15 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_right.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { Origins, Popover } from '@/components/_shared/popover'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { CalloutNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ColorEnum, renderColor, toBlockColor, toTint } from '@/utils/color'; -import { isOfficialHost } from '@/utils/subscription'; const origins: Origins = { anchorOrigin: { @@ -46,31 +45,7 @@ function CalloutTextColor({ node, onSelectColor }: { node: CalloutNode; onSelect const [originalColor, setOriginalColor] = useState(node.data?.textColor || ''); const selectedColor = originalColor || ColorEnum.BlockTextColor10; - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const builtinColors = useMemo(() => { const proPalette = [ diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx index b2006bd3..4c519f46 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx @@ -5,17 +5,16 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as ColorSvg } from '@/assets/icons/text_highlight.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { CustomColorPicker } from '@/components/_shared/color-picker/CustomColorPicker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { renderColor } from '@/utils/color'; -import { isOfficialHost } from '@/utils/subscription'; import ActionButton from './ActionButton'; import { CreateCustomColorTile } from './TextColor'; @@ -44,29 +43,9 @@ function BgColor({ const recentColorToSave = useRef(null); const initialColor = useRef(null); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + const { isPro } = useSubscriptionPlan(getSubscriptions); const maxCustomColors = isPro ? 9 : 4; - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - const isCustomColor = useCallback((color: string) => { return color.startsWith('#') || color.startsWith('0x'); }, []); @@ -103,10 +82,6 @@ function BgColor({ } }, [isOpen, visible]); - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); - const getRawColorValue = useCallback( (color: string) => { if (isCustomColor(color)) { diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx index 79431e82..53f9424b 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx @@ -5,18 +5,17 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as ColorSvg } from '@/assets/icons/text_color.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { CustomColorPicker } from '@/components/_shared/color-picker/CustomColorPicker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { renderColor } from '@/utils/color'; -import { isOfficialHost } from '@/utils/subscription'; import ActionButton from './ActionButton'; @@ -44,29 +43,9 @@ function TextColor({ const recentColorToSave = useRef(null); const initialColor = useRef(null); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + const { isPro } = useSubscriptionPlan(getSubscriptions); const maxCustomColors = isPro ? 9 : 4; - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - const isCustomColor = useCallback((color: string) => { return color.startsWith('#') || color.startsWith('0x'); }, []); @@ -103,10 +82,6 @@ function TextColor({ } }, [isOpen, visible]); - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); - const getRawColorValue = useCallback( (color: string) => { if (isCustomColor(color)) { diff --git a/src/components/view-meta/CoverPopover.tsx b/src/components/view-meta/CoverPopover.tsx index 090d43df..808830a8 100644 --- a/src/components/view-meta/CoverPopover.tsx +++ b/src/components/view-meta/CoverPopover.tsx @@ -1,11 +1,11 @@ -import { CoverType, SubscriptionPlan, ViewMetaCover } from '@/application/types'; +import { CoverType, ViewMetaCover } from '@/application/types'; +import { EmbedLink, TAB_KEY, TabOption, Unsplash, UploadImage, UploadPopover } from '@/components/_shared/image-upload'; import { useAppHandlers, useAppViewId, useOpenModalViewId } from '@/components/app/app.hooks'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { EmbedLink, Unsplash, UploadPopover, TabOption, TAB_KEY, UploadImage } from '@/components/_shared/image-upload'; -import { useTranslation } from 'react-i18next'; -import { isOfficialHost } from '@/utils/subscription'; -import Colors from './CoverColors'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { GradientEnum } from '@/utils/color'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import Colors from './CoverColors'; function CoverPopover({ coverValue, @@ -26,31 +26,7 @@ function CoverPopover({ const modalViewId = useOpenModalViewId(); const viewId = modalViewId || appViewId; - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const tabOptions: TabOption[] = useMemo(() => { return [