refactor: extract subscription plan logic into reusable hook

This commit is contained in:
Nathan
2025-11-16 14:51:45 +08:00
parent d7e622f700
commit db52cdade4
6 changed files with 89 additions and 145 deletions

View File

@@ -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<Subscription[] | undefined>
): {
activeSubscriptionPlan: SubscriptionPlan | null;
isPro: boolean;
} {
const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState<SubscriptionPlan | null>(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,
};
}

View File

@@ -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<SubscriptionPlan | null>(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 = [

View File

@@ -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<string>(node.data?.textColor || '');
const selectedColor = originalColor || ColorEnum.BlockTextColor10;
const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState<SubscriptionPlan | null>(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 = [

View File

@@ -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<string | null>(null);
const initialColor = useRef<string | null>(null);
const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState<SubscriptionPlan | null>(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)) {

View File

@@ -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<string | null>(null);
const initialColor = useRef<string | null>(null);
const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState<SubscriptionPlan | null>(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)) {

View File

@@ -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<SubscriptionPlan | null>(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 [