From 2e7ec428d2a2ed6625a4332d18f9b8868156c856 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 26 Oct 2025 13:37:02 -0600 Subject: [PATCH 1/2] add ui/ux for trial features --- src/api/ebbApi/licenseApi.ts | 9 ++ src/components/AppSelector.tsx | 82 +++++++++--------- src/components/ProFeatureOverlay.tsx | 28 ++++++ src/components/SlackFocusToggle.tsx | 23 ++++- src/pages/FocusSchedulePage.tsx | 13 ++- .../Integrations/SlackSettings.tsx | 86 +++++++++++-------- .../StartFlowPage/SmartFocusSelector.tsx | 38 +++++--- 7 files changed, 186 insertions(+), 93 deletions(-) create mode 100644 src/components/ProFeatureOverlay.tsx diff --git a/src/api/ebbApi/licenseApi.ts b/src/api/ebbApi/licenseApi.ts index c772f9d6..7116aadb 100644 --- a/src/api/ebbApi/licenseApi.ts +++ b/src/api/ebbApi/licenseApi.ts @@ -38,6 +38,9 @@ export interface LicensePermissions { canUseAllowList: boolean canUseMultipleProfiles: boolean canUseMultipleDevices: boolean + canUseSmartFocus: boolean + canUseSlackIntegration: boolean + canScheduleSessions: boolean hasProAccess: boolean } @@ -52,6 +55,9 @@ export const defaultPermissions: LicensePermissions = { canUseAllowList: false, canUseMultipleProfiles: false, canUseMultipleDevices: false, + canUseSmartFocus: false, + canUseSlackIntegration: false, + canScheduleSessions: false, hasProAccess: false, } @@ -84,6 +90,9 @@ const calculatePermissions = (license: License | null): LicensePermissions | nul canUseAllowList: hasProAccess, canUseMultipleProfiles: hasProAccess, canUseMultipleDevices: hasProAccess, + canUseSmartFocus: hasProAccess, + canUseSlackIntegration: hasProAccess, + canScheduleSessions: hasProAccess, hasProAccess, } } diff --git a/src/components/AppSelector.tsx b/src/components/AppSelector.tsx index 711fd60c..1370643b 100644 --- a/src/components/AppSelector.tsx +++ b/src/components/AppSelector.tsx @@ -11,6 +11,7 @@ import { DifficultySelector } from '@/components/difficulty-selector' import { CategoryTooltip } from '@/components/CategoryTooltip' import { usePermissions } from '@/hooks/usePermissions' import { usePaywall } from '@/hooks/usePaywall' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' interface CategoryOption { type: 'category' @@ -86,7 +87,7 @@ const getOptionDetails = (option: SearchOption) => { } export function AppSelector({ - placeholder = 'Search apps & websites...', + placeholder, emptyText = 'Enter full URL to add website', maxItems = 5, selectedApps, @@ -117,6 +118,12 @@ export function AppSelector({ const [selected, setSelected] = useState(0) const [apps, setApps] = useState([]) const [categoryOptions, setCategoryOptions] = useState([]) + + // Dynamic placeholder based on allowlist/blocklist mode + const dynamicPlaceholder = placeholder || (isAllowList + ? 'Search apps & websites to allow...' + : 'Search apps & websites to block...') + const handleClose = () => { setOpen(false) setSearch('') @@ -487,7 +494,7 @@ export function AppSelector({
setSearch(e.target.value)} onFocus={() => setOpen(true)} @@ -535,44 +542,39 @@ export function AppSelector({ Block - {!canUseAllowList ? ( - - Allow - - ) : ( - { - e.stopPropagation() - handleAllowListChange(true) - }} - analyticsEvent="allow_list_clicked" - analyticsProperties={{ - context: 'allow_list', - button_location: 'app_selector' - }} - > - Allow - - )} + + + { + e.stopPropagation() + if (!canUseAllowList) { + openPaywall() + } else { + handleAllowListChange(true) + } + }} + > + Allow + + + {!canUseAllowList && ( + + Allow List (Pro) + + )} +
)} diff --git a/src/components/ProFeatureOverlay.tsx b/src/components/ProFeatureOverlay.tsx new file mode 100644 index 00000000..60cecb55 --- /dev/null +++ b/src/components/ProFeatureOverlay.tsx @@ -0,0 +1,28 @@ +import { RainbowButton } from '@/components/ui/rainbow-button' +import { usePaywall } from '@/hooks/usePaywall' + +interface ProFeatureOverlayProps { + title: string + subtitle: string + ctaText?: string +} + +export function ProFeatureOverlay({ + title, + subtitle, + ctaText = 'Upgrade to Pro' +}: ProFeatureOverlayProps) { + const { openPaywall } = usePaywall() + + return ( +
+
+

{title}

+

{subtitle}

+ + {ctaText} + +
+
+ ) +} diff --git a/src/components/SlackFocusToggle.tsx b/src/components/SlackFocusToggle.tsx index 0ad173fe..780e49c7 100644 --- a/src/components/SlackFocusToggle.tsx +++ b/src/components/SlackFocusToggle.tsx @@ -19,6 +19,8 @@ import { SlackSettings } from '@/api/ebbApi/workflowApi' import { useAuth } from '@/hooks/useAuth' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' +import { usePermissions } from '@/hooks/usePermissions' +import { usePaywall } from '@/hooks/usePaywall' interface SlackFocusToggleProps { slackSettings: SlackSettings @@ -28,6 +30,8 @@ interface SlackFocusToggleProps { export function SlackFocusToggle({ slackSettings, onSlackSettingsChange }: SlackFocusToggleProps) { const { user } = useAuth() const navigate = useNavigate() + const { canUseSlackIntegration } = usePermissions() + const { openPaywall } = usePaywall() const [showDialog, setShowDialog] = useState(false) const [customStatusText, setCustomStatusText] = useState('') @@ -44,6 +48,12 @@ export function SlackFocusToggle({ slackSettings, onSlackSettingsChange }: Slack }, [showDialog, slackStatus?.preferences]) const handleSlackToggle = async () => { + // Check permissions first + if (!canUseSlackIntegration) { + openPaywall() + return + } + if (!user) { toast.error('Must be logged in to use Slack', { action: { @@ -104,6 +114,9 @@ export function SlackFocusToggle({ slackSettings, onSlackSettingsChange }: Slack } const getTooltipText = () => { + if (!canUseSlackIntegration) { + return 'Slack Integration (Pro)' + } if (!user) { return 'Please login to use the slack integration' } @@ -124,13 +137,15 @@ export function SlackFocusToggle({ slackSettings, onSlackSettingsChange }: Slack ) : ( -
{isActive && ( diff --git a/src/pages/FocusSchedulePage.tsx b/src/pages/FocusSchedulePage.tsx index 299dfbeb..5192407b 100644 --- a/src/pages/FocusSchedulePage.tsx +++ b/src/pages/FocusSchedulePage.tsx @@ -8,15 +8,18 @@ import { FocusScheduleApi } from '@/api/ebbApi/focusScheduleApi' import { ScheduleSessionModal } from '@/components/ScheduleSessionModal' import { Calendar, Clock, Trash2 } from 'lucide-react' import { error as errorLog } from '@tauri-apps/plugin-log' +import { usePermissions } from '@/hooks/usePermissions' +import { ProFeatureOverlay } from '@/components/ProFeatureOverlay' export default function FocusSchedulePage() { const [showScheduleModal, setShowScheduleModal] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [editingScheduleId, setEditingScheduleId] = useState() const [deletingScheduleId, setDeletingScheduleId] = useState() - + const { data: schedules, isLoading } = useFocusSchedulesWithWorkflow() const { mutateAsync: deleteFocusSchedule } = useDeleteFocusSchedule() + const { canScheduleSessions } = usePermissions() const handleEditSchedule = (scheduleId: string) => { setEditingScheduleId(scheduleId) @@ -66,7 +69,13 @@ export default function FocusSchedulePage() { return ( -
+
+ {!canScheduleSessions && ( + + )}
{ schedules && schedules.length > 0 && (
diff --git a/src/pages/SettingsPage/Integrations/SlackSettings.tsx b/src/pages/SettingsPage/Integrations/SlackSettings.tsx index a3e8891a..3058dcb0 100644 --- a/src/pages/SettingsPage/Integrations/SlackSettings.tsx +++ b/src/pages/SettingsPage/Integrations/SlackSettings.tsx @@ -3,21 +3,35 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { SlackIcon } from '@/components/icons/SlackIcon' import { AnalyticsButton } from '@/components/ui/analytics-button' import { SlackDisconnectModal } from '@/components/SlackDisconnectModal' +import { Badge } from '@/components/ui/badge' +import { KeyRound } from 'lucide-react' import { useSlackStatus } from '@/api/hooks/useSlack' import { initiateSlackOAuth } from '@/lib/utils/slackAuth.util' import { useAuth } from '@/hooks/useAuth' +import { usePermissions } from '@/hooks/usePermissions' +import { usePaywall } from '@/hooks/usePaywall' export const SlackSettings = () => { const { user } = useAuth() const { data: slackStatus, refetch } = useSlackStatus() + const { canUseSlackIntegration } = usePermissions() + const { openPaywall } = usePaywall() const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false) const handleConnect = async () => { + if (!canUseSlackIntegration) { + openPaywall() + return + } await initiateSlackOAuth() } const handleDisconnect = async () => { + if (!canUseSlackIntegration) { + openPaywall() + return + } setIsDisconnectModalOpen(true) } @@ -51,23 +65,29 @@ export const SlackSettings = () => {
Slack
- - -
-
-
- {getSlackWorkspaceMessage()} -
-
- - - {slackStatus?.workspaces?.length || 0 > 0 ? slackStatus?.workspaces.map((workspace) => ( -
- {workspace.team_name} + + + Pro + + {slackStatus?.workspaces?.length || 0 > 0 ? ( + + +
+
+
+ {getSlackWorkspaceMessage()} +
- )) : 'Press "Connect" to begin adding your Slack workspaces'} - - + + + {slackStatus?.workspaces.map((workspace) => ( +
+ {workspace.team_name} +
+ ))} +
+ + ) : null}
@@ -77,35 +97,27 @@ export const SlackSettings = () => { variant="outline" size="sm" onClick={handleDisconnect} - disabled={!slackStatus?.workspaces?.length} analyticsEvent="slack_configure_clicked" analyticsProperties={{ - context: 'slack_configure', + context: canUseSlackIntegration ? 'slack_configure' : 'slack_configure_locked', button_location: 'slack_settings' }} > Configure ) : ( - - - - - Connect - - - - + + Connect + )}
diff --git a/src/pages/StartFlowPage/SmartFocusSelector.tsx b/src/pages/StartFlowPage/SmartFocusSelector.tsx index 895019d5..2d15aae4 100644 --- a/src/pages/StartFlowPage/SmartFocusSelector.tsx +++ b/src/pages/StartFlowPage/SmartFocusSelector.tsx @@ -20,6 +20,8 @@ import { logAndToastError } from '@/lib/utils/ebbError.util' import { SmartFocusSettings as SmartFocusSettingsType } from '@/db/ebb/deviceProfileRepo' import { useDeviceProfile, useUpdateDeviceProfilePreferences } from '@/api/hooks/useDeviceProfile' import { Skeleton } from '@/components/ui/skeleton' +import { usePermissions } from '@/hooks/usePermissions' +import { usePaywall } from '@/hooks/usePaywall' interface SmartFocusSelectorProps { workflows: Workflow[] @@ -38,6 +40,8 @@ export function SmartFocusSelector({ workflows }: SmartFocusSelectorProps) { }) const { deviceId, deviceProfile } = useDeviceProfile() const { mutate: updateDeviceProfilePreferences } = useUpdateDeviceProfilePreferences() + const { canUseSmartFocus } = usePermissions() + const { openPaywall } = usePaywall() const durationOptions = [ { value: 10, label: '10 minutes' }, @@ -134,6 +138,14 @@ export function SmartFocusSelector({ workflows }: SmartFocusSelectorProps) { setIsSaving(false) } + const handleClick = () => { + if (!canUseSmartFocus) { + openPaywall() + return + } + setShowDialog(true) + } + return ( <>
@@ -142,26 +154,32 @@ export function SmartFocusSelector({ workflows }: SmartFocusSelectorProps) { ) : ( -
setShowDialog(true)} - className="h-9 w-9 rounded-md flex items-center justify-center hover:bg-accent cursor-pointer" +
-
- Smart Focus + + {canUseSmartFocus ? 'Smart Focus' : 'Smart Focus (Pro)'} + )}
From 6ab28ad3c2ed8b346d59a801253aee8f2458e11c Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 26 Oct 2025 14:22:17 -0600 Subject: [PATCH 2/2] adjust functionality access --- src/api/ebbApi/flowSessionApi.ts | 5 ++- src/hooks/usePermissions.ts | 13 +++++-- src/hooks/useWorkerPolling.ts | 55 +++++++++++++++++------------- src/lib/stores/permissionsStore.ts | 12 +++++++ 4 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 src/lib/stores/permissionsStore.ts diff --git a/src/api/ebbApi/flowSessionApi.ts b/src/api/ebbApi/flowSessionApi.ts index 531b55da..2cae050d 100644 --- a/src/api/ebbApi/flowSessionApi.ts +++ b/src/api/ebbApi/flowSessionApi.ts @@ -4,6 +4,7 @@ import { Workflow, WorkflowApi } from './workflowApi' import { slackApi } from './slackApi' import { logAndToastError } from '@/lib/utils/ebbError.util' import { WorkflowDb } from '../../db/ebb/workflowRepo' +import { usePermissionsStore } from '@/lib/stores/permissionsStore' export type FlowSessionWithWorkflow = FlowSessionWithWorkflowDb & { workflow_json?: Workflow @@ -13,10 +14,12 @@ const startFlowSession = async ( objective: string, type: 'smart' | 'manual', workflow?: Workflow | null, + ): Promise => { const inProgressFlowSession = await FlowSessionRepo.getInProgressFlowSession() if (inProgressFlowSession) throw new Error('Flow session already in progress') + const { permissions } = usePermissionsStore.getState() let workflowToUse = workflow if (!workflow) { @@ -37,7 +40,7 @@ const startFlowSession = async ( await FlowSessionRepo.createFlowSession(flowSession) // Handle Slack DND if enabled - if (workflowToUse.settings.slack?.dndEnabled) { + if (permissions.canUseSlackIntegration && workflowToUse.settings.slack?.dndEnabled) { const durationMinutes = workflowToUse.settings.defaultDuration || 25 slackApi.startFocusSession(durationMinutes).catch(error => { logAndToastError('Failed to enable Slack DND for all workspaces', error) diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index d9e81c04..79074708 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,11 +1,20 @@ import { useLicenseWithDevices } from '@/api/hooks/useLicense' import { useAuth } from './useAuth' import { defaultPermissions } from '@/api/ebbApi/licenseApi' +import { usePermissionsStore } from '@/lib/stores/permissionsStore' +import { useEffect } from 'react' export const usePermissions = () => { const { user } = useAuth() const { data: licenseData } = useLicenseWithDevices(user?.id || null) + const setPermissions = usePermissionsStore((state) => state.setPermissions) - return licenseData?.permissions || defaultPermissions -} + const permissions = licenseData?.permissions || defaultPermissions + + // Sync to store whenever permissions change + useEffect(() => { + setPermissions(permissions) + }, [permissions, setPermissions]) + return permissions +} diff --git a/src/hooks/useWorkerPolling.ts b/src/hooks/useWorkerPolling.ts index 2439d24a..736666d8 100644 --- a/src/hooks/useWorkerPolling.ts +++ b/src/hooks/useWorkerPolling.ts @@ -11,6 +11,7 @@ import { SmartSessionApi } from '@/api/ebbApi/smartSessionApi' import { ScheduledSessionExecutionApi } from '@/api/ebbApi/scheduledSessionExecutionApi' import { useAuth } from './useAuth' import { debug } from '@tauri-apps/plugin-log' +import { usePermissions } from './usePermissions' type OnlinePingEvent = { event: string @@ -24,6 +25,7 @@ export const useWorkerPolling = () => { const { deviceId } = useDeviceProfile() const { mutate: updateProfile } = useUpdateProfile() const { updateRollupForUser } = useUpdateRollupForUser() + const { canUseSmartFocus, canScheduleSessions } = usePermissions() useEffect(() => { if(isLoading || !profile) return @@ -45,7 +47,8 @@ export const useWorkerPolling = () => { updateProfile({ id: profile.id, last_check_in: last_check_in.toISO(), version }) refetch() } - if(deviceId) { + const handleShouldStartSmartFocus = async () => { + if(!deviceId || !canUseSmartFocus) return const shouldSuggestSmartSession = await SmartSessionApi.checkShouldSuggestSmartSession(deviceId) if(shouldSuggestSmartSession === 'smart') { invoke('show_notification', { @@ -59,30 +62,36 @@ export const useWorkerPolling = () => { } } - // Check for scheduled sessions - const scheduledSessionStatus = await ScheduledSessionExecutionApi.checkScheduledSessionStatus() - if(scheduledSessionStatus.type === 'reminder') { - const payload = { - workflowId: scheduledSessionStatus.schedule.workflowId, - workflowName: scheduledSessionStatus.schedule.workflowName, - } - invoke('show_notification', { - notificationType: 'scheduled-session-reminder', - payload: JSON.stringify(payload), - }) - } - else if(scheduledSessionStatus.type === 'start') { - invoke('show_notification', { - notificationType: 'scheduled-session-start', - payload: JSON.stringify({ + const handleShouldStartScheduledSession = async () => { + + // Check for scheduled sessions (Pro feature) + if(!canScheduleSessions) return + const scheduledSessionStatus = await ScheduledSessionExecutionApi.checkScheduledSessionStatus() + if(scheduledSessionStatus.type === 'reminder') { + const payload = { workflowId: scheduledSessionStatus.schedule.workflowId, workflowName: scheduledSessionStatus.schedule.workflowName, - }), - }) - } + } + invoke('show_notification', { + notificationType: 'scheduled-session-reminder', + payload: JSON.stringify(payload), + }) + } + else if(scheduledSessionStatus.type === 'start') { + invoke('show_notification', { + notificationType: 'scheduled-session-start', + payload: JSON.stringify({ + workflowId: scheduledSessionStatus.schedule.workflowId, + workflowName: scheduledSessionStatus.schedule.workflowName, + }), + }) + } - // Clean up old scheduled session tracking periodically - ScheduledSessionExecutionApi.cleanupOldSessionTracking() + // Clean up old scheduled session tracking periodically + ScheduledSessionExecutionApi.cleanupOldSessionTracking() + } + handleShouldStartSmartFocus() + handleShouldStartScheduledSession() } EbbWorker.work(event.payload, run) // used to make sure we don't run the same work multiple times @@ -97,6 +106,6 @@ export const useWorkerPolling = () => { debug(`[useWorkerPolling] Failed to unlisten online-ping event: ${error}`) } } - }, [profile, isLoading, updateRollupForUser, deviceId, user]) + }, [profile, isLoading, updateRollupForUser, deviceId, user, canUseSmartFocus, canScheduleSessions]) } diff --git a/src/lib/stores/permissionsStore.ts b/src/lib/stores/permissionsStore.ts new file mode 100644 index 00000000..0fe512d9 --- /dev/null +++ b/src/lib/stores/permissionsStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand' +import {defaultPermissions, LicensePermissions } from '@/api/ebbApi/licenseApi' + +interface PermissionsStore { + permissions: LicensePermissions + setPermissions: (permissions: LicensePermissions) => void +} + +export const usePermissionsStore = create((set) => ({ + permissions: defaultPermissions, + setPermissions: (permissions) => set({ permissions }), +}))