diff --git a/.env.example b/.env.example index bce96e06..cb914ded 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ ASSEMBLY_API_KEY= TASKS_ASSEMBLY_API_KEY= TASKS_APP_ID= # Remains unchanged for dev +FORMS_APP_ID= +CONTRACTS_APP_ID= +INVOICES_APP_ID= COPILOT_DEBUG=true BLOB_READ_WRITE_TOKEN= # Blob rw token for old client home's vercel blob store diff --git a/src/app/api/workspace/app-display-names/route.ts b/src/app/api/workspace/app-display-names/route.ts new file mode 100644 index 00000000..08958b52 --- /dev/null +++ b/src/app/api/workspace/app-display-names/route.ts @@ -0,0 +1,14 @@ +import AssemblyClient from '@assembly/assembly-client' +import { authenticateHeaders } from '@auth/lib/authenticate' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import type { APIResponse } from '@/app/types' +import { withErrorHandler } from '@/lib/with-error-handler' + +export const GET = withErrorHandler(async (req: NextRequest): Promise> => { + const user = authenticateHeaders(req.headers) + + const assembly = new AssemblyClient(z.string().parse(user.token)) + const displayNames = await assembly.getAppDisplayNames() + return NextResponse.json({ data: displayNames }) +}) diff --git a/src/app/routes.ts b/src/app/routes.ts index e0557b12..f01c49a8 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = Object.freeze({ settings: '/api/settings', workspace: '/api/workspace', tasksAppId: '/api/workspace/tasks-app-id', + appDisplayNames: '/api/workspace/app-display-names', media: '/api/media', image: '/api/media/image', upload: '/api/media/upload', @@ -53,6 +54,7 @@ export const authorizedRoutes: Record = { ROUTES.api.segment, ROUTES.api.segmentConfig, ROUTES.api.segmentStats, + ROUTES.api.appDisplayNames, ], clientUsers: [ ROUTES.api.workspace, @@ -60,6 +62,7 @@ export const authorizedRoutes: Record = { ROUTES.client, ROUTES.api.listCustomFields, ROUTES.api.tasksAppId, + ROUTES.api.appDisplayNames, { path: ROUTES.api.settings, methods: ['GET'], diff --git a/src/config/env.ts b/src/config/env.ts index b6d73413..cb4af83d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -7,6 +7,9 @@ const EnvSchema = z.object({ TASKS_ASSEMBLY_API_KEY: z.string().min(1), TASKS_APP_ID: z.uuid(), + FORMS_APP_ID: z.string().min(1), + CONTRACTS_APP_ID: z.string().min(1), + INVOICES_APP_ID: z.string().min(1), VERCEL_ENV: z.enum(['production', 'preview', 'development']).optional(), // NOTE: We can add other custom environments here VERCEL_URL: z.url(), diff --git a/src/features/action-items/components/action-item.tsx b/src/features/action-items/components/action-item.tsx index 3f2ad8e9..85174b73 100644 --- a/src/features/action-items/components/action-item.tsx +++ b/src/features/action-items/components/action-item.tsx @@ -17,6 +17,10 @@ interface ActionItemProps { export const ActionItem = ({ action, isLoading, mode, className, count }: ActionItemProps) => { const clientId = useAuthStore((s) => s.clientId) const tasksAppId = useViewStore((s) => s.tasksAppId) + const appDisplayNames = useViewStore((s) => s.appDisplayNames) + + const displayLabel = appDisplayNames[action.key] ?? action.label + const displaySingularLabel = appDisplayNames[action.key] ?? action.singularLabel const handleClick = () => { if (!clientId) return @@ -60,7 +64,7 @@ export const ActionItem = ({ action, isLoading, mode, className, count }: Action icon={action.icon} className="size-4 transition-transform duration-300 group-hover:scale-110 dark-bg:text-white" /> -

{action.label}

+

{displayLabel}

{mode === ViewMode.PREVIEW - ? ` ${count === 1 ? action.singularLabel?.toLocaleLowerCase() : action.label.toLocaleLowerCase()}` + ? ` ${count === 1 ? displaySingularLabel?.toLocaleLowerCase() : displayLabel.toLocaleLowerCase()}` : null} diff --git a/src/features/action-items/components/actions-card.tsx b/src/features/action-items/components/actions-card.tsx index c6034587..87b6d5a0 100644 --- a/src/features/action-items/components/actions-card.tsx +++ b/src/features/action-items/components/actions-card.tsx @@ -2,6 +2,7 @@ import { useViewStore, ViewMode } from '@editor/stores/viewStore' import { useNotificationCounts } from '@notification-counts/hooks/useNotificationCounts' import type { NotificationCountsDto } from '@notification-counts/notification-counts.dto' import { useEnabledActions } from '@settings/hooks/useEnabledActions' +import { useAppDisplayNames } from '@/features/action-items/hooks/useAppDisplayNames' import { HandleBarTemplate } from '@/features/handlebar-template/components/handle-bar-template' import { cn } from '@/utils/tailwind' import { ActionItem } from './action-item' @@ -14,7 +15,10 @@ export const ActionsCard = ({ readonly }: ActionCardProps) => { const { enabledActions } = useEnabledActions() const viewMode = useViewStore((store) => store.viewMode) const workspace = useViewStore((store) => store.workspace) - const { counts, isLoading } = useNotificationCounts() + const { counts, isLoading: isCountsLoading } = useNotificationCounts() + const { isLoading: isDisplayNamesLoading } = useAppDisplayNames() + + const isLoading = isCountsLoading || isDisplayNamesLoading const isPreviewMode = readonly || viewMode === ViewMode.PREVIEW diff --git a/src/features/action-items/hooks/useAppDisplayNames.ts b/src/features/action-items/hooks/useAppDisplayNames.ts new file mode 100644 index 00000000..329e1b14 --- /dev/null +++ b/src/features/action-items/hooks/useAppDisplayNames.ts @@ -0,0 +1,20 @@ +import { useViewStore } from '@editor/stores/viewStore' +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/core/axios.instance' + +export const useAppDisplayNames = () => { + const setAppDisplayNames = useViewStore((store) => store.setAppDisplayNames) + + return useQuery({ + queryKey: ['app-display-names'], + queryFn: async (): Promise> => { + const res = await api.get<{ data: Record }>('/api/workspace/app-display-names') + setAppDisplayNames(res.data.data) + return res.data.data + }, + refetchInterval: 0, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) +} diff --git a/src/features/editor/components/ClientEditorWrapper.tsx b/src/features/editor/components/ClientEditorWrapper.tsx index 45034a83..0031adb9 100644 --- a/src/features/editor/components/ClientEditorWrapper.tsx +++ b/src/features/editor/components/ClientEditorWrapper.tsx @@ -5,6 +5,7 @@ import { useViewStore } from '@editor/stores/viewStore' import { useSettingsStore } from '@settings/providers/settings.provider' import { useQuery } from '@tanstack/react-query' import { ActionsCard } from '@/features/action-items/components/actions-card' +import { useAppDisplayNames } from '@/features/action-items/hooks/useAppDisplayNames' import { Banner } from '@/features/banner' import { getImageUrl } from '@/features/banner/lib/utils' import { api } from '@/lib/core/axios.instance' @@ -36,6 +37,8 @@ export const ClientEditorWrapper = () => { refetchOnWindowFocus: false, }) + useAppDisplayNames() + const isDark = isDarkColor(backgroundColor) return ( diff --git a/src/features/editor/components/EditorWrapper.tsx b/src/features/editor/components/EditorWrapper.tsx index 1aa36e60..7cfc6165 100644 --- a/src/features/editor/components/EditorWrapper.tsx +++ b/src/features/editor/components/EditorWrapper.tsx @@ -10,6 +10,7 @@ import { useSegmentSettings } from '@settings/hooks/useSegmentSettings' import { useSettingsStore } from '@settings/providers/settings.provider' import { Activity } from 'react' import { ActionsCard } from '@/features/action-items/components/actions-card' +import { useAppDisplayNames } from '@/features/action-items/hooks/useAppDisplayNames' import { Banner } from '@/features/banner' import { getImageUrl } from '@/features/banner/lib/utils' import { useSidebarStore } from '@/features/editor/stores/sidebarStore' @@ -43,6 +44,7 @@ export function EditorWrapper({ className }: EditorWrapperProps) { useSegmentSettings() useAppControls() + useAppDisplayNames() return (
{ const actions = useSettingsStore((s) => s.actions) const setActions = useSettingsStore((s) => s.setActions) + const appDisplayNames = useViewStore((s) => s.appDisplayNames) const order = actions?.order ?? [] @@ -16,7 +18,7 @@ export const useActions = () => { .map((item) => { return { key: item.key, - label: item.label, + label: appDisplayNames[item.key] ?? item.label, icon: item.icon, checked: actions?.[item.key] ?? false, onChange: () => { diff --git a/src/features/editor/stores/viewStore.ts b/src/features/editor/stores/viewStore.ts index 75314f10..bc0f78c9 100644 --- a/src/features/editor/stores/viewStore.ts +++ b/src/features/editor/stores/viewStore.ts @@ -16,6 +16,7 @@ interface ViewStoreState { displayMode: DisplayMode workspace: WorkspaceResponse | null tasksAppId: string | null + appDisplayNames: Record activeSegmentId: string | null } @@ -24,6 +25,7 @@ interface ViewStoreAction { changeView: (data: Partial) => void setWorkspace: (workspace: WorkspaceResponse) => void setTasksAppId: (id: string) => void + setAppDisplayNames: (names: Record) => void setActiveSegmentId: (segmentId: string | null) => void } @@ -32,6 +34,7 @@ const defaultState = { displayMode: DisplayMode.DESKTOP, workspace: null, tasksAppId: null, + appDisplayNames: {}, activeSegmentId: null, } as const satisfies Partial @@ -42,6 +45,7 @@ export const useViewStore = create()((set) => ({ changeView: (data: Partial) => set(data), setWorkspace: (workspace: WorkspaceResponse) => set({ workspace }), setTasksAppId: (tasksAppId: string | null) => set({ tasksAppId }), + setAppDisplayNames: (appDisplayNames: Record) => set({ appDisplayNames }), setActiveSegmentId: (activeSegmentId: string | null) => set({ activeSegmentId }), reset: () => set(defaultState), })) diff --git a/src/lib/assembly/assembly-client.ts b/src/lib/assembly/assembly-client.ts index 1f7f2a76..fad64111 100644 --- a/src/lib/assembly/assembly-client.ts +++ b/src/lib/assembly/assembly-client.ts @@ -82,6 +82,28 @@ export default class AssemblyClient { return installedApps.find((app) => app.appId === appDeploymentId)?.id || null } + async _getAppDisplayNames(): Promise> { + const assembly = await this.assemblyPromise + const installedApps = AppInstallsResponseSchema.parse(await assembly.listAppInstalls()) + + const appDeploymentIds = { + forms: env.FORMS_APP_ID, + contracts: env.CONTRACTS_APP_ID, + invoices: env.INVOICES_APP_ID, + tasks: env.TASKS_APP_ID, + } + + const displayNames: Record = {} + for (const [key, deploymentId] of Object.entries(appDeploymentIds)) { + const app = installedApps.find((a) => a.appId === deploymentId) + if (app?.displayName) { + displayNames[key] = app.displayName + } + } + + return displayNames + } + async _createClient(requestBody: ClientCreateRequest, sendInvite: boolean = false): Promise { logger.info('AssemblyClient#_createClient', requestBody, sendInvite) const assembly = await this.assemblyPromise @@ -240,4 +262,5 @@ export default class AssemblyClient { listCustomFields = this.wrapWithRetry(this._listCustomFields) listCustomFieldOptions = this.wrapWithRetry(this._listCustomFieldOptions) getAppId = this.wrapWithRetry(this._getAppId) + getAppDisplayNames = this.wrapWithRetry(this._getAppDisplayNames) }