From 0a0cf9bf3f6947203fd6b1ee890f9de99593fc70 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:53:55 -0700 Subject: [PATCH 1/2] feat(app): adopt Neo design system and EvilCharts --- apps/agent/agent/lib/builder-runtime.ts | 4 +- apps/agent/agent/tools/write_brief.ts | 2 +- .../src/activities/activities.contracts.ts | 4 +- apps/api/src/backfill/backfill.service.ts | 8 +- apps/api/src/companies/companies.contracts.ts | 6 + apps/api/src/companies/companies.service.ts | 46 +- .../companies/company-directory.service.ts | 2 +- apps/api/src/contacts/contacts.service.ts | 2 +- apps/api/src/crm/bulk.ts | 2 +- apps/api/src/currency/currency.service.ts | 2 +- apps/api/src/deals/deals.service.ts | 22 +- .../src/google/google-connection.service.ts | 2 +- apps/api/src/search/search.service.ts | 11 +- apps/api/src/settings/settings.contracts.ts | 4 +- apps/api/src/tracking/tracking.service.ts | 2 +- .../test/currency-totals.integration.spec.ts | 96 + apps/api/test/error-formatter.spec.ts | 2 +- .../(agent-builder)/agents/[agentId]/page.tsx | 2 +- .../[slug]/(agent-builder)/agents/page.tsx | 10 +- .../(app)/[slug]/(agent-builder)/layout.tsx | 40 +- .../companies/companies-bulk-actions.tsx | 13 +- .../[slug]/companies/companies-table.tsx | 54 +- .../[slug]/companies/create-company-sheet.tsx | 162 +- .../companies/import-companies-link.tsx | 43 +- .../[slug]/companies/import/import-wizard.tsx | 4 +- .../(app)/[slug]/companies/import/page.tsx | 6 +- apps/app/app/(app)/[slug]/companies/page.tsx | 8 +- .../[slug]/contacts/contacts-bulk-actions.tsx | 13 +- .../(app)/[slug]/contacts/contacts-table.tsx | 58 +- .../[slug]/contacts/create-contact-sheet.tsx | 248 +- apps/app/app/(app)/[slug]/contacts/page.tsx | 8 +- .../app/(app)/[slug]/dashboard-summary.tsx | 189 +- .../(app)/[slug]/deals/create-deal-sheet.tsx | 296 ++- .../(app)/[slug]/deals/deals-bulk-actions.tsx | 9 +- .../app/(app)/[slug]/deals/deals-table.tsx | 78 +- apps/app/app/(app)/[slug]/deals/page.tsx | 8 +- apps/app/app/(app)/[slug]/layout.tsx | 54 +- .../app/(app)/[slug]/overview-greeting.tsx | 5 +- apps/app/app/(app)/[slug]/page.tsx | 6 +- apps/app/app/(app)/[slug]/sales-dashboard.tsx | 77 +- .../app/(app)/[slug]/settings/agent-model.tsx | 64 +- .../settings/api-keys/api-keys-table.tsx | 20 +- .../api-keys/create-api-key-sheet.tsx | 163 +- .../api-keys/created-api-key-dialog.tsx | 43 +- .../(app)/[slug]/settings/api-keys/page.tsx | 14 +- .../[slug]/settings/archive-retention.tsx | 125 +- .../settings/connections/connection-page.tsx | 25 +- .../connections/google-connection.tsx | 429 ++-- .../settings/connections/mcp-connection.tsx | 385 +-- .../connections/microsoft-connection.tsx | 375 +-- .../[slug]/settings/connections/page.tsx | 18 +- .../settings/connections/slack/page.tsx | 143 +- .../connections/slack/people/page.tsx | 11 +- .../connections/slack/slack-channels.tsx | 25 +- .../connections/slack/slack-scope-groups.tsx | 26 +- .../app/(app)/[slug]/settings/copy-value.tsx | 19 +- .../settings/currencies/currency-settings.tsx | 359 +-- .../(app)/[slug]/settings/currencies/page.tsx | 14 +- apps/app/app/(app)/[slug]/settings/layout.tsx | 12 +- .../[slug]/settings/members/members-table.tsx | 16 +- .../(app)/[slug]/settings/members/page.tsx | 10 +- apps/app/app/(app)/[slug]/settings/page.tsx | 15 +- .../(app)/[slug]/settings/research-key.tsx | 135 +- .../[slug]/settings/settings-sidebar.tsx | 148 -- .../settings/sso/add-sso-provider-sheet.tsx | 299 ++- .../app/(app)/[slug]/settings/sso/page.tsx | 10 +- .../(app)/[slug]/settings/sso/sso-table.tsx | 26 +- .../settings/tracking/allowed-domains.tsx | 40 +- .../(app)/[slug]/settings/tracking/page.tsx | 15 +- .../settings/tracking/tracking-cookies.tsx | 116 +- .../settings/tracking/tracking-rules.tsx | 66 +- .../settings/tracking/tracking-script.tsx | 193 +- .../settings/tracking/traffic-sources.tsx | 30 +- .../settings/tracking/verify-installation.tsx | 79 +- .../(app)/[slug]/settings/workspace-form.tsx | 162 +- apps/app/app/(app)/[slug]/tasks/page.tsx | 8 +- .../app/(app)/[slug]/tasks/tasks-table.tsx | 28 +- .../(landing)/grant-access/grant-access.tsx | 14 +- apps/app/app/(landing)/grant-access/page.tsx | 6 +- .../(landing)/onboarding/onboarding-form.tsx | 88 +- .../onboarding/research/research-form.tsx | 38 +- apps/app/app/(landing)/page.tsx | 2 +- apps/app/app/(landing)/sign-in/page.tsx | 6 +- .../app/(landing)/sign-in/social-sign-in.tsx | 10 +- .../app/app/(landing)/sign-in/sso-sign-in.tsx | 24 +- apps/app/app/layout.tsx | 9 +- apps/app/components/account-menu.tsx | 42 + .../agent-builder/agent-builder-chat.tsx | 87 +- .../agent-builder/agent-builder-home.tsx | 31 +- .../agent-builder/agent-builder-shell.tsx | 38 +- .../agent-builder/agent-builder-sidebar.tsx | 272 +-- .../agent-builder/agent-capabilities.tsx | 15 +- .../agent-builder/agent-code-workspace.tsx | 5 +- .../agent-builder/agent-composer.tsx | 61 +- .../agent-builder/agent-history.tsx | 54 +- .../components/agent-builder/chat-chips.tsx | 33 +- .../agent-builder/deal-list-result.tsx | 20 +- .../agent-builder/delete-chat-action.tsx | 10 +- .../agent-builder/new-agent-dialog.tsx | 4 +- .../agent-builder/share-chat-dialog.tsx | 7 +- .../agent-builder/team-agent-detail.tsx | 44 +- .../agent-builder/team-agents-index.tsx | 6 +- apps/app/components/app-header.tsx | 161 -- apps/app/components/app-icon-rail.tsx | 310 --- apps/app/components/app-toaster.tsx | 18 + apps/app/components/auth-shell.tsx | 2 +- .../components/crm/agent-conversations.tsx | 16 +- apps/app/components/crm/agent-panel.tsx | 232 +- apps/app/components/crm/bulk-actions.tsx | 11 +- apps/app/components/crm/company-picker.tsx | 8 +- .../app/components/crm/enrichment-actions.tsx | 15 +- apps/app/components/crm/facts.tsx | 8 +- .../components/crm/fields/field-columns.tsx | 4 +- .../components/crm/fields/field-editor.tsx | 19 +- apps/app/components/crm/fields/fields-copy.ts | 2 +- .../app/components/crm/fields/fields-list.tsx | 84 +- .../components/crm/fields/fields-sheet.tsx | 28 +- .../components/crm/fields/record-fields.tsx | 4 +- apps/app/components/crm/inline-field.tsx | 10 +- apps/app/components/crm/owner-cell.tsx | 13 +- apps/app/components/crm/quick-switcher.tsx | 472 +++- .../crm/record-sheet/company-sheet.tsx | 554 ++--- .../crm/record-sheet/contact-sheet.tsx | 380 +-- .../crm/record-sheet/deal-sheet.tsx | 482 ++-- .../components/crm/record-sheet/quick-add.tsx | 11 +- .../crm/record-sheet/record-actions.tsx | 18 +- .../crm/record-sheet/record-navigation.ts | 116 + .../crm/record-sheet/record-parts.tsx | 130 +- .../crm/record-sheet/record-sheet-host.tsx | 52 +- .../crm/record-sheet/record-stack.ts | 67 +- apps/app/components/crm/social-links.tsx | 4 +- apps/app/components/crm/stage-change.tsx | 6 +- apps/app/components/crm/stage-stepper.tsx | 43 +- .../crm/timeline/activity-composer.tsx | 4 +- .../crm/timeline/due-date-button.tsx | 4 +- .../components/crm/timeline/meeting-entry.tsx | 7 +- .../crm/timeline/timeline-entry.tsx | 8 +- apps/app/components/crm/timeline/timeline.tsx | 50 +- apps/app/components/crm/website-activity.tsx | 32 +- apps/app/components/dashboard-charts.tsx | 32 +- apps/app/components/data-table/list-empty.tsx | 39 + .../app/components/data-table/list-search.tsx | 62 +- .../data-table/saved-views-menu.tsx | 7 +- apps/app/components/detail-sheet.tsx | 194 +- apps/app/components/enrichment-queue.tsx | 10 +- apps/app/components/landing/agent-section.tsx | 31 +- .../landing/capabilities-section.tsx | 9 +- apps/app/components/landing/hero.tsx | 2 +- .../landing/product-shot/companies-page.tsx | 34 +- .../landing/product-shot/company-sheet.tsx | 29 +- .../landing/product-shot/product-shot.tsx | 22 +- .../landing/setup-prompt-button.tsx | 9 +- apps/app/components/local-date-time.tsx | 4 +- apps/app/components/mobile-nav.tsx | 11 + apps/app/components/page-shell.tsx | 171 -- apps/app/components/page-transition.tsx | 29 - apps/app/components/slack/channel-picker.tsx | 32 +- apps/app/components/theme-provider.tsx | 2 +- apps/app/components/workspace-nav.tsx | 254 ++ apps/app/components/workspace-shell.tsx | 104 + apps/app/lib/activity-presentation.ts | 36 +- apps/app/lib/agent-record.ts | 2 +- apps/app/lib/command-search-config.ts | 9 + apps/app/lib/conversation-cursor.test.ts | 51 + apps/app/lib/conversation-cursor.ts | 21 + apps/app/lib/deal-stage.ts | 12 +- apps/app/lib/enrichment-queue.ts | 2 +- apps/app/lib/recent-records.ts | 166 ++ apps/app/lib/social-links.ts | 29 +- apps/app/package.json | 2 +- apps/app/test/agent-session.spec.ts | 5 +- apps/app/test/day.spec.ts | 2 +- apps/app/test/recent-records.spec.ts | 161 ++ apps/app/test/record-navigation.spec.ts | 124 + biome.jsonc | 33 +- bun.lock | 33 +- docs/agent-panel.md | 5 +- docs/connections.md | 41 +- docs/design.md | 108 +- packages/db/prisma/seed.ts | 4 +- packages/ui/components.json | 5 +- packages/ui/package.json | 5 +- packages/ui/src/components/accordion.tsx | 10 +- packages/ui/src/components/agent-presence.tsx | 59 + packages/ui/src/components/alert-dialog.tsx | 15 +- .../ui/src/components/anchored-tooltip.tsx | 42 + packages/ui/src/components/app-shell.tsx | 247 ++ packages/ui/src/components/app-sidebar.tsx | 207 ++ packages/ui/src/components/async-action.tsx | 17 +- packages/ui/src/components/auth-button.tsx | 47 + packages/ui/src/components/auth-field.tsx | 106 + .../ui/src/components/auth-input-group.tsx | 102 + packages/ui/src/components/auth-input.tsx | 22 + packages/ui/src/components/badge.tsx | 17 +- .../src/components/brand-logos/linkedin.tsx | 17 + packages/ui/src/components/brand-logos/x.tsx | 17 + packages/ui/src/components/button.tsx | 37 +- packages/ui/src/components/calendar.tsx | 421 ++-- packages/ui/src/components/card-table.tsx | 2 +- packages/ui/src/components/card.tsx | 19 +- packages/ui/src/components/chart-swatch.tsx | 33 + packages/ui/src/components/chat-composer.tsx | 52 +- packages/ui/src/components/chat-thread.tsx | 41 +- packages/ui/src/components/checkbox.tsx | 2 +- packages/ui/src/components/combobox.tsx | 2 +- packages/ui/src/components/command-search.tsx | 471 ++++ packages/ui/src/components/command.tsx | 48 +- .../ui/src/components/dashboard-chart.tsx | 1025 ++++---- packages/ui/src/components/dashboard.tsx | 37 +- packages/ui/src/components/data-table.tsx | 1211 ++++++---- packages/ui/src/components/date-picker.tsx | 4 +- packages/ui/src/components/dialog.tsx | 14 +- .../ui/src/components/discard-changes.tsx | 99 + packages/ui/src/components/drawer.tsx | 14 +- packages/ui/src/components/dropdown-menu.tsx | 18 +- packages/ui/src/components/empty-cell.tsx | 2 +- packages/ui/src/components/empty.tsx | 15 +- packages/ui/src/components/evilcharts/LICENSE | 21 + .../ui/src/components/evilcharts/REGISTRY.md | 24 + .../evilcharts/charts/echarts-area-chart.tsx | 2076 +++++++++++++++++ .../evilcharts/charts/echarts-bar-chart.tsx | 1877 +++++++++++++++ .../evilcharts/charts/echarts-pie-chart.tsx | 1075 +++++++++ .../evilcharts/ui/echarts-brush.tsx | 213 ++ .../evilcharts/ui/echarts-chart.tsx | 185 ++ .../evilcharts/ui/echarts-config.ts | 74 + .../components/evilcharts/ui/echarts-dot.tsx | 70 + .../evilcharts/ui/echarts-legend.tsx | 136 ++ .../evilcharts/ui/echarts-lifecycle.tsx | 197 ++ .../evilcharts/ui/echarts-options.ts | 64 + .../evilcharts/ui/echarts-tooltip.tsx | 150 ++ packages/ui/src/components/field.tsx | 20 +- packages/ui/src/components/form-sheet.tsx | 206 ++ .../ui/src/components/generated-avatar.tsx | 98 + packages/ui/src/components/icon.tsx | 119 +- packages/ui/src/components/ink-field.tsx | 108 + packages/ui/src/components/input-group.tsx | 25 +- packages/ui/src/components/input.tsx | 2 +- packages/ui/src/components/kbd.tsx | 41 + packages/ui/src/components/label.tsx | 2 +- packages/ui/src/components/logo-spinner.tsx | 54 + packages/ui/src/components/meta-row.tsx | 17 + packages/ui/src/components/option-label.tsx | 26 + .../ui/src/components/overflow-tooltip.tsx | 90 + packages/ui/src/components/page-section.tsx | 52 + packages/ui/src/components/page-shell.tsx | 310 +++ packages/ui/src/components/person-avatar.tsx | 8 +- packages/ui/src/components/popover.tsx | 6 +- packages/ui/src/components/questionnaire.tsx | 8 +- packages/ui/src/components/radio-group.tsx | 6 +- packages/ui/src/components/record-sheet.tsx | 442 ++++ packages/ui/src/components/select.tsx | 18 +- packages/ui/src/components/setting-rows.tsx | 64 + packages/ui/src/components/settings-card.tsx | 119 + packages/ui/src/components/sheet.tsx | 50 +- packages/ui/src/components/simple-table.tsx | 17 +- packages/ui/src/components/skeleton-lines.tsx | 93 + packages/ui/src/components/skeleton-swap.tsx | 11 +- packages/ui/src/components/skeleton.tsx | 2 +- packages/ui/src/components/sonner.tsx | 136 +- packages/ui/src/components/sortable-list.tsx | 10 +- packages/ui/src/components/spinner.tsx | 43 +- packages/ui/src/components/stat-card.tsx | 14 +- .../ui/src/components/status-indicator.tsx | 13 +- packages/ui/src/components/suggestion.tsx | 7 +- packages/ui/src/components/switch.tsx | 4 +- packages/ui/src/components/table-empty.tsx | 37 + .../ui/src/components/table-pagination.tsx | 73 +- packages/ui/src/components/table.tsx | 39 +- packages/ui/src/components/tabs.tsx | 14 +- packages/ui/src/components/textarea.tsx | 8 +- packages/ui/src/components/toggle-group.tsx | 4 +- packages/ui/src/components/toggle.tsx | 10 +- packages/ui/src/components/token-field.tsx | 2 +- packages/ui/src/components/tool-step.tsx | 16 +- packages/ui/src/components/tooltip.tsx | 8 +- packages/ui/src/components/user-menu.tsx | 161 ++ packages/ui/src/components/value-meter.tsx | 24 + .../ui/src/components/workspace-switcher.tsx | 152 ++ packages/ui/src/hooks/use-mobile.ts | 31 +- packages/ui/src/hooks/use-search-input.ts | 12 +- packages/ui/src/lib/{dither.ts => bloom.ts} | 0 packages/ui/src/lib/chart-config.ts | 65 + packages/ui/src/lib/format.ts | 2 +- packages/ui/src/lib/interaction-config.ts | 3 + packages/ui/src/lib/layout-config.ts | 6 + packages/ui/src/lib/row-accent.ts | 22 +- packages/ui/src/lib/seed-palette.ts | 30 + packages/ui/src/lib/shell-config.ts | 15 + packages/ui/src/lib/skeleton-config.ts | 13 + packages/ui/src/lib/utils.ts | 25 +- packages/ui/src/styles/globals.css | 832 +++++-- packages/ui/src/styles/reduced-motion.css | 11 + packages/ui/src/styles/sheet.css | 84 + packages/ui/src/styles/shell.css | 31 + packages/ui/test/dashboard-chart.spec.tsx | 334 +++ packages/ui/test/evilcharts-engine.spec.tsx | 1082 +++++++++ packages/ui/test/form-sheet.spec.tsx | 340 +++ .../ui/test/settings-form-parity.spec.tsx | 599 +++++ packages/ui/test/table-controls.spec.tsx | 72 + packages/ui/test/table-pagination.spec.tsx | 141 ++ packages/validation/package.json | 3 +- packages/validation/src/search-config.ts | 4 + 302 files changed, 21899 insertions(+), 6829 deletions(-) delete mode 100644 apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx create mode 100644 apps/app/components/account-menu.tsx delete mode 100644 apps/app/components/app-header.tsx delete mode 100644 apps/app/components/app-icon-rail.tsx create mode 100644 apps/app/components/app-toaster.tsx create mode 100644 apps/app/components/crm/record-sheet/record-navigation.ts create mode 100644 apps/app/components/data-table/list-empty.tsx delete mode 100644 apps/app/components/page-shell.tsx delete mode 100644 apps/app/components/page-transition.tsx create mode 100644 apps/app/components/workspace-nav.tsx create mode 100644 apps/app/components/workspace-shell.tsx create mode 100644 apps/app/lib/command-search-config.ts create mode 100644 apps/app/lib/conversation-cursor.test.ts create mode 100644 apps/app/lib/conversation-cursor.ts create mode 100644 apps/app/lib/recent-records.ts create mode 100644 apps/app/test/recent-records.spec.ts create mode 100644 apps/app/test/record-navigation.spec.ts create mode 100644 packages/ui/src/components/agent-presence.tsx create mode 100644 packages/ui/src/components/anchored-tooltip.tsx create mode 100644 packages/ui/src/components/app-shell.tsx create mode 100644 packages/ui/src/components/app-sidebar.tsx create mode 100644 packages/ui/src/components/auth-button.tsx create mode 100644 packages/ui/src/components/auth-field.tsx create mode 100644 packages/ui/src/components/auth-input-group.tsx create mode 100644 packages/ui/src/components/auth-input.tsx create mode 100644 packages/ui/src/components/brand-logos/linkedin.tsx create mode 100644 packages/ui/src/components/brand-logos/x.tsx create mode 100644 packages/ui/src/components/chart-swatch.tsx create mode 100644 packages/ui/src/components/command-search.tsx create mode 100644 packages/ui/src/components/discard-changes.tsx create mode 100644 packages/ui/src/components/evilcharts/LICENSE create mode 100644 packages/ui/src/components/evilcharts/REGISTRY.md create mode 100644 packages/ui/src/components/evilcharts/charts/echarts-area-chart.tsx create mode 100644 packages/ui/src/components/evilcharts/charts/echarts-bar-chart.tsx create mode 100644 packages/ui/src/components/evilcharts/charts/echarts-pie-chart.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-brush.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-chart.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-config.ts create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-dot.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-legend.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-lifecycle.tsx create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-options.ts create mode 100644 packages/ui/src/components/evilcharts/ui/echarts-tooltip.tsx create mode 100644 packages/ui/src/components/form-sheet.tsx create mode 100644 packages/ui/src/components/generated-avatar.tsx create mode 100644 packages/ui/src/components/ink-field.tsx create mode 100644 packages/ui/src/components/kbd.tsx create mode 100644 packages/ui/src/components/logo-spinner.tsx create mode 100644 packages/ui/src/components/meta-row.tsx create mode 100644 packages/ui/src/components/option-label.tsx create mode 100644 packages/ui/src/components/overflow-tooltip.tsx create mode 100644 packages/ui/src/components/page-section.tsx create mode 100644 packages/ui/src/components/page-shell.tsx create mode 100644 packages/ui/src/components/record-sheet.tsx create mode 100644 packages/ui/src/components/setting-rows.tsx create mode 100644 packages/ui/src/components/settings-card.tsx create mode 100644 packages/ui/src/components/skeleton-lines.tsx create mode 100644 packages/ui/src/components/table-empty.tsx create mode 100644 packages/ui/src/components/user-menu.tsx create mode 100644 packages/ui/src/components/value-meter.tsx create mode 100644 packages/ui/src/components/workspace-switcher.tsx rename packages/ui/src/lib/{dither.ts => bloom.ts} (100%) create mode 100644 packages/ui/src/lib/chart-config.ts create mode 100644 packages/ui/src/lib/interaction-config.ts create mode 100644 packages/ui/src/lib/layout-config.ts create mode 100644 packages/ui/src/lib/seed-palette.ts create mode 100644 packages/ui/src/lib/shell-config.ts create mode 100644 packages/ui/src/lib/skeleton-config.ts create mode 100644 packages/ui/src/styles/reduced-motion.css create mode 100644 packages/ui/src/styles/sheet.css create mode 100644 packages/ui/src/styles/shell.css create mode 100644 packages/ui/test/dashboard-chart.spec.tsx create mode 100644 packages/ui/test/evilcharts-engine.spec.tsx create mode 100644 packages/ui/test/form-sheet.spec.tsx create mode 100644 packages/ui/test/settings-form-parity.spec.tsx create mode 100644 packages/ui/test/table-controls.spec.tsx create mode 100644 packages/ui/test/table-pagination.spec.tsx create mode 100644 packages/validation/src/search-config.ts diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index e1dcfedad..109f84f21 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -265,7 +265,7 @@ export async function saveBuilderDraft( backend: "eve-default", networkPolicy: "deny-all", credentials: "app-runtime-only", - summary: "Isolated sandbox · deny-all network · bounded CRM tools", + summary: "Isolated sandbox with deny-all network and bounded CRM tools", }; const files = artifactFiles(input, JSON.stringify(manifest, null, 2)); for (const file of files) assertSafeArtifact(file.content); @@ -1009,5 +1009,5 @@ function scopeSummary( (resource) => resource.kind !== "integration", ); if (recordScope === "WORKSPACE") return "Workspace CRM records"; - return records.map((resource) => resource.label).join(" · "); + return records.map((resource) => resource.label).join(", "); } diff --git a/apps/agent/agent/tools/write_brief.ts b/apps/agent/agent/tools/write_brief.ts index 4a1841dc5..9fa8c902b 100644 --- a/apps/agent/agent/tools/write_brief.ts +++ b/apps/agent/agent/tools/write_brief.ts @@ -26,7 +26,7 @@ export default defineTool({ currentRole: z .string() .optional() - .describe('e.g. "CEO & Co-founder · Comp AI"'), + .describe('e.g. "CEO and Co-founder at Comp AI"'), tenure: z.string().optional().describe('e.g. "2 yrs 3 mos"'), previousRoles: z.array(z.string()).optional(), seniority: z.string().optional().describe('e.g. "Founder / C-level"'), diff --git a/apps/api/src/activities/activities.contracts.ts b/apps/api/src/activities/activities.contracts.ts index 2b633c38a..e37865c27 100644 --- a/apps/api/src/activities/activities.contracts.ts +++ b/apps/api/src/activities/activities.contracts.ts @@ -72,7 +72,7 @@ export const activityCreateInput = z .refine( (input) => input.type !== ActivityType.TASK || Boolean(input.subject), { - message: "A task needs a subject — it is the thing to do.", + message: "A task needs a subject. It is the thing to do.", path: ["subject"], }, ); @@ -82,7 +82,7 @@ export type ActivityCreateInput = z.infer; export const activityUpdateInput = z.object({ id: z.string(), subject: z.string().trim().min(1, { - message: "A task needs a subject — it is the thing to do.", + message: "A task needs a subject. It is the thing to do.", }), dueAt: taskDueDayInput.nullable(), }); diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 04f0d8176..49c2eae58 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -155,7 +155,7 @@ export class BackfillService implements OnModuleInit { const brand = await this.agent.backfill({ kind: "brand", - reason: "Backfill — this company has no logo or icon", + reason: "Backfill: this company has no logo or icon", companyIds, budget: 2, priority: PRIORITY.brand, @@ -163,7 +163,7 @@ export class BackfillService implements OnModuleInit { const profile = await this.agent.backfill({ kind: "company-profile", - reason: "Backfill — this company was never successfully looked up", + reason: "Backfill: this company was never successfully looked up", companyIds: rows.map((row) => row.id), }); @@ -196,7 +196,7 @@ export class BackfillService implements OnModuleInit { const photos = await this.agent.backfill({ kind: "portrait", - reason: "Backfill — somewhere to look for a picture, and no picture", + reason: "Backfill: somewhere to look for a picture, and no picture", contactIds: photoRows.map((row) => row.id), budget: 1, priority: PRIORITY.portrait, @@ -218,7 +218,7 @@ export class BackfillService implements OnModuleInit { const research = await this.agent.backfill({ kind: "identify", - reason: "Backfill — this contact was never researched", + reason: "Backfill: this contact was never researched", contactIds: researchRows.map((row) => row.id), }); diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index d2107d8ac..1d106cddf 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -218,6 +218,12 @@ export const companyDetailOutput = z.object({ primaryContactId: z.string().nullable(), primaryContact: companyDetailPrimaryContactOutput.nullable(), reportingCurrency: z.string(), + openPipeline: z.object({ + count: z.number().int().nonnegative(), + totalCents: z.number().nullable(), + unconverted: z.number().int().nonnegative(), + nextClose: z.string().nullable(), + }), deals: z.array(companyDetailDealOutput), }); diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 5d896f5c0..8dfcc6f97 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -161,6 +161,7 @@ export class CompaniesService { } async byId(id: string) { + const reportingCurrency = await this.conversion.reportingCurrency(); const company = await this.db.company.findUnique({ where: { id }, select: { @@ -226,6 +227,7 @@ export class CompaniesService { amount: true, currency: true, baseAmount: true, + baseCurrency: true, expectedCloseDate: true, owner: { select: OWNER_SELECT }, }, @@ -237,6 +239,30 @@ export class CompaniesService { throw new NotFoundException(`No company with id ${id}.`); } + const openWhere: Prisma.DealWhereInput = { + companyId: id, + archivedAt: null, + stage: { in: [...OPEN_DEAL_STAGES] }, + }; + const [openDeals, openValue, unconverted] = await Promise.all([ + this.db.deal.aggregate({ + where: openWhere, + _count: { _all: true }, + _min: { expectedCloseDate: true }, + }), + this.db.deal.aggregate({ + where: { + AND: [openWhere, this.conversion.countedWhere(reportingCurrency)], + }, + _sum: { baseAmount: true }, + }), + this.db.deal.count({ + where: { + AND: [openWhere, this.conversion.pendingWhere(reportingCurrency)], + }, + }), + ]); + const { deals, primaryContact, @@ -255,13 +281,23 @@ export class CompaniesService { enrichedAt: enrichedAt?.toISOString() ?? null, primaryContactId: primaryContact?.id ?? null, primaryContact, - reportingCurrency: await this.conversion.reportingCurrency(), + reportingCurrency, + openPipeline: { + count: openDeals._count._all, + totalCents: toCents(openValue._sum.baseAmount), + unconverted, + nextClose: openDeals._min.expectedCloseDate?.toISOString() ?? null, + }, deals: deals.map((deal) => ({ ...deal, amount: undefined, baseAmount: undefined, + baseCurrency: undefined, amountCents: toCents(deal.amount), - baseAmountCents: toCents(deal.baseAmount), + baseAmountCents: + deal.baseCurrency === reportingCurrency + ? toCents(deal.baseAmount) + : null, expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, })), }; @@ -474,7 +510,7 @@ export class CompaniesService { const domain = normalizeDomain(input.domain); if (input.domain.trim() && !domain) { throw new BadRequestException( - `"${input.domain}" is not a domain — try something like "stripe.com".`, + `"${input.domain}" is not a domain. Try something like "stripe.com".`, ); } data.domain = domain; @@ -507,7 +543,7 @@ export class CompaniesService { if (data.enrichmentStatus === "PENDING") { await this.agent.companyCreated( id, - "Domain changed — anything we knew was about a different company", + "Domain changed. Anything we knew was about a different company", ); void this.favicon.backfill(id, updated.domain); } @@ -713,7 +749,7 @@ export class CompaniesService { if (!company.domain) { throw new BadRequestException( - "There is nothing to read without a domain — add one first.", + "There is nothing to read without a domain. Add one first.", ); } diff --git a/apps/api/src/companies/company-directory.service.ts b/apps/api/src/companies/company-directory.service.ts index b60f09f15..15488c667 100644 --- a/apps/api/src/companies/company-directory.service.ts +++ b/apps/api/src/companies/company-directory.service.ts @@ -47,7 +47,7 @@ export class CompanyDirectoryService { await this.agent.companyCreated( outcome.id, - `Created from an email domain (${domain}) — it has no name but the domain`, + `Created from an email domain (${domain}). It has no name but the domain`, ); this.logger.log({ diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 043fd4b78..2968c9837 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -684,7 +684,7 @@ export class ContactsService { const queued = await this.agent.contactCreated( id, contact.linkedinUrl && !contact.imageUrl - ? "A rep asked for a fresh look — they have a LinkedIn profile on file but no picture" + ? "A rep asked for a fresh look. They have a LinkedIn profile on file but no picture" : "A rep asked for a fresh look", true, ); diff --git a/apps/api/src/crm/bulk.ts b/apps/api/src/crm/bulk.ts index aa5474a01..1ae1c262c 100644 --- a/apps/api/src/crm/bulk.ts +++ b/apps/api/src/crm/bulk.ts @@ -8,7 +8,7 @@ export const bulkIdsInput = z.object({ ids: z .array(z.string()) .min(1, "Nothing was selected.") - .max(MAX_BULK_IDS, "Too many records at once — select a page at a time."), + .max(MAX_BULK_IDS, "Too many records at once. Select a page at a time."), }); export type BulkResult = { diff --git a/apps/api/src/currency/currency.service.ts b/apps/api/src/currency/currency.service.ts index d0bfa973a..0236e06e0 100644 --- a/apps/api/src/currency/currency.service.ts +++ b/apps/api/src/currency/currency.service.ts @@ -150,7 +150,7 @@ export class CurrencyService { if (quoteCurrency === baseCurrency) { throw new BadRequestException( - `${baseCurrency} is the reporting currency — its rate is always 1.`, + `${baseCurrency} is the reporting currency. Its rate is always 1.`, ); } diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 4a4d01c86..ea8d4d742 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -192,6 +192,7 @@ export class DealsService { } async byId(id: string) { + const reportingCurrency = await this.conversion.reportingCurrency(); const deal = await this.db.deal.findUnique({ where: { id }, select: { @@ -203,6 +204,7 @@ export class DealsService { amount: true, currency: true, baseAmount: true, + baseCurrency: true, fxRate: true, fxRateAt: true, expectedCloseDate: true, @@ -227,6 +229,7 @@ export class DealsService { contacts, amount, baseAmount, + baseCurrency, fxRate, fxRateAt, archivedAt, @@ -237,10 +240,17 @@ export class DealsService { ...rest, fields: await this.fields.valuesFor("DEAL", id), amountCents: toCents(amount), - baseAmountCents: toCents(baseAmount), - reportingCurrency: await this.conversion.reportingCurrency(), - fxRate: fxRate?.toNumber() ?? null, - fxRateAt: fxRateAt?.toISOString() ?? null, + baseAmountCents: + baseCurrency === reportingCurrency ? toCents(baseAmount) : null, + reportingCurrency, + fxRate: + baseCurrency === reportingCurrency + ? (fxRate?.toNumber() ?? null) + : null, + fxRateAt: + baseCurrency === reportingCurrency + ? (fxRateAt?.toISOString() ?? null) + : null, stageChangedAt: deal.stageChangedAt.toISOString(), expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, closedAt: deal.closedAt?.toISOString() ?? null, @@ -497,7 +507,7 @@ export class DealsService { } if (LOSING.has(input.stage) && !closedReason) { throw new BadRequestException( - "Say why it was lost — a closed-lost deal with no reason teaches nobody anything.", + "Say why it was lost. A closed-lost deal with no reason teaches nobody anything.", ); } @@ -701,7 +711,7 @@ export class DealsService { if (LOSING.has(input.stage) && !closedReason) { throw new BadRequestException( - "Say why they were lost — a closed-lost deal with no reason teaches nobody anything.", + "Say why they were lost. A closed-lost deal with no reason teaches nobody anything.", ); } diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index d25e7d4d7..406ed6605 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -187,7 +187,7 @@ export class GoogleConnectionService { const ours = await this.match.internalIdentity(); if (ours.domains.has(normalised)) { throw new NotFoundException( - "That is our own domain — it is already excluded.", + "That is our own domain. It is already excluded.", ); } diff --git a/apps/api/src/search/search.service.ts b/apps/api/src/search/search.service.ts index 7a435dca8..b1c6ecfc3 100644 --- a/apps/api/src/search/search.service.ts +++ b/apps/api/src/search/search.service.ts @@ -1,4 +1,5 @@ import type { Db } from "@crm/db"; +import { QUICK_SEARCH } from "@crm/validation/search-config"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -13,15 +14,13 @@ export type SearchHit = { imageUrl: string | null; }; -const PER_KIND = 5; - @Injectable() export class SearchService { constructor(@InjectDatabase() private readonly db: Db) {} async quick(q: string): Promise<{ hits: SearchHit[] }> { const term = q.trim(); - if (term.length < 2) return { hits: [] }; + if (term.length < QUICK_SEARCH.query.minLength) return { hits: [] }; const [companies, contacts, deals] = await Promise.all([ this.db.company.findMany({ @@ -31,7 +30,7 @@ export class SearchService { { domain: { contains: term, mode: "insensitive" } }, ], }, - take: PER_KIND, + take: QUICK_SEARCH.results.perKind, orderBy: { name: "asc" }, select: { id: true, @@ -50,7 +49,7 @@ export class SearchService { { email: { contains: term, mode: "insensitive" } }, ], }, - take: PER_KIND, + take: QUICK_SEARCH.results.perKind, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], select: { id: true, @@ -63,7 +62,7 @@ export class SearchService { }), this.db.deal.findMany({ where: { name: { contains: term, mode: "insensitive" } }, - take: PER_KIND, + take: QUICK_SEARCH.results.perKind, orderBy: [{ stage: "asc" }, { name: "asc" }], select: { id: true, diff --git a/apps/api/src/settings/settings.contracts.ts b/apps/api/src/settings/settings.contracts.ts index aa8c91ffb..4b17bbf11 100644 --- a/apps/api/src/settings/settings.contracts.ts +++ b/apps/api/src/settings/settings.contracts.ts @@ -54,8 +54,8 @@ export const setResearchKeyInput = z.object({ apiKey: z .string() .trim() - .min(8, "That does not look like a Context API key — it is too short.") - .max(500, "That does not look like a Context API key — it is too long.") + .min(8, "That does not look like a Context API key. It is too short.") + .max(500, "That does not look like a Context API key. It is too long.") .refine( (value) => !/\s/.test(value), "An API key has no spaces in it. Paste the whole key on its own.", diff --git a/apps/api/src/tracking/tracking.service.ts b/apps/api/src/tracking/tracking.service.ts index 58c4b84d6..2a2c72eea 100644 --- a/apps/api/src/tracking/tracking.service.ts +++ b/apps/api/src/tracking/tracking.service.ts @@ -231,7 +231,7 @@ export class TrackingService { if (!trackingReady(row?.trackingLimitToDomains ?? true, domains)) { throw new BadRequestException( - "Add the domain your website runs on first — there is no script to find yet.", + "Add the domain your website runs on first. There is no script to find yet.", ); } diff --git a/apps/api/test/currency-totals.integration.spec.ts b/apps/api/test/currency-totals.integration.spec.ts index 5cb97c7a4..85ca5a0c8 100644 --- a/apps/api/test/currency-totals.integration.spec.ts +++ b/apps/api/test/currency-totals.integration.spec.ts @@ -2,7 +2,10 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DealStage, db, RateSource } from "@crm/db"; import { normalizeCurrency } from "@crm/db/currency"; import { SETTINGS_ID, writeReportingCurrency } from "@crm/db/settings"; +import { AgentQueueService } from "../src/agent/agent-queue.service"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompaniesService } from "../src/companies/companies.service"; +import { FaviconService } from "../src/companies/favicon.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DashboardService } from "../src/dashboard/dashboard.service"; @@ -27,6 +30,15 @@ const deals = new DealsService( new FieldsService(db, { fieldBackfill: async () => undefined } as never), ); const dashboard = new DashboardService(db, conversion); +const companies = new CompaniesService( + db, + agent, + new AgentQueueService(db), + new FaviconService(db), + new ActivityStampService(db), + conversion, + new FieldsService(db, { fieldBackfill: async () => undefined } as never), +); let companyId: string; let previousReportingCurrency: string | null = null; @@ -518,3 +530,87 @@ describe("the dashboard only values what it can convert", () => { await db.deal.deleteMany({ where: { id: { in: [open.id, unvalued.id] } } }); }); }); + +describe("record sheet money", () => { + it("excludes archived and stale amounts from a company's open pipeline", async () => { + await writeReportingCurrency(db, "USD"); + const company = await db.company.create({ + data: { name: `Record money ${suffix}` }, + }); + + try { + const close = new Date("2027-02-01T00:00:00.000Z"); + await db.deal.createMany({ + data: [ + { + companyId: company.id, + name: "Current conversion", + amount: 100, + currency: "EUR", + baseAmount: 110, + baseCurrency: "USD", + expectedCloseDate: close, + }, + { + companyId: company.id, + name: "Stale conversion", + amount: 100, + currency: "EUR", + baseAmount: 9000, + baseCurrency: "JPY", + }, + { + companyId: company.id, + name: "Unlabelled conversion", + amount: 100, + currency: "EUR", + baseAmount: 110, + }, + { + companyId: company.id, + name: "Archived conversion", + amount: 1000, + currency: "USD", + baseAmount: 1000, + baseCurrency: "USD", + archivedAt: new Date(), + expectedCloseDate: new Date("2026-01-01T00:00:00.000Z"), + }, + { + companyId: company.id, + name: "Closed conversion", + stage: DealStage.CLOSED_WON, + amount: 2000, + currency: "USD", + baseAmount: 2000, + baseCurrency: "USD", + }, + ].map((deal) => ({ ...deal, ownerId: userId })), + }); + + const record = await companies.byId(company.id); + expect(record.openPipeline).toEqual({ + count: 3, + totalCents: 11000, + unconverted: 2, + nextClose: close.toISOString(), + }); + + const stale = record.deals.find( + (deal) => deal.name === "Stale conversion", + ); + expect(stale?.baseAmountCents).toBeNull(); + const staleDetail = await deals.byId(stale?.id ?? "missing"); + expect(staleDetail.baseAmountCents).toBeNull(); + expect(staleDetail.fxRate).toBeNull(); + + await db.deal.deleteMany({ where: { companyId: company.id } }); + expect( + (await companies.byId(company.id)).openPipeline.totalCents, + ).toBeNull(); + } finally { + await db.deal.deleteMany({ where: { companyId: company.id } }); + await db.company.delete({ where: { id: company.id } }); + } + }); +}); diff --git a/apps/api/test/error-formatter.spec.ts b/apps/api/test/error-formatter.spec.ts index f6126cddb..ed584c5d8 100644 --- a/apps/api/test/error-formatter.spec.ts +++ b/apps/api/test/error-formatter.spec.ts @@ -14,7 +14,7 @@ describe("what a rejected form says", () => { const cause = causeOf(setResearchKeyInput, { apiKey: "short" }); expect(readableInputError("ignored", cause)).toBe( - "That does not look like a Context API key — it is too short.", + "That does not look like a Context API key. It is too short.", ); }); diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx index 1f5aa61c1..19d5d30bc 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx @@ -1,8 +1,8 @@ +import { PageShellFallback } from "@crm/ui/components/page-shell"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Suspense } from "react"; import { TeamAgentDetail } from "@/components/agent-builder/team-agent-detail"; -import { PageShellFallback } from "@/components/page-shell"; import { getServerTrpcClient } from "@/lib/trpc/server"; import { nullIfMissing } from "../../missing-record"; diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx index 8edbb7e79..32319d03c 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/page.tsx @@ -1,6 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; -import { TeamAgentsIndex } from "@/components/agent-builder/team-agents-index"; import { PageShell, PageShellContent, @@ -9,7 +6,10 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { TeamAgentsIndex } from "@/components/agent-builder/team-agents-index"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -28,7 +28,7 @@ export default function AgentsPage() { - }> + }> diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx index fa0388334..c266388b4 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/layout.tsx @@ -1,9 +1,5 @@ import { Suspense } from "react"; -import { - AgentBuilderShell, - AgentBuilderSidebarFallback, -} from "@/components/agent-builder/agent-builder-shell"; -import { AgentBuilderSidebar } from "@/components/agent-builder/agent-builder-sidebar"; +import { AgentBuilderShell } from "@/components/agent-builder/agent-builder-shell"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -11,39 +7,23 @@ export default function AgentBuilderLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - }> - - - } - > + + + + {children} ); } -async function PrefetchedAgentBuilderSidebar() { +async function PrefetchedAgentChatNav() { const trpc = getServerTrpc(); const queryClient = getServerQueryClient(); - const conversationsQuery = trpc.conversations.builderList.queryOptions(); - const agentsQuery = trpc.agents.list.queryOptions(); - const [conversations, agents] = await Promise.all([ - queryClient.fetchQuery(conversationsQuery), - queryClient.fetchQuery(agentsQuery), + await Promise.all([ + queryClient.prefetchQuery(trpc.conversations.builderList.queryOptions()), + queryClient.prefetchQuery(trpc.agents.list.queryOptions()), ]); - const updatedAt = Math.min( - queryClient.getQueryState(conversationsQuery.queryKey)?.dataUpdatedAt ?? 0, - queryClient.getQueryState(agentsQuery.queryKey)?.dataUpdatedAt ?? 0, - ); - return ( - - - - ); + return {null}; } diff --git a/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx b/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx index 3ac14002e..b1770f737 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/companies/companies-bulk-actions.tsx @@ -1,8 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; -import Renew from "@carbon/icons-react/es/Renew"; -import Undo from "@carbon/icons-react/es/Undo"; import { DropdownMenuGroup, DropdownMenuItem, @@ -10,6 +7,7 @@ import { } from "@crm/ui/components/dropdown-menu"; import { formatCount } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArchiveIcon, RefreshCwIcon, Undo2Icon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -58,7 +56,8 @@ export function CompaniesBulkActions({ await cache.company(); reportBulk( result, - (count) => `Looking up ${companies(count)} — the table will update.`, + (count) => + `Looking up ${companies(count)}. The table updates when complete.`, ); onDone(); }, @@ -108,7 +107,7 @@ export function CompaniesBulkActions({ restore.mutate({ ids })}> - + Restore @@ -146,14 +145,14 @@ export function CompaniesBulkActions({ /> enrich.mutate({ ids })}> - + Re-enrich archive.mutate({ ids })}> - + Archive diff --git a/apps/app/app/(app)/[slug]/companies/companies-table.tsx b/apps/app/app/(app)/[slug]/companies/companies-table.tsx index b2356b13f..a79cfdbdc 100644 --- a/apps/app/app/(app)/[slug]/companies/companies-table.tsx +++ b/apps/app/app/(app)/[slug]/companies/companies-table.tsx @@ -1,6 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; import { Button } from "@crm/ui/components/button"; import { DataTable, @@ -14,6 +13,7 @@ import { } from "@crm/ui/components/entity-logo"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { useQuery } from "@tanstack/react-query"; +import { ArchiveIcon } from "lucide-react"; import { useMemo } from "react"; import { EnrichmentIndicator } from "@/components/crm/enrichment-status"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; @@ -21,6 +21,7 @@ import { useFieldFacets } from "@/components/crm/fields/field-facets"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { ListEmpty } from "@/components/data-table/list-empty"; import { ListSearch } from "@/components/data-table/list-search"; import { SavedViewsMenu } from "@/components/data-table/saved-views-menu"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -44,7 +45,6 @@ const COLUMNS: DataTableColumn[] = [ header: "Company", sortable: true, hideable: false, - width: "w-[26%]", cell: (row) => ( [] = [ id: "domain", header: "Domain", sortable: true, - width: "w-[16%]", + width: "40", hideBelow: "md", cell: (row) => row.domain ? ( - {row.domain} + + {row.domain} + ) : ( ), @@ -75,11 +77,11 @@ const COLUMNS: DataTableColumn[] = [ id: "industry", header: "Industry", sortable: true, - width: "w-[16%]", + width: "40", hideBelow: "lg", cell: (row) => row.industry ? ( - {row.industry} + {row.industry} ) : ( ), @@ -88,7 +90,7 @@ const COLUMNS: DataTableColumn[] = [ id: "owner", header: "Owner", sortable: true, - width: "w-[16%]", + width: "40", hideBelow: "md", cell: (row) => , }, @@ -97,7 +99,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Contacts", sortable: true, align: "right", - width: "w-[9%]", + width: "28", hideBelow: "lg", cell: (row) => {row.contactCount}, }, @@ -106,7 +108,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Open deals", sortable: true, align: "right", - width: "w-[9%]", + width: "28", cell: (row) => {row.openDealCount}, }, { @@ -115,7 +117,7 @@ const COLUMNS: DataTableColumn[] = [ label: "Created date", sortable: true, align: "right", - width: "w-[10%]", + width: "28", defaultHidden: true, cell: (row) => ( @@ -128,7 +130,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Last activity", sortable: true, align: "right", - width: "w-[12%]", + width: "32", hideBelow: "sm", cell: (row) => ( @@ -145,7 +147,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Enrichment", label: "Enrichment status", defaultHidden: true, - width: "w-[14%]", + width: "36", cell: (row) => ( ), @@ -158,7 +160,7 @@ const ARCHIVED_COLUMN: DataTableColumn = { label: "Archived date", sortable: true, align: "right", - width: "w-[12%]", + width: "32", cell: (row) => ( {row.archivedAt ? ( @@ -244,18 +246,19 @@ export function CompaniesTable() { return ( } + search={} actions={ <> @@ -279,11 +282,22 @@ export function CompaniesTable() { getRowId={(row) => row.id} loading={companies.isFetching} onRowHover={(row) => prefetchRecord({ kind: "company", id: row.id })} - onRowClick={(row) => openRecord({ kind: "company", id: row.id })} + onRowClick={(row, orderedRows) => + openRecord( + { kind: "company", id: row.id }, + orderedRows.map((entry) => ({ kind: "company", id: entry.id })), + ) + } empty={ - input.archived - ? "No archived companies." - : "No companies match this view." + } /> ); diff --git a/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx b/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx index 59405fad7..7a4225423 100644 --- a/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx +++ b/apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx @@ -1,13 +1,19 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; import { Button } from "@crm/ui/components/button"; import { Field, FieldDescription, - FieldGroup, + FieldError, FieldLabel, } from "@crm/ui/components/field"; +import { + FormSheet, + FormSheetContent, + FormSheetFooter, + FormSheetForm, + useFormSheetValidation, +} from "@crm/ui/components/form-sheet"; import { Icon } from "@crm/ui/components/icon"; import { Input } from "@crm/ui/components/input"; import { @@ -18,17 +24,14 @@ import { SelectValue, } from "@crm/ui/components/select"; import { - Sheet, - SheetClose, - SheetContent, SheetDescription, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@crm/ui/components/sheet"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; @@ -42,7 +45,7 @@ const UNASSIGNED = "unassigned"; function AddButton(props: ComponentProps) { return ( ); @@ -58,6 +61,7 @@ export function CreateCompanySheet() { function CreateCompanyForm() { const openRecord = useOpenRecord(); + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); @@ -80,21 +84,32 @@ function CreateCompanyForm() { await cache.company(company.id); toast.success(`${company.name} added.`); await setOpen(null); - setName(""); - setDomain(""); - setOwnerId(UNASSIGNED); + reset(); openRecord({ kind: "company", id: company.id }); }, onError: (error) => toast.error(error.message), }), ); + const reset = () => { + validation.reset(); + setName(""); + setDomain(""); + setOwnerId(UNASSIGNED); + }; + return ( - setOpen(next || null)}> + setOpen(next || null)} + > - + New company @@ -103,11 +118,13 @@ function CreateCompanyForm() { -
{ event.preventDefault(); + if (!validation.validate(event.currentTarget)) return; create.mutate({ name, domain: domain || undefined, @@ -115,68 +132,77 @@ function CreateCompanyForm() { }); }} > - - - Name - setName(event.target.value)} - placeholder="Stripe" - autoComplete="off" - required - /> - + + Name + setName(event.target.value)} + placeholder="Stripe" + autoComplete="off" + pattern=".*\S.*" + required + /> + + {validation.errors[nameId]} + + - - Domain - setDomain(event.target.value)} - placeholder="stripe.com" - autoComplete="off" - inputMode="url" - /> - - A full URL is fine — it is reduced to the bare host, which has - to be unique. - - + + Domain + setDomain(event.target.value)} + placeholder="stripe.com" + autoComplete="off" + inputMode="url" + /> + + {validation.errors[domainId]} + + + A full URL works. The CRM uses the host, which has to be unique. + + - - Owner - - - -
+ + Owner + + + - + - - - - -
-
+ + + ); } diff --git a/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx b/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx index 56f492e4a..687d2c99c 100644 --- a/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx +++ b/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx @@ -1,30 +1,51 @@ -import Upload from "@carbon/icons-react/es/Upload"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; +import { UploadIcon } from "lucide-react"; import Link from "next/link"; import { workspaceUrl } from "@/lib/workspace-url"; +const LABEL = "Import CSV"; + export async function ImportCompaniesLink({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; + const href = workspaceUrl(slug, "/companies/import"); return ( - + <> + + + ); } export function ImportCompaniesLinkFallback() { return ( - + <> + + + ); } diff --git a/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx b/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx index cdcffd31e..45efda741 100644 --- a/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx +++ b/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx @@ -1,6 +1,5 @@ "use client"; -import Upload from "@carbon/icons-react/es/Upload"; import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; import { Button } from "@crm/ui/components/button"; import { @@ -40,6 +39,7 @@ import { } from "@crm/ui/components/table"; import { COMPANY_IMPORT } from "@crm/validation/company-import"; import { useMutation } from "@tanstack/react-query"; +import { UploadIcon } from "lucide-react"; import Link from "next/link"; import { useId, useReducer, useState } from "react"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -441,7 +441,7 @@ function ResultStep({ View companies diff --git a/apps/app/app/(app)/[slug]/companies/import/page.tsx b/apps/app/app/(app)/[slug]/companies/import/page.tsx index a56dfb0d7..b14fd65d4 100644 --- a/apps/app/app/(app)/[slug]/companies/import/page.tsx +++ b/apps/app/app/(app)/[slug]/companies/import/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { workspaceUrl } from "@/lib/workspace-url"; import { ImportWizard } from "./import-wizard"; diff --git a/apps/app/app/(app)/[slug]/companies/page.tsx b/apps/app/app/(app)/[slug]/companies/page.tsx index 943801e3a..2c8490756 100644 --- a/apps/app/app/(app)/[slug]/companies/page.tsx +++ b/apps/app/app/(app)/[slug]/companies/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellActions, @@ -9,7 +7,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -47,7 +47,7 @@ export default function CompaniesPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx index 5fae7fb98..2e97d7836 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx @@ -1,8 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; -import Renew from "@carbon/icons-react/es/Renew"; -import Undo from "@carbon/icons-react/es/Undo"; import { DropdownMenuGroup, DropdownMenuItem, @@ -13,6 +10,7 @@ import { } from "@crm/ui/components/dropdown-menu"; import { formatCount } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArchiveIcon, RefreshCwIcon, Undo2Icon } from "lucide-react"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -75,7 +73,8 @@ export function ContactsBulkActions({ await cache.contact(); reportBulk( result, - (count) => `Looking up ${contacts(count)} — the table will update.`, + (count) => + `Looking up ${contacts(count)}. The table updates when complete.`, ); onDone(); }, @@ -125,7 +124,7 @@ export function ContactsBulkActions({ restore.mutate({ ids })}> - + Restore @@ -190,14 +189,14 @@ export function ContactsBulkActions({ enrich.mutate({ ids })}> - + Re-enrich archive.mutate({ ids })}> - + Archive diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx index 1847de03b..bc022be2d 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx @@ -1,6 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; import { Button } from "@crm/ui/components/button"; import { DataTable, @@ -12,7 +11,8 @@ import { PersonAvatar } from "@crm/ui/components/person-avatar"; import { useSearchInput } from "@crm/ui/hooks/use-search-input"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { ArchiveIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { CompanyCell } from "@/components/crm/company-cell"; import { contactName } from "@/components/crm/contact-name"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; @@ -20,6 +20,7 @@ import { useFieldFacets } from "@/components/crm/fields/field-facets"; import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { ListEmpty } from "@/components/data-table/list-empty"; import { ListSearch } from "@/components/data-table/list-search"; import { SavedViewsMenu } from "@/components/data-table/saved-views-menu"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -38,7 +39,6 @@ const COLUMNS: DataTableColumn[] = [ header: "Name", sortable: true, hideable: false, - width: "w-[22%]", cell: (row) => ( [] = [ id: "title", header: "Title", sortable: true, - width: "w-[20%]", + width: "40", hideBelow: "lg", cell: (row) => row.title ? ( - {row.title} + {row.title} ) : ( ), @@ -68,11 +68,13 @@ const COLUMNS: DataTableColumn[] = [ id: "email", header: "Email", sortable: true, - width: "w-[24%]", + width: "56", hideBelow: "md", cell: (row) => row.email ? ( - {row.email} + + {row.email} + ) : ( ), @@ -81,14 +83,14 @@ const COLUMNS: DataTableColumn[] = [ id: "company", header: "Company", sortable: true, - width: "w-[18%]", + width: "40", cell: (row) => , }, { id: "owner", header: "Owner", sortable: true, - width: "w-[16%]", + width: "40", hideBelow: "md", cell: (row) => , }, @@ -98,7 +100,7 @@ const COLUMNS: DataTableColumn[] = [ label: "Created date", sortable: true, align: "right", - width: "w-[10%]", + width: "28", defaultHidden: true, cell: (row) => ( @@ -111,7 +113,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Last activity", sortable: true, align: "right", - width: "w-[12%]", + width: "32", hideBelow: "sm", cell: (row) => ( @@ -131,7 +133,7 @@ const ARCHIVED_COLUMN: DataTableColumn = { label: "Archived date", sortable: true, align: "right", - width: "w-[12%]", + width: "32", cell: (row) => ( {row.archivedAt ? ( @@ -179,11 +181,6 @@ export function ContactsTable() { return selection.ids.filter((id) => matching.has(id)); }, [rows, input.archived, selection.ids]); - // biome-ignore lint/correctness/useExhaustiveDependencies: clearing on archived-mode change is the entire purpose of this effect. - useEffect(() => { - selection.clear(); - }, [input.archived]); - const toggleArchived = (next: boolean) => { selection.clear(); if (!next && query.sort === "archivedAt") query.setSort(""); @@ -265,18 +262,19 @@ export function ContactsTable() { return ( } + search={} actions={ <> @@ -300,11 +298,23 @@ export function ContactsTable() { getRowId={(row) => row.id} loading={contacts.isFetching} onRowHover={(row) => prefetchRecord({ kind: "contact", id: row.id })} - onRowClick={(row) => openRecord({ kind: "contact", id: row.id })} + onRowClick={(row, orderedRows) => + openRecord( + { kind: "contact", id: row.id }, + orderedRows.map((entry) => ({ kind: "contact", id: entry.id })), + ) + } empty={ - input.archived - ? "No archived contacts." - : "No contacts match this view." + } /> ); diff --git a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx index adbe905d0..5e27462dc 100644 --- a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx +++ b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx @@ -1,8 +1,14 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; import { Button } from "@crm/ui/components/button"; -import { Field, FieldGroup, FieldLabel } from "@crm/ui/components/field"; +import { Field, FieldError, FieldLabel } from "@crm/ui/components/field"; +import { + FormSheet, + FormSheetContent, + FormSheetFooter, + FormSheetForm, + useFormSheetValidation, +} from "@crm/ui/components/form-sheet"; import { Icon } from "@crm/ui/components/icon"; import { Input } from "@crm/ui/components/input"; import { @@ -13,17 +19,14 @@ import { SelectValue, } from "@crm/ui/components/select"; import { - Sheet, - SheetClose, - SheetContent, SheetDescription, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@crm/ui/components/sheet"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; @@ -38,7 +41,7 @@ const NONE = "none"; function AddButton(props: ComponentProps) { return ( ); @@ -54,6 +57,7 @@ export function CreateContactSheet({ companyId }: { companyId?: string }) { function CreateContactForm({ companyId }: { companyId?: string }) { const openRecord = useOpenRecord(); + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); @@ -83,22 +87,42 @@ function CreateContactForm({ companyId }: { companyId?: string }) { `${[contact.firstName, contact.lastName].filter(Boolean).join(" ")} added.`, ); await setOpen(null); - setFirstName(""); - setLastName(""); - setEmail(""); - setTitle(""); + reset(); openRecord({ kind: "contact", id: contact.id }); }, onError: (error) => toast.error(error.message), }), ); + const reset = () => { + validation.reset(); + setFirstName(""); + setLastName(""); + setEmail(""); + setTitle(""); + setCompany(companyId ?? NONE); + setOwnerId(NONE); + }; + return ( - setOpen(next || null)}> + setOpen(next || null)} + > - + New contact @@ -107,11 +131,13 @@ function CreateContactForm({ companyId }: { companyId?: string }) { -
{ event.preventDefault(); + if (!validation.validate(event.currentTarget)) return; create.mutate({ firstName, lastName: lastName || undefined, @@ -122,93 +148,121 @@ function CreateContactForm({ companyId }: { companyId?: string }) { }); }} > - - - First name - setFirstName(event.target.value)} - autoComplete="off" - required - /> - - - - Last name - setLastName(event.target.value)} - autoComplete="off" - /> - - - - Email - setEmail(event.target.value)} - autoComplete="off" - /> - - - - Title - setTitle(event.target.value)} - placeholder="Head of Security" - autoComplete="off" - /> - - - - Company - - - - - Owner - - - -
- - + + First name + setFirstName(event.target.value)} + autoComplete="off" + pattern=".*\S.*" + required + /> + + {validation.errors[firstNameId]} + + + + + Last name + setLastName(event.target.value)} + autoComplete="off" + /> + + {validation.errors[lastNameId]} + + + + + Email + setEmail(event.target.value)} + autoComplete="off" + /> + + {validation.errors[emailId]} + + + + + Title + setTitle(event.target.value)} + placeholder="Head of Security" + autoComplete="off" + /> + + {validation.errors[titleId]} + + + + + Company + + + + + Owner + + + + + - - - - -
-
+ + + ); } diff --git a/apps/app/app/(app)/[slug]/contacts/page.tsx b/apps/app/app/(app)/[slug]/contacts/page.tsx index f5717e612..d9820d8ac 100644 --- a/apps/app/app/(app)/[slug]/contacts/page.tsx +++ b/apps/app/app/(app)/[slug]/contacts/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellActions, @@ -9,7 +7,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -37,7 +37,7 @@ export default function ContactsPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/dashboard-summary.tsx b/apps/app/app/(app)/[slug]/dashboard-summary.tsx index 08ae17d6a..7fc1f4e55 100644 --- a/apps/app/app/(app)/[slug]/dashboard-summary.tsx +++ b/apps/app/app/(app)/[slug]/dashboard-summary.tsx @@ -6,37 +6,37 @@ import { CardAction, CardDescription, CardHeader, - CardPanel, CardPanelEmpty, CardTitle, } from "@crm/ui/components/card"; -import { CardTableEmpty } from "@crm/ui/components/card-table"; import { Checkbox } from "@crm/ui/components/checkbox"; +import { DashboardPanel } from "@crm/ui/components/dashboard"; import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { EntityLogo, type EntityLogoTone, } from "@crm/ui/components/entity-logo"; +import { PageShellLoading } from "@crm/ui/components/page-shell"; import { SimpleTable, type SimpleTableColumn, SimpleTableRow, } from "@crm/ui/components/simple-table"; -import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { TableCell } from "@crm/ui/components/table"; +import { ValueMeter } from "@crm/ui/components/value-meter"; import { formatCount, formatMoneyCompact } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useQueryState } from "nuqs"; -import type { CSSProperties, ReactNode } from "react"; +import type { ReactNode } from "react"; import { toast } from "sonner"; import { DealStageIndicator } from "@/components/crm/deal-stage"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { LocalRelativeTime } from "@/components/local-date-time"; import { activityLabel } from "@/lib/activity-presentation"; -import { dealStageColor } from "@/lib/deal-stage"; +import { dealStageChartColor } from "@/lib/deal-stage"; import { SEARCH_PARAM } from "@/lib/search-param-keys"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -45,19 +45,24 @@ import { overviewParsers } from "./overview-search-params"; import { SalesDashboard } from "./sales-dashboard"; const CELL = "px-3 py-2.5 align-middle"; +const STAGE_CELL = "hidden @lg/simple-table:table-cell"; +const SHARE_CELL = "hidden @sm/simple-table:table-cell"; +const COMPANY_CELL = "hidden @md/simple-table:table-cell"; +const ACTIVITY_DEAL_CELL = "hidden @2xl/simple-table:table-cell"; +const WHO_CELL = "hidden @lg/simple-table:table-cell"; const OPEN_COLUMNS: SimpleTableColumn[] = [ { id: "deal", header: "Deal" }, { id: "stage", header: "Stage", - width: "w-32", - className: "hidden lg:table-cell", + width: "w-44", + className: STAGE_CELL, }, { id: "share", srLabel: "Share of the largest", width: "w-24", - className: "hidden sm:table-cell", + className: SHARE_CELL, }, { id: "value", header: "Value", width: "w-20", align: "right" }, ]; @@ -72,19 +77,19 @@ const ACTIVITY_COLUMNS: SimpleTableColumn[] = [ id: "company", header: "Company", width: "w-44", - className: "hidden md:table-cell", + className: COMPANY_CELL, }, { id: "deal", header: "Deal", width: "w-48", - className: "hidden lg:table-cell", + className: ACTIVITY_DEAL_CELL, }, { id: "who", header: "Who", width: "w-32", - className: "hidden md:table-cell", + className: WHO_CELL, }, { id: "when", header: "When", width: "w-20", align: "right" }, ]; @@ -115,11 +120,7 @@ export function DashboardSummary() { const summary = summaryQuery.data; if (!summary) { - return ( -
- -
- ); + return ; } const { biggestOpen, overdueTasks, recentActivity } = summary; @@ -139,12 +140,12 @@ export function DashboardSummary() { The largest open deals, and how long each has sat in its stage - - + {biggestOpen.length === 0 ? ( Nothing open. Time to fill the pipeline. @@ -152,14 +153,22 @@ export function DashboardSummary() { ) : ( {biggestOpen.map((deal) => ( openRecord({ kind: "deal", id: deal.id })} + onClick={() => + openRecord( + { kind: "deal", id: deal.id }, + biggestOpen.map((entry) => ({ + kind: "deal", + id: entry.id, + })), + ) + } > } /> - )} - + @@ -205,18 +214,18 @@ export function DashboardSummary() { : `${formatCount(overdueTasks.length, "task")} past due`} - - + {overdueTasks.length === 0 ? ( Nothing overdue. Good. ) : ( {overdueTasks.map((task) => ( @@ -263,7 +272,7 @@ export function DashboardSummary() { ))} )} - + @@ -278,54 +287,60 @@ export function DashboardSummary() { : "Every note, task and stage change across the workspace"} - - {recentActivity.length === 0 ? ( - Nothing has happened yet. - ) : ( - - {recentActivity.map((entry) => ( - - - - {entry.subject ?? activityLabel(entry.type)} - - - - - - - - - - ))} - - )} + + {recentActivity.length === 0 ? ( + Nothing has happened yet. + ) : ( + + {recentActivity.map((entry) => ( + + + + {entry.subject ?? activityLabel(entry.type)} + + + + {entry.company ? ( + + {entry.company.name} + + ) : ( + + )} + + + {entry.deal ? ( + + {entry.deal.name} + + ) : ( + + )} + + + {entry.createdBy.name} + + + + + + ))} + + )} + ); @@ -356,35 +371,11 @@ function DealCell({ /> {name} - - {meta ? ( - <> - {company.name} · {meta} - - ) : ( - company.name - )} + + {company.name} + {meta ? {meta} : null} ); } - -function ValueMeter({ share, color }: { share: number; color: string }) { - return ( - - - - ); -} diff --git a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx index fcdd96fe8..2590fd283 100644 --- a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx +++ b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx @@ -1,15 +1,21 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; import { CURRENCIES } from "@crm/db/currency"; import { Button } from "@crm/ui/components/button"; import { DatePicker } from "@crm/ui/components/date-picker"; import { Field, FieldDescription, - FieldGroup, + FieldError, FieldLabel, } from "@crm/ui/components/field"; +import { + FormSheet, + FormSheetContent, + FormSheetFooter, + FormSheetForm, + useFormSheetValidation, +} from "@crm/ui/components/form-sheet"; import { Icon } from "@crm/ui/components/icon"; import { Input } from "@crm/ui/components/input"; import { @@ -20,17 +26,14 @@ import { SelectValue, } from "@crm/ui/components/select"; import { - Sheet, - SheetClose, - SheetContent, SheetDescription, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@crm/ui/components/sheet"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; @@ -46,7 +49,7 @@ const UNSET = ""; function AddButton(props: ComponentProps) { return ( ); @@ -62,6 +65,7 @@ export function CreateDealSheet({ companyId }: { companyId?: string }) { function CreateDealForm({ companyId }: { companyId?: string }) { const openRecord = useOpenRecord(); + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); @@ -95,25 +99,44 @@ function CreateDealForm({ companyId }: { companyId?: string }) { await cache.deal(deal.id); toast.success(`${deal.name} added.`); await setOpen(null); - setName(""); - setAmount(""); - setCurrency(""); - setCloseDate(""); + reset(); openRecord({ kind: "deal", id: deal.id }); }, onError: (error) => toast.error(error.message), }), ); - const ready = - name.trim() !== "" && company !== UNSET && resolvedOwner !== UNSET; + const reset = () => { + validation.reset(); + setName(""); + setCompany(companyId ?? UNSET); + setOwnerId(UNSET); + setStage("DEMO_BOOKED"); + setAmount(""); + setCurrency(""); + setCloseDate(""); + }; return ( - setOpen(next || null)}> + setOpen(next || null)} + > - + New deal @@ -121,11 +144,18 @@ function CreateDealForm({ companyId }: { companyId?: string }) { -
{ event.preventDefault(); + const errors: Record = {}; + if (company === UNSET) + errors["create-deal-company"] = "Choose a company."; + if (resolvedOwner === UNSET) + errors["create-deal-owner"] = "Choose an owner."; + if (!validation.validate(event.currentTarget, errors)) return; const parsed = Number.parseFloat(amount); create.mutate({ name, @@ -140,119 +170,151 @@ function CreateDealForm({ companyId }: { companyId?: string }) { }); }} > - - - Name - setName(event.target.value)} - placeholder="Stripe — Comp AI" - autoComplete="off" - required - /> - + + Name + setName(event.target.value)} + placeholder="Stripe Comp AI" + autoComplete="off" + pattern=".*\S.*" + required + /> + + {validation.errors[nameId]} + + - - Company - - + + Company + { + setCompany(value); + validation.clearError("create-deal-company"); + }} + /> + + {validation.errors["create-deal-company"]} + + - - Owner - - + + Owner + + + {validation.errors["create-deal-owner"]} + + - - Stage - + + + + + {OPEN_STAGES.map((value) => ( + + {dealStageLabel(value)} + + ))} + + + + A new deal starts open. Close it from the pipeline once there is + an outcome to record. + + + + + Amount +
+ setAmount(event.target.value)} + placeholder="24000" + inputMode="decimal" + autoComplete="off" + /> + + {validation.errors[amountId]} + + - - A new deal is an open deal — close it from the pipeline once - there is an outcome to record. - - +
+
- - Amount -
- setAmount(event.target.value)} - placeholder="24000" - inputMode="decimal" - autoComplete="off" - /> - -
-
- - - Expected close date - - -
- + + Expected close date + + + - - - - - - -
-
+ + + ); } diff --git a/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx b/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx index be440e9c7..1ff6eeea1 100644 --- a/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/deals/deals-bulk-actions.tsx @@ -1,7 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; -import Undo from "@carbon/icons-react/es/Undo"; import type { DealStage } from "@crm/db/enums"; import { Button } from "@crm/ui/components/button"; import { @@ -25,6 +23,7 @@ import { Spinner } from "@crm/ui/components/spinner"; import { Textarea } from "@crm/ui/components/textarea"; import { formatCount } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArchiveIcon, Undo2Icon } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { @@ -126,7 +125,7 @@ export function DealsBulkActions({ restore.mutate({ ids })}> - + Restore @@ -145,7 +144,7 @@ export function DealsBulkActions({ open={confirming} onOpenChange={setConfirming} title={`Delete ${deals(ids.length)} forever?`} - description="Everything filed against them — activity, notes, the amounts in your pipeline — goes too. This cannot be undone." + description="This deletes their activity, notes and pipeline amounts. You cannot undo this." onConfirm={() => purge.mutate({ ids })} /> @@ -188,7 +187,7 @@ export function DealsBulkActions({ archive.mutate({ ids })}> - + Archive diff --git a/apps/app/app/(app)/[slug]/deals/deals-table.tsx b/apps/app/app/(app)/[slug]/deals/deals-table.tsx index 0fb19ccb4..e02477371 100644 --- a/apps/app/app/(app)/[slug]/deals/deals-table.tsx +++ b/apps/app/app/(app)/[slug]/deals/deals-table.tsx @@ -1,6 +1,5 @@ "use client"; -import Archive from "@carbon/icons-react/es/Archive"; import { Button } from "@crm/ui/components/button"; import { DataTable, @@ -8,10 +7,12 @@ import { type DataTableFacet, } from "@crm/ui/components/data-table"; import { EmptyCellValue } from "@crm/ui/components/empty-cell"; +import { MetaRow } from "@crm/ui/components/meta-row"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { formatMoney } from "@crm/ui/lib/format"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo } from "react"; +import { ArchiveIcon } from "lucide-react"; +import { useMemo } from "react"; import { CLOSING_OPTIONS } from "@/components/crm/closing-window"; import { CompanyCell } from "@/components/crm/company-cell"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; @@ -20,6 +21,7 @@ import { OwnerCell } from "@/components/crm/owner-cell"; import { usePrefetchRecord } from "@/components/crm/record-sheet/record-prefetch"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { DealStageMenu } from "@/components/crm/stage-change"; +import { ListEmpty } from "@/components/data-table/list-empty"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; import { LocalDay, LocalRelativeTime } from "@/components/local-date-time"; @@ -37,21 +39,22 @@ const COLUMNS: DataTableColumn[] = [ header: "Deal", sortable: true, hideable: false, - width: "w-[24%]", - cell: (row) => {row.name}, + cell: (row) => ( + {row.name} + ), }, { id: "company", header: "Company", sortable: true, - width: "w-[18%]", + width: { sm: "40" }, cell: (row) => , }, { id: "stage", header: "Stage", sortable: true, - width: "w-[18%]", + width: { sm: "40" }, cell: (row) => , }, { @@ -59,7 +62,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Amount", sortable: true, align: "right", - width: "w-[12%]", + width: "36", hideBelow: "sm", cell: (row) => row.amountCents === null ? ( @@ -74,7 +77,7 @@ const COLUMNS: DataTableColumn[] = [ id: "owner", header: "Owner", sortable: true, - width: "w-[14%]", + width: "40", hideBelow: "md", cell: (row) => , }, @@ -82,7 +85,7 @@ const COLUMNS: DataTableColumn[] = [ id: "expectedCloseDate", header: "Close date", sortable: true, - width: "w-[12%]", + width: "32", hideBelow: "lg", cell: (row) => row.expectedCloseDate ? ( @@ -99,7 +102,7 @@ const COLUMNS: DataTableColumn[] = [ label: "Created date", sortable: true, align: "right", - width: "w-[10%]", + width: "28", defaultHidden: true, cell: (row) => ( @@ -112,7 +115,7 @@ const COLUMNS: DataTableColumn[] = [ header: "Last activity", sortable: true, align: "right", - width: "w-[12%]", + width: "32", hideBelow: "lg", cell: (row) => ( @@ -132,7 +135,7 @@ const ARCHIVED_COLUMN: DataTableColumn = { label: "Archived date", sortable: true, align: "right", - width: "w-[12%]", + width: "32", cell: (row) => ( {row.archivedAt ? ( @@ -169,11 +172,6 @@ export function DealsTable() { return selection.ids.filter((id) => matching.has(id)); }, [rows, input.archived, selection.ids]); - // biome-ignore lint/correctness/useExhaustiveDependencies: clearing on archived-mode change is the entire purpose of this effect. - useEffect(() => { - selection.clear(); - }, [input.archived]); - const toggleArchived = (next: boolean) => { selection.clear(); if (!next && query.sort === "archivedAt") query.setSort(""); @@ -229,16 +227,17 @@ export function DealsTable() { return ( } + search={} actions={ } @@ -249,6 +248,7 @@ export function DealsTable() { facets={facets} tabs={{ id: "status", + label: "Status", allLabel: "All deals", options: [ { value: "open", label: "Open" }, @@ -269,26 +269,40 @@ export function DealsTable() { getRowId={(row) => row.id} loading={deals.isFetching} onRowHover={(row) => prefetchRecord({ kind: "deal", id: row.id })} - onRowClick={(row) => openRecord({ kind: "deal", id: row.id })} + onRowClick={(row, orderedRows) => + openRecord( + { kind: "deal", id: row.id }, + orderedRows.map((entry) => ({ kind: "deal", id: entry.id })), + ) + } empty={ - input.archived ? "No archived deals." : "No deals match this view." + } meta={ input.archived || openPipelineCents === null ? undefined : ( - - {deals.data?.total ?? 0} deals ·{" "} - - {formatMoney(openPipelineCents, reportingCurrency)} - {" "} - open pipeline + + {deals.data?.total ?? 0} deals + + + {formatMoney(openPipelineCents, reportingCurrency)} + {" "} + open pipeline + {unconverted && unconverted.count > 0 ? ( - - {" "} - · {unconverted.count} not counted (no{" "} + + {unconverted.count} not counted (no{" "} {unconverted.currencies.join(", ")} rate) ) : null} - + ) } /> diff --git a/apps/app/app/(app)/[slug]/deals/page.tsx b/apps/app/app/(app)/[slug]/deals/page.tsx index f1d7ab44b..65f816d2d 100644 --- a/apps/app/app/(app)/[slug]/deals/page.tsx +++ b/apps/app/app/(app)/[slug]/deals/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellActions, @@ -9,7 +7,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -39,7 +39,7 @@ export default function DealsPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/layout.tsx b/apps/app/app/(app)/[slug]/layout.tsx index d562cd03d..976da6555 100644 --- a/apps/app/app/(app)/[slug]/layout.tsx +++ b/apps/app/app/(app)/[slug]/layout.tsx @@ -1,12 +1,14 @@ import { notFound, unstable_rethrow } from "next/navigation"; import { connection } from "next/server"; import { Suspense } from "react"; -import { AppHeader, AppHeaderFallback } from "@/components/app-header"; -import { AppIconRail, AppIconRailFallback } from "@/components/app-icon-rail"; +import { AccountMenu, AccountMenuFallback } from "@/components/account-menu"; import { QuickSwitcher } from "@/components/crm/quick-switcher"; import { RecordSheetHost } from "@/components/crm/record-sheet/record-sheet-host"; import { RecordStackBoundary } from "@/components/crm/record-sheet/record-stack"; +import { EnrichmentQueue } from "@/components/enrichment-queue"; import { MobileNavProvider } from "@/components/mobile-nav"; +import { WorkspaceNav, WorkspaceNavFallback } from "@/components/workspace-nav"; +import { WorkspaceShell } from "@/components/workspace-shell"; import { requireMailboxAccess } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -18,26 +20,28 @@ export default function AppLayout({ return ( -
- }> - - - -
- }> - + }> + + + } + end={ + }> + - {children} -
+ } + > + {children} + - - - + + + - - - -
+ + +
); @@ -54,7 +58,14 @@ async function loadWorkspace() { } } -async function WorkspaceHeader({ +async function WorkspaceSidebar() { + await connection(); + const workspace = await loadWorkspace(); + + return ; +} + +async function WorkspaceAccount({ params, }: Pick, "params">) { await connection(); @@ -68,7 +79,8 @@ async function WorkspaceHeader({ return ( - + ) { - }> + }> diff --git a/apps/app/app/(app)/[slug]/sales-dashboard.tsx b/apps/app/app/(app)/[slug]/sales-dashboard.tsx index d4a3162d8..5857e6603 100644 --- a/apps/app/app/(app)/[slug]/sales-dashboard.tsx +++ b/apps/app/app/(app)/[slug]/sales-dashboard.tsx @@ -6,9 +6,15 @@ import { CardHeader, CardTitle, } from "@crm/ui/components/card"; -import type { ChartConfig } from "@crm/ui/components/chart"; -import { DashboardRow, StatGroup } from "@crm/ui/components/dashboard"; +import { ChartSwatch } from "@crm/ui/components/chart-swatch"; +import { + DashboardPanel, + DashboardRow, + StatGroup, +} from "@crm/ui/components/dashboard"; +import { MetaRow } from "@crm/ui/components/meta-row"; import { StatCard, type StatDelta } from "@crm/ui/components/stat-card"; +import { CHART_CONFIG } from "@crm/ui/lib/chart-config"; import { formatCount, formatMoney, @@ -17,16 +23,20 @@ import { } from "@crm/ui/lib/format"; import Link from "next/link"; import type { ReactNode } from "react"; -import { AreaTrend, DonutStat } from "@/components/dashboard-charts"; -import { dealStageColor, dealStageLabel } from "@/lib/deal-stage"; +import { + AreaTrend, + DonutStat, + type SeriesConfig, +} from "@/components/dashboard-charts"; +import { dealStageChartColor, dealStageLabel } from "@/lib/deal-stage"; import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type Summary = RouterOutputs["dashboard"]["summary"]; -const TREND_CONFIG: ChartConfig = { - won: { label: "Closed won", color: "var(--success)" }, - created: { label: "New pipeline", color: "var(--chart-1)" }, +const TREND_CONFIG: SeriesConfig = { + won: { label: "Closed won", color: CHART_CONFIG.trend.won }, + created: { label: "New pipeline", color: CHART_CONFIG.trend.created }, }; function changeDelta( @@ -70,7 +80,7 @@ export function SalesDashboard({ summary }: { summary: Summary }) { key: stage.stage, label: dealStageLabel(stage.stage), value: stage.valueCents, - color: dealStageColor(stage.stage), + color: dealStageChartColor(stage.stage), count: stage.count, }, ] @@ -88,31 +98,50 @@ export function SalesDashboard({ summary }: { summary: Summary }) { wonPrevMonth.valueCents, "vs. last month", )} - description={`${formatCount(wonThisMonth.count, "deal")} · ${money(wonPrevMonth.valueCents)} last month`} + description={ + + {formatCount(wonThisMonth.count, "deal")} + {money(wonPrevMonth.valueCents)} last month + + } /> + + {formatCount(pipeline.totalDeals, "deal")} in progress + + + {money(closingThisMonthTotal.valueCents)} due this month + + + } /> + {performance.wins} won + {performance.losses} lost + + ) } /> ) : ( @@ -172,10 +200,11 @@ export function SalesDashboard({ summary }: { summary: Summary }) {
    {stageSlices.map((slice) => ( @@ -184,11 +213,7 @@ export function SalesDashboard({ summary }: { summary: Summary }) { href={`${workspaceUrl("/deals")}?stage=${slice.key}`} className="flex items-center gap-2.5 py-2 text-xs hover:underline" > - + {slice.label} @@ -227,7 +252,7 @@ function ChartPanel({ {title} {description ? {description} : null} -
    {children}
    + {children} ); } diff --git a/apps/app/app/(app)/[slug]/settings/agent-model.tsx b/apps/app/app/(app)/[slug]/settings/agent-model.tsx index 6584a94c9..30afad4d5 100644 --- a/apps/app/app/(app)/[slug]/settings/agent-model.tsx +++ b/apps/app/app/(app)/[slug]/settings/agent-model.tsx @@ -1,14 +1,6 @@ "use client"; -import ChevronDown from "@carbon/icons-react/es/ChevronDown"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; import { Command, CommandEmpty, @@ -17,13 +9,17 @@ import { CommandItem, CommandList, } from "@crm/ui/components/command"; +import { Field, FieldDescription } from "@crm/ui/components/field"; import { Icon } from "@crm/ui/components/icon"; +import { MetaRow } from "@crm/ui/components/meta-row"; import { Popover, PopoverContent, PopoverTrigger, } from "@crm/ui/components/popover"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { ChevronDownIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -46,7 +42,7 @@ function perMillion(rate: number): string { function priceHint(model: CatalogModel): string | null { if (!model.pricing) return null; - return `${perMillion(model.pricing.input)} in · ${perMillion(model.pricing.output)} out per 1M`; + return `${perMillion(model.pricing.input)} in, ${perMillion(model.pricing.output)} out per 1M`; } function contextHint(tokens: number): string { @@ -98,7 +94,7 @@ export function AgentModel() { const currentLabel = selectedId ? effectiveName - : `Default — ${effectiveName}`; + : `Default (${effectiveName})`; const choose = (id: string) => { setOpen(false); @@ -107,15 +103,12 @@ export function AgentModel() { }; return ( - - - Research agent - - The model the agent thinks with, routed through the Vercel AI Gateway. - - - - + + @@ -142,7 +135,7 @@ export function AgentModel() { data-checked={current === FOLLOW_DEFAULT} onSelect={() => choose(FOLLOW_DEFAULT)} > - Default — {defaultModel?.name ?? defaultId} + Default ({defaultModel?.name ?? defaultId}) @@ -172,16 +165,23 @@ export function AgentModel() { -

    - {unavailable - ? `Could not reach the AI Gateway to list models. The agent is still running ${effectiveId}.` - : effective - ? `${effectiveId} · ${contextHint(effective.contextWindowTokens)}${ - priceHint(effective) ? ` · ${priceHint(effective)}` : "" - }` - : effectiveId} -

    -
    -
    + {unavailable ? ( + + Could not reach the AI Gateway to list models. The agent is still + running {effectiveId}. + + ) : ( + + {effectiveId} + {effective ? ( + {contextHint(effective.contextWindowTokens)} + ) : null} + {effective && priceHint(effective) ? ( + {priceHint(effective)} + ) : null} + + )} + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx index a6737ee7b..5db38b34e 100644 --- a/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx +++ b/apps/app/app/(app)/[slug]/settings/api-keys/api-keys-table.tsx @@ -1,6 +1,5 @@ "use client"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; import { AlertDialog, AlertDialogAction, @@ -17,6 +16,7 @@ import { Button } from "@crm/ui/components/button"; import { DataTable, type DataTableColumn } from "@crm/ui/components/data-table"; import { Icon } from "@crm/ui/components/icon"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -42,7 +42,6 @@ function columns( header: "Name", sortable: true, hideable: false, - width: "w-[28%]", cell: (row) => ( {row.name ?? "Untitled key"} @@ -52,17 +51,17 @@ function columns( { id: "start", header: "Key", - width: "w-[20%]", + width: "36", hideBelow: "sm", cell: (row) => ( - {row.start ? `${row.start}…` : "—"} + {row.start ? `${row.start}…` : ""} ), }, { id: "createdAt", header: "Created", sortable: true, - width: "w-[16%]", + width: "28", hideBelow: "md", cell: (row) => ( @@ -75,7 +74,7 @@ function columns( header: "Last used", label: "Last used date", sortable: true, - width: "w-[16%]", + width: "28", hideBelow: "lg", cell: (row) => ( @@ -91,7 +90,7 @@ function columns( id: "expiresAt", header: "Expires", sortable: true, - width: "w-[14%]", + width: "24", hideBelow: "lg", cell: (row) => row.expiresAt ? ( @@ -114,12 +113,12 @@ function columns( label: "Actions", hideable: false, align: "right", - width: "w-[6%]", + width: "12", cell: (row) => ( ); @@ -69,11 +72,13 @@ export function CreateApiKeySheet() { } function CreateApiKeyForm() { + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); const nameId = useId(); const expirationId = useId(); + const triggerRef = useRef(null); const [open, setOpen] = useQueryState( SEARCH_PARAM.dialog.create, @@ -88,22 +93,33 @@ function CreateApiKeyForm() { onSuccess: async (apiKey) => { await cache.apiKeys(); await setOpen(null); - setName(""); - setExpiration("90"); + reset(); setCreated(apiKey); }, onError: (error) => toast.error(error.message), }), ); + const reset = () => { + validation.reset(); + setName(""); + setExpiration("90"); + }; + return ( <> - setOpen(next || null)}> + setOpen(next || null)} + > - + - + New API key @@ -112,11 +128,13 @@ function CreateApiKeyForm() { -
    { event.preventDefault(); + if (!validation.validate(event.currentTarget)) return; create.mutate({ name: name.trim(), expiresInDays: @@ -124,67 +142,70 @@ function CreateApiKeyForm() { }); }} > - - - Name - setName(event.target.value)} - placeholder="CI pipeline" - maxLength={64} - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - required - /> - - Something you will recognise later, like where it runs. - - - - - Expires - - - -
    - - - - - - - -
    -
    + + + { + event.preventDefault(); + triggerRef.current?.focus(); + }} onOpenChange={(next) => { if (!next) setCreated(null); }} diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx index 1baa59c40..16632e61c 100644 --- a/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx +++ b/apps/app/app/(app)/[slug]/settings/api-keys/created-api-key-dialog.tsx @@ -9,11 +9,13 @@ import { DialogHeader, DialogTitle, } from "@crm/ui/components/dialog"; +import { useDiscardChanges } from "@crm/ui/components/discard-changes"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "@crm/ui/components/input-group"; +import { type ComponentProps, useState } from "react"; import type { RouterOutputs } from "@/lib/trpc/types"; import { CopyValue } from "../copy-value"; @@ -22,13 +24,41 @@ type CreatedApiKey = RouterOutputs["apiKeys"]["create"]; export function CreatedApiKeyDialog({ apiKey, onOpenChange, + onCloseAutoFocus, }: { apiKey: CreatedApiKey | null; onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: ComponentProps["onCloseAutoFocus"]; }) { + const [copiedId, setCopiedId] = useState(null); + const guard = useDiscardChanges({ + open: apiKey !== null, + dirty: apiKey !== null && copiedId !== apiKey.id, + onOpenChange: (next) => { + if (!next) setCopiedId(null); + onOpenChange(next); + }, + wording: { + title: "Close without copying the key?", + description: + "This is the only time the key is shown. Copy it before you close.", + confirmLabel: "Close anyway", + keepLabel: "Go back to the key", + }, + }); + return ( - - + + event.preventDefault()} + onCloseAutoFocus={(event) => { + if (apiKey !== null) { + event.preventDefault(); + return; + } + onCloseAutoFocus?.(event); + }} + > {apiKey?.name ?? "API key"} created @@ -43,14 +73,19 @@ export function CreatedApiKeyDialog({ className="font-mono" /> - + setCopiedId(apiKey?.id ?? null)} + /> - + + {guard.dialog} ); } diff --git a/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx b/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx index 9503fda86..9ed386f20 100644 --- a/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/api-keys/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellActions, @@ -9,7 +7,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -25,13 +25,13 @@ export default function ApiKeysSettingsPage({ searchParams, }: PageProps<"/[slug]/settings/api-keys">) { return ( - + API Keys - Personal keys for calling the CRM API. Each one acts as you — - anything it can read or change is exactly what you can. + Personal keys for calling the CRM API. Each key has the same access + as your account. @@ -41,7 +41,7 @@ export default function ApiKeysSettingsPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/settings/archive-retention.tsx b/apps/app/app/(app)/[slug]/settings/archive-retention.tsx index d43eef321..17fe3dba8 100644 --- a/apps/app/app/(app)/[slug]/settings/archive-retention.tsx +++ b/apps/app/app/(app)/[slug]/settings/archive-retention.tsx @@ -1,24 +1,12 @@ "use client"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@crm/ui/components/field"; +import { Field, FieldLabel } from "@crm/ui/components/field"; import { Input } from "@crm/ui/components/input"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -27,18 +15,15 @@ export function ArchiveRetention() { const trpc = useTRPC(); const cache = useCrmCache(); const daysId = useId(); - const [draft, setDraft] = useState(""); + const [draft, setDraft] = useState(null); const retention = useQuery(trpc.settings.archiveRetention.queryOptions()); - useEffect(() => { - if (retention.data) setDraft(String(retention.data.days)); - }, [retention.data]); - const save = useMutation( trpc.settings.setArchiveRetention.mutationOptions({ onSuccess: async () => { await cache.settings(); + setDraft(null); toast.success("Archive retention saved."); }, onError: (error) => toast.error(error.message), @@ -47,63 +32,55 @@ export function ArchiveRetention() { if (!retention.data) return null; - const days = Number.parseInt(draft, 10); + const value = draft ?? String(retention.data.days); + const days = Number.parseInt(value, 10); const unchanged = days === retention.data.days; return ( - - - Archived records - - Deleted records are archived and hidden, then pruned for good. - - - - - - - - -
    { - event.preventDefault(); - if (!Number.isFinite(days)) { - toast.error("Enter a number of days."); - return; - } - save.mutate({ days }); - }} + - - - - Prune archived records after - - setDraft(event.target.value)} - /> - Days. 180 is the default. - - - -
    -
    + {save.isPending ? : null} + Save + + } + control="compact" + > +
    { + event.preventDefault(); + if (!Number.isFinite(days)) { + toast.error("Enter a number of days."); + return; + } + save.mutate({ days }); + }} + > + + Prune archived records after + setDraft(event.target.value)} + /> + +
    + ); } diff --git a/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx b/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx index c6114d515..996ba64ed 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx @@ -1,4 +1,4 @@ -import { Spinner } from "@crm/ui/components/spinner"; +import { PageShell, PageShellFallback } from "@crm/ui/components/page-shell"; import { cn } from "@crm/ui/lib/utils"; export function ConnectionPage({ @@ -11,24 +11,15 @@ export function ConnectionPage({ children: React.ReactNode; }) { return ( -
    -
    - {children} -
    -
    + + {children} + ); } export function ConnectionPageLoading() { - return ( -
    - -
    - ); + return ; } diff --git a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx index 20f3fca8b..04630bf30 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx @@ -1,7 +1,5 @@ "use client"; -import Launch from "@carbon/icons-react/es/Launch"; -import Warning from "@carbon/icons-react/es/Warning"; import { authClient } from "@crm/auth/client"; import { SYNC_SCOPES } from "@crm/auth/scopes"; import { @@ -23,22 +21,20 @@ import { } from "@crm/ui/components/alert-dialog"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; import { Icon } from "@crm/ui/components/icon"; -import { Label } from "@crm/ui/components/label"; +import { + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@crm/ui/components/page-shell"; +import { SettingRow, SettingRows } from "@crm/ui/components/setting-rows"; +import { SettingsCard, SettingsColumn } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; +import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { LocalRelativeTime } from "@/components/local-date-time"; @@ -104,22 +100,29 @@ function failureSignature( return failures.sort().join("|"); } +function GoogleHeader() { + return ( + + + Google + + Read-only Gmail and Calendar. Only conversations with companies in the + CRM are stored. + + + + ); +} + function GoogleUnavailable() { return ( - - - -
    - Google - -
    -
    - - Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the root .env file - and restart. - -
    -
    + + } + description="Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the root .env file and restart." + /> ); } @@ -160,51 +163,41 @@ function ConnectGoogle({ } return ( - - - -
    - Google - -
    -
    - - Read-only Gmail and Calendar. Only conversations with companies in the - CRM are stored. - - - - - -
    - + + } + description="Connect the Google account you sign in with." + action={ + + } + > {connectError ? ( - - - - Google did not finish connecting - - {CONNECT_ERRORS.get(connectError) ?? - "Google returned an error before the connection was made. Try again."} - - - + + + Google did not finish connecting + + {CONNECT_ERRORS.get(connectError) ?? + "Google returned an error before the connection was made. Try again."} + + ) : null} -
    +
    ); } @@ -273,13 +266,27 @@ export function GoogleConnection({ }), ); - if (!status.data) return null; + if (!status.data) return ; const { sources, hasRefreshToken, configured, linked, required } = status.data; - if (!configured) return ; - if (!linked) return ; + if (!configured) { + return ( + <> + + + + ); + } + if (!linked) { + return ( + <> + + + + ); + } const failing = sources.filter( (source) => source.status === "NEEDS_RECONNECT" || source.lastError, @@ -293,131 +300,145 @@ export function GoogleConnection({ const healthy = failing.length === 0 && hasRefreshToken; return ( - - - -
    - Google + <> + + + -
    -
    - - Meetings and email threads land on the matching company as they - happen. - - - - - -
    - - - {!hasRefreshToken ? ( - - - Google did not return a refresh token - Sign out and back in. - - ) : failing.length > 0 ? ( - failing.map((source) => { - const { summary, url } = explain( - source.lastError ?? "Google needs reconnecting.", - ); - - return ( - - - - {SOURCES[source.source].label} sync failed - - {summary} - - {url ? ( - - - - ) : null} - - ); - }) - ) : ( -

    - {lastSyncedAt ? ( + } + description="Meetings and email threads land on the matching company as they happen." + hint={ + lastSyncedAt ? ( <> Last checked ) : ( "Waiting for the first check" - )} -

    - )} - - {sources.map((source) => { - const copy = SOURCES[source.source]; - - return ( -
    syncNow.mutate()} > - - - - setAutoCreate.mutate({ source: source.source, enabled }) - } - /> + {syncNow.isPending ? "Checking…" : "Check now"} + + } + > + {!hasRefreshToken ? ( + + + Google did not return a refresh token + Sign out and back in. + + ) : failing.length > 0 ? ( +
    + {failing.map((source) => { + const { summary, url } = explain( + source.lastError ?? "Google needs reconnecting.", + ); + + return ( + + + + {SOURCES[source.source].label} sync failed + + {summary} + + {url ? ( + + + + ) : null} + + ); + })}
    - ); - })} - - -
    + ) : null} + + + + + {sources.map((source) => { + const copy = SOURCES[source.source]; + + return ( + ( + + setAutoCreate.mutate({ + source: source.source, + enabled, + }) + } + /> + )} + /> + ); + })} + + + + + Manage in your Google account + + } + action={ - - Delete synced data? + Revoke Google access? - Every email and meeting brought in from Google is removed - from the CRM. The next check starts from now, so nothing - deleted here comes back. + {required + ? "You will be signed out, and you cannot use the CRM again until you grant access." + : "New email and meetings stop arriving. Everything already synced stays, and you can connect Google again from this page."} @@ -425,28 +446,40 @@ export function GoogleConnection({ Cancel purge.mutate()} + onClick={() => revoke.mutate()} > - Delete + Revoke - + } + /> + + - - Revoke Google access? + Delete synced data? - {required - ? "You will be signed out, and you cannot use the CRM again until you grant access." - : "New email and meetings stop arriving. Everything already synced stays, and you can connect Google again from this page."} + Every email and meeting brought in from Google is removed + from the CRM. The next check starts from now, so nothing + deleted here comes back. @@ -454,26 +487,16 @@ export function GoogleConnection({ Cancel revoke.mutate()} + onClick={() => purge.mutate()} > - Revoke + Delete - - -
    -
    - - + } + /> + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/connections/mcp-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/mcp-connection.tsx index 0804da1e3..d893785ff 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/mcp-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/mcp-connection.tsx @@ -1,7 +1,5 @@ "use client"; -import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; -import Warning from "@carbon/icons-react/es/Warning"; import { Accordion, AccordionContent, @@ -22,19 +20,12 @@ import { import { Badge } from "@crm/ui/components/badge"; import { Button } from "@crm/ui/components/button"; import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, CardPanel, CardPanelDetail, CardPanelGroup, CardPanelGroupContent, CardPanelGroupHeader, CardPanelScroll, - CardTitle, CardToolbar, } from "@crm/ui/components/card"; import { CodeBlock } from "@crm/ui/components/code-block"; @@ -45,18 +36,29 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@crm/ui/components/dropdown-menu"; +import { FieldLegend, FieldSet } from "@crm/ui/components/field"; import { Icon } from "@crm/ui/components/icon"; import { InlineReferenceText } from "@crm/ui/components/inline-reference-text"; +import { PageSection } from "@crm/ui/components/page-section"; +import { + PageShellActions, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@crm/ui/components/page-shell"; import { ProviderMark } from "@crm/ui/components/provider-mark"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { ToggleGroup, ToggleGroupItem } from "@crm/ui/components/toggle-group"; import { useMountEffect } from "@crm/ui/hooks/use-mount-effect"; import type { ProviderBrand } from "@crm/ui/lib/provider-brand"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CircleAlertIcon, EllipsisVerticalIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useId, useState } from "react"; import { toast } from "sonner"; import { MCP_CONNECTION_STATUS } from "@/lib/mcp-connection-status"; import { useTRPC } from "@/lib/trpc/client"; @@ -157,6 +159,7 @@ export function McpConnection({ ); const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); const [toolFilter, setToolFilter] = useState("all"); + const accessLegendId = useId(); const start = useMutation( trpc.mcp.start.mutationOptions({ onSuccess: ({ authorizationUrl }) => { @@ -201,48 +204,50 @@ export function McpConnection({ return ( {connecting ? : null} -
    -
    - -

    - - {entry.name} -

    -

    - Deployed agents receive only reviewed tools. -

    -
    - {connected && canManage ? ( -
    - - - - - - - setConfirmingDisconnect(true)} - > - Disconnect {entry.name} - - - - -
    - ) : null} +
    + + + + + + {entry.name} + + + Deployed agents receive only reviewed tools. + + + {connected && canManage ? ( + + + + + + + + setConfirmingDisconnect(true)} + > + Disconnect {entry.name} + + + + + + ) : null} +
    {presentation.overview ? ( @@ -250,121 +255,127 @@ export function McpConnection({ ) : null} {connecting ? ( - - + - - + } + description={`Waiting for ${entry.name} to confirm the connection.`} + /> ) : null} {!connected && !connecting ? ( - - - Access - - Choose what deployed agents can do. - - - - {!configured ? ( - - - - MCP connections are not configured - - Set MCP_ENCRYPTION_KEY in the root .env file. Then restart the - API. - - - - ) : null} - - {connection?.status === "error" ? ( - - - - {entry.name} needs authorization - - Reconnect {entry.name}. The stored access is no longer usable. - - - - ) : null} - - {!connected && configured && canManage ? ( - -

    - What can agents do in {entry.name}? -

    - { - if (value === "read_only" || value === "read_write") - setMode(value); - }} - > - Read only - - Read and write - - -

    - {mode === "read_only" - ? `Agents read ${entry.name} and change nothing.` - : `Agents read ${entry.name} and make approved changes.`} -

    -
    - ) : null} - - - {!connected && configured && canManage ? ( - - ) : null} - -
    + ) : undefined + } + > + {!configured || + connection?.status === "error" || + (configured && canManage) ? ( +
    + {!configured ? ( + + + MCP connections are not configured + + Set MCP_ENCRYPTION_KEY in the root .env file. Then restart + the API. + + + ) : null} + + {connection?.status === "error" ? ( + + + {entry.name} needs authorization + + Reconnect {entry.name}. The stored access is no longer + usable. + + + ) : null} + + {configured && canManage ? ( +
    + + What can agents do in {entry.name}? + + { + if (value === "read_only" || value === "read_write") + setMode(value); + }} + > + + Read only + + + Read and write + + +
    + ) : null} +
    + ) : null} + ) : null} {connected ? ( - - - Tool access - - {presentation.pendingReviewCount > 0 - ? `${presentation.pendingReviewCount} tools need review. Open a tool to inspect it.` - : "All tools are reviewed. Open a tool to inspect it."} - - {presentation.pendingReadTools.length > 0 && canManage ? ( - - - - ) : null} - + 0 + ? `${presentation.pendingReviewCount} tools need review. Open a tool to inspect it.` + : "All tools are reviewed. Open a tool to inspect it." + } + action={ + presentation.pendingReadTools.length > 0 && canManage ? ( + + ) : undefined + } + > - + ) : null} {overview.failure ? ( - + {overview.failure.title} {overview.failure.code} @@ -543,15 +554,14 @@ function ConnectionDetails({ ) : null} {overview.scopeGroups.length > 0 ? ( - - - Permissions - - - {overview.scopeCount} scopes - - - + + {overview.scopeCount} scopes + + } + > - + ) : null} {overview.agents.length > 0 ? ( - - - Used by - - -
    - {overview.agents.map((agent) => ( - - - - {agent.name} - - {agent.description ? ( - - {agent.description} - - ) : null} + +
    + {overview.agents.map((agent) => ( + + + + {agent.name} - {agent.statusLabel} - - ))} -
    - - + {agent.description ? ( + + {agent.description} + + ) : null} +
    + {agent.statusLabel} + + ))} +
    + ) : null} ); diff --git a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx index 6be4afa5e..1e27745da 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx @@ -1,6 +1,5 @@ "use client"; -import Warning from "@carbon/icons-react/es/Warning"; import { authClient } from "@crm/auth/client"; import { MICROSOFT_SYNC_SCOPES } from "@crm/auth/scopes"; import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; @@ -17,22 +16,20 @@ import { } from "@crm/ui/components/alert-dialog"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; import { Icon } from "@crm/ui/components/icon"; -import { Label } from "@crm/ui/components/label"; +import { + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@crm/ui/components/page-shell"; +import { SettingRow, SettingRows } from "@crm/ui/components/setting-rows"; +import { SettingsCard, SettingsColumn } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery } from "@tanstack/react-query"; -import Link from "next/link"; +import { CircleAlertIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { LocalRelativeTime } from "@/components/local-date-time"; @@ -49,22 +46,29 @@ const CONNECT_ERRORS = new Map([ ], ]); +function MicrosoftHeader() { + return ( + + + Microsoft + + Read-only Outlook mail. Only conversations with companies in the CRM + are stored. + + + + ); +} + function MicrosoftUnavailable() { return ( - - - -
    - Microsoft - -
    -
    - - Set MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET in the root .env - file and restart. - -
    -
    + + } + description="Set MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET in the root .env file and restart." + /> ); } @@ -98,51 +102,41 @@ function ConnectMicrosoft({ } return ( - - - -
    - Microsoft - -
    -
    - - Read-only Outlook mail. Only conversations with companies in the CRM - are stored. - - - - - -
    - + + } + description="Connect the Microsoft account you sign in with." + action={ + + } + > {connectError ? ( - - - - Microsoft did not finish connecting - - {CONNECT_ERRORS.get(connectError) ?? - "Microsoft returned an error before the connection was made. Try again."} - - - + + + Microsoft did not finish connecting + + {CONNECT_ERRORS.get(connectError) ?? + "Microsoft returned an error before the connection was made. Try again."} + + ) : null} -
    +
    ); } @@ -198,14 +192,26 @@ export function MicrosoftConnection({ }), ); - if (!status.data) return null; + if (!status.data) return ; const { sources, hasRefreshToken, configured, linked, required } = status.data; - if (!configured) return ; + if (!configured) { + return ( + <> + + + + ); + } if (!linked) { - return ; + return ( + <> + + + + ); } const failing = sources.filter( @@ -220,105 +226,116 @@ export function MicrosoftConnection({ const healthy = failing.length === 0 && hasRefreshToken; return ( - - - -
    - Microsoft + <> + + + -
    -
    - - Email threads land on the matching company as they happen. - - - - - -
    - - - {!hasRefreshToken ? ( - - - Microsoft did not return a refresh token - Sign out and back in. - - ) : failing.length > 0 ? ( - failing.map((source) => ( - - - Email sync failed - - {source.lastError ?? "Microsoft needs reconnecting."} - - - )) - ) : ( -

    - {lastSyncedAt ? ( + } + description="Email threads land on the matching company as they happen." + hint={ + lastSyncedAt ? ( <> Last checked ) : ( "Waiting for the first check" - )} -

    - )} - - {sources.map((source) => ( -
    - - - - setAutoCreate.mutate({ source: source.source, enabled }) - } - /> -
    - ))} - - -
    + {syncNow.isPending ? "Checking…" : "Check now"} + + } + > + {!hasRefreshToken ? ( + + + Microsoft did not return a refresh token + Sign out and back in. + + ) : failing.length > 0 ? ( +
    + {failing.map((source) => ( + + + Email sync failed + + {source.lastError ?? "Microsoft needs reconnecting."} + + + ))} +
    + ) : null} + + + + + {sources.map((source) => ( + ( + + setAutoCreate.mutate({ source: source.source, enabled }) + } + /> + )} + /> + ))} + + + + + Manage in your Microsoft account + + } + action={ - - Delete synced data? + Disconnect Microsoft? - Every email brought in from Outlook is removed from the CRM. - The next check starts from now, so nothing deleted here - comes back. + {required + ? "You will be signed out, and you cannot use the CRM again until you grant access." + : "New email stops arriving. Everything already synced stays, and you can connect Microsoft again from this page."}{" "} + Remove this app from your Microsoft account to withdraw + consent. @@ -326,30 +343,40 @@ export function MicrosoftConnection({ Cancel purge.mutate()} + onClick={() => revoke.mutate()} > - Delete + Disconnect - + } + /> + + - - Disconnect Microsoft? + Delete synced data? - {required - ? "You will be signed out, and you cannot use the CRM again until you grant access." - : "New email stops arriving. Everything already synced stays, and you can connect Microsoft again from this page."}{" "} - Microsoft has no way for us to withdraw the consent itself — - remove this app from your Microsoft account to do that. + Every email brought in from Outlook is removed from the CRM. + The next check starts from now, so nothing deleted here + comes back. @@ -357,26 +384,16 @@ export function MicrosoftConnection({ Cancel revoke.mutate()} + onClick={() => purge.mutate()} > - Disconnect + Delete - - -
    -
    -
    -
    + } + /> + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/connections/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/page.tsx index 78c0df540..9ad51cd68 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/page.tsx @@ -4,7 +4,6 @@ import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import SlackLogo from "@crm/ui/components/brand-logos/slack"; import { Button } from "@crm/ui/components/button"; import { ProviderMark } from "@crm/ui/components/provider-mark"; -import { Spinner } from "@crm/ui/components/spinner"; import { providerBrand } from "@crm/ui/lib/provider-brand"; import type { Metadata } from "next"; import Link from "next/link"; @@ -12,6 +11,7 @@ import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { AddConnectionDialog } from "./add-connection-dialog"; +import { ConnectionPage, ConnectionPageLoading } from "./connection-page"; export const metadata: Metadata = { title: "Connections" }; @@ -108,9 +108,9 @@ async function ConnectionsSettingsPageContent({ ]; return ( -
    + {rows.length > 0 ? ( -
    + <>

    @@ -132,9 +132,9 @@ async function ConnectionsSettingsPageContent({ ))}

    -
    + ) : ( -
    +

    Nothing is connected yet @@ -189,16 +189,12 @@ async function ConnectionsSettingsPageContent({ brand: providerBrand(vendor.slug, vendor.name), }))} /> -

    + ); } function ConnectionsFallback() { - return ( -
    - -
    - ); + return ; } function ConnectionCard({ diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx index 7eff64f4a..597044df6 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx @@ -1,5 +1,3 @@ -import Close from "@carbon/icons-react/es/Close"; -import Warning from "@carbon/icons-react/es/Warning"; import { describeSlackScopes, SLACK_REQUESTED_SCOPES, @@ -17,6 +15,16 @@ import { import SlackLogo from "@crm/ui/components/brand-logos/slack"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; +import { PageSection } from "@crm/ui/components/page-section"; +import { + PageShellActions, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@crm/ui/components/page-shell"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { CircleAlertIcon, XIcon } from "lucide-react"; import Link from "next/link"; import { Suspense } from "react"; import { NewAgentDialog } from "@/components/agent-builder/new-agent-dialog"; @@ -76,21 +84,23 @@ async function SlackConnectionPageContent({ return status.connected ? ( ) : ( - -
    -
    - -

    Slack

    - - Not connected - -
    -

    - Connecting Slack gives the CRM a way in and a way out. What it - actually does with that is up to you afterwards, one automation at a - time. -

    -
    + + + + + + Slack + + + Connecting Slack gives the CRM a way in and a way out. What it + actually does with that is up to you afterwards, one automation at a + time. + + + + + + -
    +
    -

    +

    You approve the workspace in Slack. You can disconnect it here at any time.

    -
    -
    -

    - Afterwards, most teams start with one of these -

    -

    - Suggestions, not settings. None of them exist until you pick one and - switch it on. -

    -
    +
    {suggestions.map(([name, description]) => ( -
    +

    {name}

    -

    +

    {description}

    ))}
    -
    + ); } @@ -183,24 +190,30 @@ function ConnectedSlack({ : [...drift.missing, SLACK_USER_GRANT]; return ( -
    -
    - -

    Slack

    - - {status.workspace ?? "Connected"} - + + + + + Slack + + + {status.canManage + ? "Here is what Slack gave us. Agents only post where their automation says." + : "Here is what Slack gave us. Only an owner or an admin can disconnect it."} + + + + -
    -

    - {status.canManage - ? "Here is what Slack gave us. Agents only post where their automation says." - : "Here is what Slack gave us. Only an owner or an admin can disconnect it."} -

    -
    + + -
    -
    -
    -

    Agents that use Slack

    -

    - Built in chat, not here. Open one to change it. -

    -
    + -
    -
    + } + > +
    {agents.length === 0 ? (

    No deployed agents use Slack yet. @@ -240,11 +250,11 @@ function ConnectedSlack({ >

    {agent.name}

    -

    +

    {agent.description}

    - + @@ -260,8 +270,8 @@ function ConnectedSlack({ Describe another agent in chat
    -
    -
    + +

    {status.people.reviewed === 0 ? "No workspace people have been reviewed yet." @@ -291,9 +301,9 @@ function MissingGrant({ ); return ( -

    +
    - + {privateChannels ? "Comp AI cannot reach private channels" @@ -305,7 +315,7 @@ function MissingGrant({ {missing.map((entry) => (
  • @@ -334,8 +344,7 @@ function PlainList({ tone: string; }) { return ( -
    -

    {title}

    +
    {items.map((item) => (
    @@ -348,6 +357,6 @@ function PlainList({
    ))}
    -
    + ); } diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx index d50a2b850..762852011 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx @@ -1,10 +1,9 @@ import SlackLogo from "@crm/ui/components/brand-logos/slack"; -import { Spinner } from "@crm/ui/components/spinner"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; -import { ConnectionPage } from "../../connection-page"; +import { ConnectionPage, ConnectionPageLoading } from "../../connection-page"; import { SlackPeopleMatches } from "./slack-people-matches"; type SlackPeoplePageProps = { @@ -13,13 +12,7 @@ type SlackPeoplePageProps = { export default function SlackPeoplePage(props: SlackPeoplePageProps) { return ( - - - - } - > + }> ); diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx index bbf9d9ff0..e6c06b326 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx @@ -1,5 +1,5 @@ "use client"; -import Search from "@carbon/icons-react/es/Search"; + import { AlertDialog, AlertDialogCancel, @@ -20,7 +20,9 @@ import { InputGroupAddon, InputGroupInput, } from "@crm/ui/components/input-group"; +import { PageSection } from "@crm/ui/components/page-section"; import { useMutation } from "@tanstack/react-query"; +import { SearchIcon } from "lucide-react"; import { useDeferredValue, useState } from "react"; import { toast } from "sonner"; import { @@ -72,14 +74,10 @@ export function SlackChannels() { const canInviteItself = channels.canInviteItself; return ( -
    -
    -
    -

    Channels Comp AI can reach

    -

    - Agents pick from this list. -

    -
    + refresh.mutate()} @@ -88,8 +86,8 @@ export function SlackChannels() { > {refreshing ? "Refreshing…" : "Refresh"} -
    - + } + > {channels.stalled ? (

    Comp AI is not reading Slack right now. The list can be out of date. @@ -99,9 +97,10 @@ export function SlackChannels() { {rows.length > 0 || query ? ( - + setQuery(event.target.value)} placeholder="Search channels" value={query} @@ -144,7 +143,7 @@ export function SlackChannels() { onConfirm={() => asking && void joinAction.run(asking.id)} status={joinAction.status} /> -

    + ); } diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx index cd848f750..2e6c9d2c0 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx @@ -1,8 +1,5 @@ "use client"; -import Checkmark from "@carbon/icons-react/es/Checkmark"; -import Close from "@carbon/icons-react/es/Close"; -import Warning from "@carbon/icons-react/es/Warning"; import { Accordion, AccordionContent, @@ -10,6 +7,8 @@ import { AccordionTrigger, } from "@crm/ui/components/accordion"; import { Icon } from "@crm/ui/components/icon"; +import { PageSection } from "@crm/ui/components/page-section"; +import { CheckIcon, CircleAlertIcon, XIcon } from "lucide-react"; export type ScopeLine = { scope: string; @@ -34,16 +33,11 @@ export function SlackScopeGroups({ withheld: ScopeLine[]; }) { return ( -
    -
    -

    {title}

    -

    - Broad means the whole workspace, not one channel. Open a group to see - the details. -

    -
    - - + + {groups.map((group) => { const broad = group.scopes.filter((entry) => entry.sensitive).length; @@ -67,7 +61,7 @@ export function SlackScopeGroups({
  • {entry.grant} @@ -88,7 +82,7 @@ export function SlackScopeGroups({ {entry.grant} @@ -103,6 +97,6 @@ export function SlackScopeGroups({
  • ))} - + ); } diff --git a/apps/app/app/(app)/[slug]/settings/copy-value.tsx b/apps/app/app/(app)/[slug]/settings/copy-value.tsx index 380191d3f..bcfb99cb6 100644 --- a/apps/app/app/(app)/[slug]/settings/copy-value.tsx +++ b/apps/app/app/(app)/[slug]/settings/copy-value.tsx @@ -1,11 +1,19 @@ "use client"; -import Copy from "@carbon/icons-react/es/Copy"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; +import { CopyIcon } from "lucide-react"; import { toast } from "sonner"; -export function CopyValue({ value, label }: { value: string; label: string }) { +export function CopyValue({ + value, + label, + onCopied, +}: { + value: string; + label: string; + onCopied?: () => void; +}) { const unavailable = () => toast.error( `Could not copy the ${label.toLowerCase()}. Select it instead.`, @@ -26,11 +34,14 @@ export function CopyValue({ value, label }: { value: string; label: string }) { clipboard .writeText(value) - .then(() => toast.success(`${label} copied.`)) + .then(() => { + toast.success(`${label} copied.`); + onCopied?.(); + }) .catch(unavailable); }} > - + Copy {label.toLowerCase()} ); diff --git a/apps/app/app/(app)/[slug]/settings/currencies/currency-settings.tsx b/apps/app/app/(app)/[slug]/settings/currencies/currency-settings.tsx index 5d29edaaf..433f0c1eb 100644 --- a/apps/app/app/(app)/[slug]/settings/currencies/currency-settings.tsx +++ b/apps/app/app/(app)/[slug]/settings/currencies/currency-settings.tsx @@ -1,18 +1,17 @@ "use client"; -import { CURRENCIES } from "@crm/db/currency"; +import { CURRENCIES, isCurrencyCode } from "@crm/db/currency"; +import { Badge } from "@crm/ui/components/badge"; import { Button } from "@crm/ui/components/button"; import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { CardTableEmpty } from "@crm/ui/components/card-table"; -import { Field, FieldDescription, FieldLabel } from "@crm/ui/components/field"; + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { useFormSheetValidation } from "@crm/ui/components/form-sheet"; import { Input } from "@crm/ui/components/input"; +import { OptionLabel } from "@crm/ui/components/option-label"; import { Select, SelectContent, @@ -20,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "@crm/ui/components/select"; +import { SettingsCard, SettingsColumn } from "@crm/ui/components/settings-card"; import { SimpleTable, type SimpleTableColumn, @@ -53,6 +53,7 @@ const USAGE_COLUMNS: SimpleTableColumn[] = [ ]; export function CurrencySettings() { + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); @@ -83,6 +84,7 @@ export function CurrencySettings() { trpc.currency.setManualRate.mutationOptions({ onSuccess: async () => { await invalidate(); + validation.reset(); setDraftCurrency(""); setDraftRate(""); toast.success("Rate saved."); @@ -130,135 +132,80 @@ export function CurrencySettings() { refresh.isPending; return ( -
    - - - Reporting currency - - Every total, chart and average in the CRM is expressed in this - currency. Each deal keeps the currency it was sold in. - - - - - - Report totals in - - - {canManage - ? "Changing this re-converts every deal at today's rates. Figures already reported will move." - : "Only an owner or an admin can change how money is reported."} - - - - - - - - Exchange rates - - How many {reportingCurrency} one unit of each currency buys. Fetched - daily from open.er-api.com; a rate you enter here wins. - - - - - - - -
    { - event.preventDefault(); - const rate = Number.parseFloat(draftRate); - if (!Number.isFinite(rate) || rate <= 0) { - toast.error("A rate has to be a number greater than zero."); - return; - } - setRate.mutate({ currency: draftCurrency, rate }); - }} + + + + Report totals in + - - - - - {CURRENCIES.filter( - (entry) => entry.code !== reportingCurrency, - ).map((entry) => ( - - {entry.code} · {entry.name} - - ))} - - - - - - - 1 {draftCurrency || "unit"} = ? {reportingCurrency} - - setDraftRate(event.target.value)} - /> - - - - -
    + + + + + {CURRENCIES.map((entry) => ( + + + + ))} + + + + + + Fetched daily from open.er-api.com. Last fetched{" "} + . + + ) : ( + "Fetched daily from open.er-api.com." + ) + } + action={ + + } + control={rates.length === 0 ? "full" : "table"} + > {rates.length === 0 ? ( - +

    No rates yet. Refresh to fetch them, or enter one by hand. - +

    ) : ( {rates.map((rate) => ( - {rate.currency} - - {rate.name ? ` · ${rate.name}` : ""} + + {rate.currency} + {rate.name ? ( + + {rate.name} + + ) : null} @@ -294,41 +241,137 @@ export function CurrencySettings() { ))} )} -
    + - - - Currencies in use - - {unconverted.count === 0 - ? "Every deal with an amount can be converted into the reporting currency." - : `${formatCount(unconverted.count, "deal")} cannot be converted, so ${unconverted.count === 1 ? "it is" : "they are"} left out of every total.`} - {refreshedAt ? ( - <> - {" "} - Rates last fetched . - - ) : null} - - + + {setRate.isPending ? : null} + Save rate + + } + > +
    { + event.preventDefault(); + if (busy) return; + const rate = Number.parseFloat(draftRate); + const errors: Record = {}; + if (!isCurrencyCode(draftCurrency)) + errors[rateCurrencyId] = "Choose a currency."; + else if (draftCurrency === reportingCurrency) + errors[rateCurrencyId] = + `${reportingCurrency} is the reporting currency. Its rate is always 1.`; + if (!Number.isFinite(rate) || rate <= 0) + errors[rateValueId] = + "A rate has to be a number greater than zero."; + if (!validation.validate(event.currentTarget, errors)) return; + setRate.mutate({ currency: draftCurrency, rate }); + }} + > + + + Currency + + + {validation.errors[rateCurrencyId]} + + + + + 1 {draftCurrency || "unit"} = ? {reportingCurrency} + + setDraftRate(event.target.value)} + /> + + {validation.errors[rateValueId]} + + + + +
    + + {inUse.length === 0 ? ( - No deals have an amount yet. +

    + No deals have an amount yet. +

    ) : ( {inUse.map((row) => ( - {row.currency} - - {row.name ? ` · ${row.name}` : ""} - - {row.currency === reportingCurrency ? ( - - {" "} - · reporting currency + + + {row.currency} + {row.currency === reportingCurrency ? ( + Reporting currency + ) : null} - ) : null} + {row.name ? ( + + {row.name} + + ) : null} + {row.deals} @@ -344,7 +387,7 @@ export function CurrencySettings() { ))} )} -
    -
    + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/currencies/page.tsx b/apps/app/app/(app)/[slug]/settings/currencies/page.tsx index 3fdba12de..a065ab9ef 100644 --- a/apps/app/app/(app)/[slug]/settings/currencies/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/currencies/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -20,7 +20,7 @@ export const metadata: Metadata = { export default function CurrenciesSettingsPage() { return ( - + Currencies @@ -32,7 +32,7 @@ export default function CurrenciesSettingsPage() { - }> + }> @@ -50,9 +50,7 @@ async function Currencies() { return ( -
    - -
    +
    ); } diff --git a/apps/app/app/(app)/[slug]/settings/layout.tsx b/apps/app/app/(app)/[slug]/settings/layout.tsx index de5b1fc6d..74edf6fff 100644 --- a/apps/app/app/(app)/[slug]/settings/layout.tsx +++ b/apps/app/app/(app)/[slug]/settings/layout.tsx @@ -1,17 +1,7 @@ -import { Suspense } from "react"; -import { SettingsSidebar, SettingsSidebarFallback } from "./settings-sidebar"; - export default function SettingsLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return ( -
    - }> - - - {children} -
    - ); + return
    {children}
    ; } diff --git a/apps/app/app/(app)/[slug]/settings/members/members-table.tsx b/apps/app/app/(app)/[slug]/settings/members/members-table.tsx index 023bdfb04..efa5a3a5e 100644 --- a/apps/app/app/(app)/[slug]/settings/members/members-table.tsx +++ b/apps/app/app/(app)/[slug]/settings/members/members-table.tsx @@ -1,6 +1,5 @@ "use client"; -import OverflowMenuHorizontal from "@carbon/icons-react/es/OverflowMenuHorizontal"; import { Button } from "@crm/ui/components/button"; import { DataTable, @@ -16,6 +15,7 @@ import { import { Icon } from "@crm/ui/components/icon"; import { PersonAvatar } from "@crm/ui/components/person-avatar"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { EllipsisIcon } from "lucide-react"; import { toast } from "sonner"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -46,7 +46,6 @@ function columns( header: "Name", sortable: true, hideable: false, - width: "w-[34%]", cell: (row) => ( ( {row.email} @@ -76,7 +75,7 @@ function columns( id: "role", header: "Role", sortable: true, - width: "w-[14%]", + width: "28", cell: (row) => ( {ROLE_LABEL[row.role]} ), @@ -87,7 +86,7 @@ function columns( label: "Joined date", sortable: true, align: "right", - width: "w-[14%]", + width: "28", hideBelow: "sm", cell: (row) => ( @@ -101,13 +100,13 @@ function columns( label: "Actions", hideable: false, align: "right", - width: "w-[6%]", + width: "12", cell: (row) => canChangeRoles ? ( @@ -169,8 +168,9 @@ export function MembersTable() { return ( } + search={} columns={columns( workspace.data?.canChangeRoles ?? false, (member, role) => setRole.mutate({ memberId: member.id, role }), diff --git a/apps/app/app/(app)/[slug]/settings/members/page.tsx b/apps/app/app/(app)/[slug]/settings/members/page.tsx index b8bcfdd90..05401ca8d 100644 --- a/apps/app/app/(app)/[slug]/settings/members/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/members/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -23,7 +23,7 @@ export default function MembersSettingsPage({ searchParams, }: PageProps<"/[slug]/settings/members">) { return ( - + Members @@ -34,7 +34,7 @@ export default function MembersSettingsPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/settings/page.tsx b/apps/app/app/(app)/[slug]/settings/page.tsx index 284c9f0e8..7f2dba2b8 100644 --- a/apps/app/app/(app)/[slug]/settings/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,10 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import { SettingsColumn } from "@crm/ui/components/settings-card"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -23,7 +24,7 @@ export const metadata: Metadata = { export default function GeneralSettingsPage() { return ( - + General @@ -34,7 +35,7 @@ export default function GeneralSettingsPage() { - }> + }> @@ -58,12 +59,12 @@ async function Settings() { return ( -
    + -
    +
    ); } diff --git a/apps/app/app/(app)/[slug]/settings/research-key.tsx b/apps/app/app/(app)/[slug]/settings/research-key.tsx index 3c5dab1a4..702f907ed 100644 --- a/apps/app/app/(app)/[slug]/settings/research-key.tsx +++ b/apps/app/app/(app)/[slug]/settings/research-key.tsx @@ -2,21 +2,9 @@ import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@crm/ui/components/field"; +import { Field, FieldLabel } from "@crm/ui/components/field"; import { Input } from "@crm/ui/components/input"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -50,71 +38,60 @@ export function ResearchKey() { const { configured, hint } = key.data; return ( - - - Company research - - Enter your Context API key so our agents can research every company in - the CRM. - - - - - - - - -
    { - event.preventDefault(); - save.mutate({ apiKey: draft.trim() }); - }} + + } + hint={ + <> + Don't have a Context API key?{" "} + + Sign up here + + + } + action={ + + } + control="compact" + > +
    { + event.preventDefault(); + save.mutate({ apiKey: draft.trim() }); + }} + > + + Context API key + setDraft(event.target.value)} + placeholder={hint ?? "Paste the key"} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + disabled={save.isPending} + /> + +
    +
    ); } diff --git a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx deleted file mode 100644 index cdce91b4f..000000000 --- a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { Button } from "@crm/ui/components/button"; -import { cn } from "@crm/ui/lib/utils"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useMemo } from "react"; -import { useWorkspaceUrl } from "@/lib/use-workspace-url"; - -type SettingsNavItem = { - title: string; - href: string; -}; - -const ROOT = "/settings"; - -const ITEMS: SettingsNavItem[] = [ - { title: "General", href: ROOT }, - { title: "Tracking & Analytics", href: `${ROOT}/tracking` }, - { title: "Connections", href: `${ROOT}/connections` }, - { title: "Currencies", href: `${ROOT}/currencies` }, - { title: "Members", href: `${ROOT}/members` }, - { title: "API Keys", href: `${ROOT}/api-keys` }, - { title: "SSO", href: `${ROOT}/sso` }, -]; - -function isActive(href: string, root: string, pathname: string): boolean { - return href === root ? pathname === href : pathname.startsWith(href); -} - -function NavLink({ - item, - active, - className, -}: { - item: SettingsNavItem; - active: boolean; - className: string; -}) { - return ( - - ); -} - -export function SettingsSidebarFallback() { - return ( - <> - - - - - ); -} - -export function SettingsSidebar() { - const pathname = usePathname(); - const workspaceUrl = useWorkspaceUrl(); - - const root = workspaceUrl(ROOT); - const items = useMemo( - () => ITEMS.map((item) => ({ ...item, href: workspaceUrl(item.href) })), - [workspaceUrl], - ); - - return ( - <> - - - - - ); -} diff --git a/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx b/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx index f16e4d4ac..3544a14be 100644 --- a/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx +++ b/apps/app/app/(app)/[slug]/settings/sso/add-sso-provider-sheet.tsx @@ -1,13 +1,19 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; import { Button } from "@crm/ui/components/button"; import { Field, FieldDescription, - FieldGroup, + FieldError, FieldLabel, } from "@crm/ui/components/field"; +import { + FormSheet, + FormSheetContent, + FormSheetFooter, + FormSheetForm, + useFormSheetValidation, +} from "@crm/ui/components/form-sheet"; import { Icon } from "@crm/ui/components/icon"; import { Input } from "@crm/ui/components/input"; import { @@ -16,17 +22,14 @@ import { InputGroupInput, } from "@crm/ui/components/input-group"; import { - Sheet, - SheetClose, - SheetContent, SheetDescription, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@crm/ui/components/sheet"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; @@ -48,7 +51,7 @@ const EMPTY = { function AddButton(props: ComponentProps) { return ( ); @@ -63,6 +66,7 @@ export function AddSsoProviderSheet() { } function AddSsoProviderForm() { + const validation = useFormSheetValidation(); const trpc = useTRPC(); const cache = useCrmCache(); @@ -87,7 +91,7 @@ function AddSsoProviderForm() { await cache.sso(); toast.success(`${provider.name} saved.`); await setOpen(null); - setValues(EMPTY); + reset(); }, onError: (error) => toast.error(error.message), }), @@ -102,15 +106,24 @@ function AddSsoProviderForm() { providerId || "…" }`; - const complete = Object.values(values).every((value) => value.trim() !== ""); + const reset = () => { + validation.reset(); + setValues(EMPTY); + }; return ( - setOpen(next || null)}> + value !== "")} + pending={register.isPending} + onDiscard={reset} + open={open} + onOpenChange={(next) => setOpen(next || null)} + > - + Add an identity provider @@ -118,11 +131,17 @@ function AddSsoProviderForm() { -
    { event.preventDefault(); + if ( + !settings.data?.canConfigure || + !validation.validate(event.currentTarget) + ) + return; register.mutate({ providerId, issuer: values.issuer.trim(), @@ -132,115 +151,161 @@ function AddSsoProviderForm() { }); }} > - - - Name - edit({ providerId: event.target.value })} - placeholder="okta" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - required - /> - - Names the sign-in button. Cannot be changed later. - - - - - Issuer URL - edit({ issuer: event.target.value })} - placeholder="https://acme.okta.com" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - inputMode="url" - required - /> - Where discovery lives. - - - - Email domain - edit({ domain: event.target.value })} - placeholder="acme.com" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - required - /> - Comma-separate several. - - - - Client ID - edit({ clientId: event.target.value })} - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - required - /> - - - - Client secret - edit({ clientSecret: event.target.value })} - autoComplete="off" - required - /> - Never shown again. - - - - Redirect URI - - - - - - - - Add this at your provider before saving. - - - -
    - - + + Name + edit({ providerId: event.target.value })} + placeholder="okta" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + pattern=".*\S.*" + required + /> + + {validation.errors[providerIdId]} + + + Names the sign-in button. Cannot be changed later. + + + + + Issuer URL + edit({ issuer: event.target.value })} + placeholder="https://acme.okta.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + inputMode="url" + pattern=".*\S.*" + required + /> + + {validation.errors[issuerId]} + + Where discovery lives. + + + + Email domain + edit({ domain: event.target.value })} + placeholder="acme.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + pattern=".*\S.*" + required + /> + + {validation.errors[domainId]} + + Comma-separate several. + + + + Client ID + edit({ clientId: event.target.value })} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + pattern=".*\S.*" + required + /> + + {validation.errors[clientIdId]} + + + + + Client secret + edit({ clientSecret: event.target.value })} + autoComplete="off" + pattern=".*\S.*" + required + /> + + {validation.errors[clientSecretId]} + + Never shown again. + + + + Redirect URI + + + + + + + + Add this at your provider before saving. + + + + + - - - - -
    -
    + + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/sso/page.tsx b/apps/app/app/(app)/[slug]/settings/sso/page.tsx index 52b3d5c8b..042c81f90 100644 --- a/apps/app/app/(app)/[slug]/settings/sso/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/sso/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellActions, @@ -9,7 +7,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -25,7 +25,7 @@ export default function SsoSettingsPage({ searchParams, }: PageProps<"/[slug]/settings/sso">) { return ( - + SSO @@ -41,7 +41,7 @@ export default function SsoSettingsPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx b/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx index 010a7f5be..7b43423bd 100644 --- a/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx +++ b/apps/app/app/(app)/[slug]/settings/sso/sso-table.tsx @@ -1,6 +1,5 @@ "use client"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; import { AlertDialog, AlertDialogAction, @@ -16,6 +15,7 @@ import { Button } from "@crm/ui/components/button"; import { DataTable, type DataTableColumn } from "@crm/ui/components/data-table"; import { Icon } from "@crm/ui/components/icon"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; @@ -38,13 +38,14 @@ function columns( header: "Provider", sortable: true, hideable: false, - width: "w-[30%]", cell: (row) => ( {row.name} - - {row.type === "saml" ? "SAML" : "OpenID Connect"} - {row.clientIdLastFour ? ` · client …${row.clientIdLastFour}` : ""} + + {row.type === "saml" ? "SAML" : "OpenID Connect"} + {row.clientIdLastFour ? ( + Client …{row.clientIdLastFour} + ) : null} ), @@ -53,7 +54,7 @@ function columns( id: "domain", header: "Email domain", sortable: true, - width: "w-[22%]", + width: "40", hideBelow: "sm", cell: (row) => ( @@ -65,7 +66,7 @@ function columns( id: "issuer", header: "Issuer", sortable: true, - width: "w-[22%]", + width: "40", hideBelow: "md", cell: (row) => ( {row.issuer} @@ -74,7 +75,7 @@ function columns( { id: "callbackURL", header: "Redirect URI", - width: "w-[20%]", + width: "36", hideBelow: "lg", cell: (row) => ( @@ -89,13 +90,13 @@ function columns( label: "Actions", hideable: false, align: "right", - width: "w-[6%]", + width: "12", cell: (row) => canConfigure ? ( @@ -148,8 +149,9 @@ export function SsoTable() { return ( } + search={} columns={columns( settings.data?.canConfigure ?? false, (provider) => remove.mutate({ providerId: provider.providerId }), @@ -159,7 +161,7 @@ export function SsoTable() { total={providers.data?.total ?? 0} getRowId={(row) => row.providerId} loading={providers.isFetching} - empty="No identity provider yet — everyone signs in with Google." + empty="No identity provider yet. Everyone signs in with Google." /> ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/allowed-domains.tsx b/apps/app/app/(app)/[slug]/settings/tracking/allowed-domains.tsx index e69cc9301..bb64361f0 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/allowed-domains.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/allowed-domains.tsx @@ -1,15 +1,6 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { CardTableEmpty } from "@crm/ui/components/card-table"; import { Field, FieldLabel } from "@crm/ui/components/field"; import { Icon } from "@crm/ui/components/icon"; import { Input } from "@crm/ui/components/input"; @@ -25,6 +16,7 @@ import { SelectTrigger, SelectValue, } from "@crm/ui/components/select"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { SimpleTable, type SimpleTableColumn, @@ -33,6 +25,7 @@ import { import { Spinner } from "@crm/ui/components/spinner"; import { TableCell } from "@crm/ui/components/table"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { LocalRelativeTime } from "@/components/local-date-time"; @@ -75,22 +68,17 @@ export function AllowedDomains() { const { domains, canManage } = tracking.data; return ( - - - Allowed domains - - The script records page views on these hosts only. - - - - - - - + } + control={domains.length === 0 ? "full" : "table"} + > {domains.length === 0 ? ( - +

    Add the domain your website runs on to get your tracking script. - +

    ) : ( {domains.map((domain) => ( @@ -108,7 +96,7 @@ export function AllowedDomains() { {domain.lastSeenAt ? ( ) : ( - "—" + "" )} @@ -127,7 +115,7 @@ export function AllowedDomains() { ))} )} -
    + ); } @@ -160,7 +148,7 @@ function AddDomain({ disabled }: { disabled: boolean }) { diff --git a/apps/app/app/(app)/[slug]/settings/tracking/page.tsx b/apps/app/app/(app)/[slug]/settings/tracking/page.tsx index 85abe8371..4ecf9ff02 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,10 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import { SettingsColumn } from "@crm/ui/components/settings-card"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -20,7 +21,7 @@ export const metadata: Metadata = { export default function TrackingSettingsPage() { return ( - + Tracking & Analytics @@ -32,7 +33,7 @@ export default function TrackingSettingsPage() { - }> + }> @@ -56,9 +57,9 @@ async function Tracking() { return ( -
    + -
    +
    ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/tracking-cookies.tsx b/apps/app/app/(app)/[slug]/settings/tracking/tracking-cookies.tsx index 3b6b054e0..9011ca7e9 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/tracking-cookies.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/tracking-cookies.tsx @@ -1,14 +1,5 @@ "use client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { Field, FieldDescription, FieldLabel } from "@crm/ui/components/field"; -import { Label } from "@crm/ui/components/label"; import { Select, SelectContent, @@ -16,9 +7,10 @@ import { SelectTrigger, SelectValue, } from "@crm/ui/components/select"; +import { SettingRow, SettingRows } from "@crm/ui/components/setting-rows"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useId } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -44,7 +36,6 @@ const TOGGLES = [ export function TrackingCookies() { const trpc = useTRPC(); const cache = useCrmCache(); - const lifetimeId = useId(); const tracking = useQuery(trpc.tracking.settings.queryOptions()); @@ -71,67 +62,56 @@ export function TrackingCookies() { const busy = !canManage || setFlag.isPending || setLifetime.isPending; return ( - - - Cookies - - How a returning visitor is recognised. - - - - + + {TOGGLES.map((toggle) => ( -
    - + title={toggle.label} + description={toggle.hint} + control={(id, describedBy) => ( + + setFlag.mutate({ flag: toggle.flag, enabled }) + } + /> + )} + /> + ))} - ( + - setLifetime.mutate({ days: Number(value) }) - } - > - - - - - {cookieLifetimes.map((lifetime) => ( - - {lifetime.label} - - ))} - - - - After this a returning visitor counts as somebody new. Shorten it if - your policy asks you to. - - - - + > + + + + + {cookieLifetimes.map((lifetime) => ( + + {lifetime.label} + + ))} + + + )} + /> + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/tracking-rules.tsx b/apps/app/app/(app)/[slug]/settings/tracking/tracking-rules.tsx index 5fb85c70d..5ac7b709d 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/tracking-rules.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/tracking-rules.tsx @@ -1,13 +1,7 @@ "use client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { Label } from "@crm/ui/components/label"; +import { SettingRow, SettingRows } from "@crm/ui/components/setting-rows"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -45,41 +39,31 @@ export function TrackingRules() { const { canManage } = tracking.data; return ( - - - Tracking rules - - Where the script may run, and how it follows a visitor. - - - - + + {RULES.map((rule) => ( -
    - - - - setFlag.mutate({ flag: rule.flag, enabled }) - } - /> -
    + title={rule.label} + description={rule.hint} + control={(id, describedBy) => ( + + setFlag.mutate({ flag: rule.flag, enabled }) + } + /> + )} + /> ))} -
    -
    + + ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx b/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx index 4311e825b..aca743228 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx @@ -1,6 +1,5 @@ "use client"; -import Copy from "@carbon/icons-react/es/Copy"; import { Accordion, AccordionContent, @@ -19,20 +18,13 @@ import { AlertDialogTrigger, } from "@crm/ui/components/alert-dialog"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; import { Icon } from "@crm/ui/components/icon"; -import { Label } from "@crm/ui/components/label"; +import { SettingRow, SettingRows } from "@crm/ui/components/setting-rows"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { CopyIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -95,29 +87,58 @@ export function TrackingScript() { }; return ( - - - -
    - Tracking script - -
    -
    - - One tag, 4 KB, in the head of every page you measure. - - - + + } + description="One tag, 4 KB, in the head of every page you measure." + hint="Rotating the site ID stops every copy of the old script at once." + action={ +
    + + + + + + + + Rotate the site ID? + + Every copy of the old script stops recording at once, + including any you have forgotten about. You will need to paste + the new tag on every page that carries the old one. Nothing + already collected is lost. + + + + + Cancel + rotate.mutate()} + > + Rotate + + + + - - - - +
    + } + > +
    {">"} -

    - Site ID{" "} - {siteId} · - Rotating it stops every copy of the old script at once. -

    +
    +

    + Site ID{" "} + {siteId} +

    +

    Rotating it stops every copy of the old script at once.

    +
    @@ -172,9 +195,7 @@ export function TrackingScript() {
    1. In Tag Manager, add a new Custom HTML tag.
    2. -
    3. - Paste this snippet — not the one above — as the tag's HTML. -
    4. +
    5. Use this snippet as the tag's HTML.
    6. Trigger it on All Pages, then publish the container. Keep{" "} {scriptUrl}{" "} @@ -191,66 +212,24 @@ export function TrackingScript() { -
      - - - - setFlag.mutate({ flag: "paused", enabled }) - } + + ( + + setFlag.mutate({ flag: "paused", enabled }) + } + /> + )} /> -
      - - -
      - - - - - - - - Rotate the site ID? - - Every copy of the old script stops recording at once, - including any you have forgotten about. You will need to - paste the new tag on every page that carries the old one. - Nothing already collected is lost. - - - - - Cancel - rotate.mutate()} - > - Rotate - - - - -
      -
      - - + +
    +
    ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/traffic-sources.tsx b/apps/app/app/(app)/[slug]/settings/tracking/traffic-sources.tsx index 164caef71..0c6ad288e 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/traffic-sources.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/traffic-sources.tsx @@ -1,12 +1,6 @@ "use client"; -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { CardTableEmpty } from "@crm/ui/components/card-table"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { SimpleTable, type SimpleTableColumn, @@ -32,27 +26,23 @@ export function TrafficSources() { if (!sources.data) return null; return ( - - - Traffic sources - - Where your visitors come from. Only people who have submitted a form - are attributed to a record. - - - + {sources.data.length === 0 ? ( - +

    No sources yet. They appear once the script records its first page view. - +

    ) : ( {sources.data.map((row) => ( {row.source} - {row.medium ?? "—"} + {row.medium ?? ""} {row.views.toLocaleString()} @@ -64,6 +54,6 @@ export function TrafficSources() { ))} )} -
    + ); } diff --git a/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx b/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx index 5b10f21af..07ed1ca82 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx @@ -1,18 +1,8 @@ "use client"; -import CheckmarkFilled from "@carbon/icons-react/es/CheckmarkFilled"; -import Warning from "@carbon/icons-react/es/Warning"; import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; -import { Field, FieldDescription, FieldLabel } from "@crm/ui/components/field"; +import { Field, FieldLabel } from "@crm/ui/components/field"; import { Icon } from "@crm/ui/components/icon"; import { InputGroup, @@ -20,9 +10,11 @@ import { InputGroupInput, InputGroupText, } from "@crm/ui/components/input-group"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; @@ -51,33 +43,24 @@ export function VerifyInstallation() { const { canManage, siteId } = tracking.data; return ( - - - -
    - Verify installation - {result ? : null} -
    -
    - - We load one page and look for the script, then read your Tag Manager - container if it is not in the HTML. - - - - - -
    - - + : null} + description="We load one page and look for the script, then read your Tag Manager container if it is not in the HTML." + hint="The page has to be public. A page behind a login always fails this check." + action={ + + } + > +
    { @@ -108,16 +91,12 @@ export function VerifyInstallation() { disabled={!canManage || verify.isPending} /> - - The page has to be public. A page behind a login always fails this - check. -
    {result && siteId ? : null} - - +
    +
    ); } @@ -151,7 +130,7 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { if (result.status === "unreachable") { return ( - + Could not open {result.host} {result.detail} We only follow public pages, and we never follow a @@ -164,7 +143,7 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { if (result.status === "missing") { return ( - + No script on {result.host} The page answered in {result.responseMs} ms, but the tag was not in @@ -181,7 +160,7 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { if (result.container && !result.container.carriesSiteId) { return ( - + Tag Manager will drop the site ID Container {result.container.id} carries the tag, but the site ID is @@ -199,7 +178,7 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { return ( - + {result.container ? `Script found in container ${result.container.id}` @@ -209,11 +188,11 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { It answered in {result.responseMs} ms. Site ID {siteId} matched, and this domain is {result.allowed ? "on" : "not on"} the allow list. {result.container - ? " The tag is not in the HTML, so it only runs once Tag Manager fires it — a page view is the proof." + ? " Tag Manager runs the tag after page load. A recorded page view confirms the installation." : ""} {result.pageView ? " A page view arrived in the last five minutes." - : " No page view has arrived yet — open the page in a browser to send one."} + : " No page view has arrived yet. Open the page in a browser to send one."} ); diff --git a/apps/app/app/(app)/[slug]/settings/workspace-form.tsx b/apps/app/app/(app)/[slug]/settings/workspace-form.tsx index 9ced03711..5483d34ef 100644 --- a/apps/app/app/(app)/[slug]/settings/workspace-form.tsx +++ b/apps/app/app/(app)/[slug]/settings/workspace-form.tsx @@ -1,14 +1,6 @@ "use client"; import { Button } from "@crm/ui/components/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@crm/ui/components/card"; import { Field, FieldDescription, @@ -22,6 +14,7 @@ import { InputGroupInput, InputGroupText, } from "@crm/ui/components/input-group"; +import { SettingsCard } from "@crm/ui/components/settings-card"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -73,89 +66,80 @@ export function WorkspaceForm() { setDraft({ ...values, ...patch }); return ( - - - Workspace - - The name and website of the company using this CRM. - - - - - - - - -
    { - event.preventDefault(); - save.mutate({ - name: values.name, - website: values.website.trim(), - }); - }} + - - - Name - edit({ name: event.target.value })} - placeholder="Acme Inc." - autoComplete="organization" + {save.isPending ? : null} + Save + + } + control="compact" + > + { + event.preventDefault(); + save.mutate({ + name: values.name, + website: values.website.trim(), + }); + }} + > + + + Name + edit({ name: event.target.value })} + placeholder="Acme Inc." + autoComplete="organization" + disabled={!canRename || save.isPending} + required + /> + + + + Website + + + https:// + + edit({ website: event.target.value })} + placeholder="acme.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + inputMode="url" disabled={!canRename || save.isPending} - required /> - - Shown wherever the CRM refers to your own company. - - - - - Website - - - https:// - - edit({ website: event.target.value })} - placeholder="acme.com" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - inputMode="url" - disabled={!canRename || save.isPending} - /> - - Your own company's website. - - - - - {canRename ? null : ( -

    - Only an owner or an admin can change this. -

    - )} -
    -
    + + Your own company's website. + + + + ); } diff --git a/apps/app/app/(app)/[slug]/tasks/page.tsx b/apps/app/app/(app)/[slug]/tasks/page.tsx index f08cadcca..680c1e0c7 100644 --- a/apps/app/app/(app)/[slug]/tasks/page.tsx +++ b/apps/app/app/(app)/[slug]/tasks/page.tsx @@ -1,5 +1,3 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; import { PageShell, PageShellContent, @@ -8,7 +6,9 @@ import { PageShellHeading, PageShellLoading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import type { Metadata } from "next"; +import { Suspense } from "react"; import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; @@ -31,7 +31,7 @@ export default function TasksPage({ - }> + }> diff --git a/apps/app/app/(app)/[slug]/tasks/tasks-table.tsx b/apps/app/app/(app)/[slug]/tasks/tasks-table.tsx index 704616b55..3ba8332c4 100644 --- a/apps/app/app/(app)/[slug]/tasks/tasks-table.tsx +++ b/apps/app/app/(app)/[slug]/tasks/tasks-table.tsx @@ -14,6 +14,7 @@ import { useRef, useState } from "react"; import { toast } from "sonner"; import { contactName } from "@/components/crm/contact-name"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; +import { ListEmpty } from "@/components/data-table/list-empty"; import { ListSearch } from "@/components/data-table/list-search"; import { useTableQuery } from "@/components/data-table/use-table-query"; import { LocalDay, LocalRelativeTime } from "@/components/local-date-time"; @@ -85,7 +86,7 @@ export function TasksTable() { label: "Status", sortable: true, hideable: false, - width: "w-12", + width: "12", cell: (row) => ( { @@ -104,12 +105,11 @@ export function TasksTable() { header: "Task", sortable: true, hideable: false, - width: "w-[34%]", cell: (row) => row.subject ? ( @@ -123,7 +123,7 @@ export function TasksTable() { id: "company", header: "Related to", sortable: true, - width: "w-[24%]", + width: { sm: "56" }, cell: (row) => { if (row.deal) { return ( @@ -154,7 +154,7 @@ export function TasksTable() { header: "Due", sortable: true, align: "right", - width: "w-32", + width: { sm: "32" }, cell: (row) => row.dueAt ? ( ( @@ -194,7 +194,7 @@ export function TasksTable() { header: "Created", sortable: true, align: "right", - width: "w-24", + width: "28", defaultHidden: true, cell: (row) => ( @@ -228,6 +228,7 @@ export function TasksTable() { {announcement} } columns={columns} @@ -237,6 +238,7 @@ export function TasksTable() { facets={facets} tabs={{ id: "status", + label: "Status", allLabel: "All tasks", options: [ { value: "open", label: "Open" }, @@ -245,7 +247,17 @@ export function TasksTable() { }} getRowId={(row) => row.id} loading={tasks.isFetching} - empty="No tasks match this view." + empty={ + + } /> ); diff --git a/apps/app/app/(landing)/grant-access/grant-access.tsx b/apps/app/app/(landing)/grant-access/grant-access.tsx index 9f5ee1fad..f194eb653 100644 --- a/apps/app/app/(landing)/grant-access/grant-access.tsx +++ b/apps/app/app/(landing)/grant-access/grant-access.tsx @@ -6,10 +6,10 @@ import { MICROSOFT_SYNC_SCOPES, SYNC_SCOPES, } from "@crm/auth/scopes"; +import { AuthButton } from "@crm/ui/components/auth-button"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; -import { Button } from "@crm/ui/components/button"; -import { Spinner } from "@crm/ui/components/spinner"; +import { LogoSpinner } from "@crm/ui/components/logo-spinner"; import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; @@ -69,7 +69,7 @@ export function GrantAccess({ const { label, Logo } = PROVIDERS[provider]; return ( - + ); })} - +
    ); } diff --git a/apps/app/app/(landing)/grant-access/page.tsx b/apps/app/app/(landing)/grant-access/page.tsx index 8edbe86e8..b9732e95f 100644 --- a/apps/app/app/(landing)/grant-access/page.tsx +++ b/apps/app/app/(landing)/grant-access/page.tsx @@ -13,13 +13,13 @@ export const instant = false; const DESCRIPTION = { google: - "This CRM reads your Gmail and Calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", + "This CRM reads your Gmail and Calendar so meetings and email threads show up on the right company. Access is read-only. The CRM sends nothing on your behalf.", microsoft: - "This CRM reads your Outlook mail so email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", + "This CRM reads your Outlook mail so email threads show up on the right company. Access is read-only. The CRM sends nothing on your behalf.", } satisfies Record; const BOTH = - "This CRM reads your mail and calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf."; + "This CRM reads your mail and calendar so meetings and email threads show up on the right company. Access is read-only. The CRM sends nothing on your behalf."; export default async function GrantAccessPage() { const { user } = await requireSession(); diff --git a/apps/app/app/(landing)/onboarding/onboarding-form.tsx b/apps/app/app/(landing)/onboarding/onboarding-form.tsx index 7f813dbb5..8a5f8ca46 100644 --- a/apps/app/app/(landing)/onboarding/onboarding-form.tsx +++ b/apps/app/app/(landing)/onboarding/onboarding-form.tsx @@ -1,21 +1,21 @@ "use client"; import { workspaceSlug } from "@crm/db/workspace"; -import { Button } from "@crm/ui/components/button"; +import { AuthButton } from "@crm/ui/components/auth-button"; import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@crm/ui/components/field"; -import { Input } from "@crm/ui/components/input"; + AuthField, + AuthFieldDescription, + AuthFieldGroup, + AuthFieldLabel, +} from "@crm/ui/components/auth-field"; +import { AuthInput } from "@crm/ui/components/auth-input"; import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from "@crm/ui/components/input-group"; -import { Spinner } from "@crm/ui/components/spinner"; + AuthInputGroup, + AuthInputGroupAddon, + AuthInputGroupInput, + AuthInputGroupText, +} from "@crm/ui/components/auth-input-group"; +import { LogoSpinner } from "@crm/ui/components/logo-spinner"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useId, useRef, useState } from "react"; @@ -58,10 +58,10 @@ export function OnboardingForm({ placeholder }: { placeholder: string }) { }} className="flex flex-col gap-6" > - - - Company name - + + Company name + - + - - Workspace URL - - - / - - + Workspace URL + + + / + + - - + + Your team will use this address to open the CRM. - - + + - - Website - - - https:// - - + Website + + + https:// + + - - + + Read once, so every answer afterwards knows what you sell. - - - + + + - + ); } diff --git a/apps/app/app/(landing)/onboarding/research/research-form.tsx b/apps/app/app/(landing)/onboarding/research/research-form.tsx index 30887d2eb..137ba2a68 100644 --- a/apps/app/app/(landing)/onboarding/research/research-form.tsx +++ b/apps/app/app/(landing)/onboarding/research/research-form.tsx @@ -1,15 +1,15 @@ "use client"; import { CONTEXT_DEV_SIGNUP_URL } from "@crm/db/settings"; -import { Button } from "@crm/ui/components/button"; +import { AuthButton } from "@crm/ui/components/auth-button"; import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@crm/ui/components/field"; -import { Input } from "@crm/ui/components/input"; -import { Spinner } from "@crm/ui/components/spinner"; + AuthField, + AuthFieldDescription, + AuthFieldGroup, + AuthFieldLabel, +} from "@crm/ui/components/auth-field"; +import { AuthInput } from "@crm/ui/components/auth-input"; +import { LogoSpinner } from "@crm/ui/components/logo-spinner"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useId } from "react"; @@ -41,10 +41,10 @@ export function ResearchForm() { }} className="flex flex-col gap-6" > - - - Context API key - + + Context API key + - + Don't have a Context API key?{" "} Sign up here - - - + + + - + ); } diff --git a/apps/app/app/(landing)/page.tsx b/apps/app/app/(landing)/page.tsx index 8f27656dd..53075df8e 100644 --- a/apps/app/app/(landing)/page.tsx +++ b/apps/app/app/(landing)/page.tsx @@ -11,7 +11,7 @@ import { ProductShot } from "@/components/landing/product-shot/product-shot"; export const metadata: Metadata = { title: "The CRM for agents", description: - "The first agentic CRM experience — durable research agents that read your inbox, keep every record current and book their own follow-ups.", + "The first agentic CRM experience. Durable research agents that read your inbox, keep every record current and book their own follow-ups.", }; export default function Home() { diff --git a/apps/app/app/(landing)/sign-in/page.tsx b/apps/app/app/(landing)/sign-in/page.tsx index 5b0e451af..039c4836d 100644 --- a/apps/app/app/(landing)/sign-in/page.tsx +++ b/apps/app/app/(landing)/sign-in/page.tsx @@ -90,12 +90,12 @@ async function SignIn({ <>

    - Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET — or MICROSOFT_CLIENT_ID - and MICROSOFT_CLIENT_SECRET — in the root .env file and restart. Your + Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, or MICROSOFT_CLIENT_ID + and MICROSOFT_CLIENT_SECRET, in the root .env file and restart. Your own identity provider can be added from Settings once somebody is signed in.

    diff --git a/apps/app/app/(landing)/sign-in/social-sign-in.tsx b/apps/app/app/(landing)/sign-in/social-sign-in.tsx index 882f89c12..9845409f3 100644 --- a/apps/app/app/(landing)/sign-in/social-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/social-sign-in.tsx @@ -2,10 +2,10 @@ import { signIn } from "@crm/auth/client"; import type { MailboxProviderId } from "@crm/auth/scopes"; +import { AuthButton } from "@crm/ui/components/auth-button"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; -import { Button } from "@crm/ui/components/button"; -import { Spinner } from "@crm/ui/components/spinner"; +import { LogoSpinner } from "@crm/ui/components/logo-spinner"; import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; @@ -45,7 +45,7 @@ export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { } return ( - + ); } diff --git a/apps/app/app/(landing)/sign-in/sso-sign-in.tsx b/apps/app/app/(landing)/sign-in/sso-sign-in.tsx index 623592766..93395559f 100644 --- a/apps/app/app/(landing)/sign-in/sso-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/sso-sign-in.tsx @@ -1,8 +1,8 @@ "use client"; import { signIn } from "@crm/auth/client"; -import { Button } from "@crm/ui/components/button"; -import { Spinner } from "@crm/ui/components/spinner"; +import { AuthButton } from "@crm/ui/components/auth-button"; +import { LogoSpinner } from "@crm/ui/components/logo-spinner"; import { useState } from "react"; import { toast } from "sonner"; @@ -14,6 +14,11 @@ export type SsoProvider = { export function SsoSignIn({ providers }: { providers: SsoProvider[] }) { const [pending, setPending] = useState(null); + function fail(message?: string) { + setPending(null); + toast.error(message ?? "Could not reach the sign-in service."); + } + async function handleClick(providerId: string) { setPending(providerId); @@ -25,28 +30,27 @@ export function SsoSignIn({ providers }: { providers: SsoProvider[] }) { errorCallbackURL: `${origin}/sign-in`, }); - if (error) { - toast.error(error.message ?? "Could not reach the sign-in service."); - setPending(null); - } + if (error) fail(error.message); } return ( <> {providers.map((provider) => ( - + ))} ); diff --git a/apps/app/app/layout.tsx b/apps/app/app/layout.tsx index d22ba155b..4a4547c60 100644 --- a/apps/app/app/layout.tsx +++ b/apps/app/app/layout.tsx @@ -1,10 +1,11 @@ import "@crm/ui/globals.css"; -import { Toaster } from "@crm/ui/components/sonner"; import { TooltipProvider } from "@crm/ui/components/tooltip"; import { cn } from "@crm/ui/lib/utils"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Suspense } from "react"; +import { AppToaster } from "@/components/app-toaster"; import { LocalDateTimeHydrator } from "@/components/local-date-time"; import { ThemeProvider } from "@/components/theme-provider"; import { TRPCReactProvider } from "@/lib/trpc/client"; @@ -22,7 +23,7 @@ const fontMono = Geist_Mono({ export const metadata: Metadata = { title: { default: "Comp AI - CRM", - template: "%s · Comp AI CRM", + template: "Comp AI CRM: %s", }, description: "Customer Relationship Management for Comp AI", icons: { @@ -51,7 +52,9 @@ export default function RootLayout({ {children} - + + + diff --git a/apps/app/components/account-menu.tsx b/apps/app/components/account-menu.tsx new file mode 100644 index 000000000..decb3f51e --- /dev/null +++ b/apps/app/components/account-menu.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { DropdownMenuItem } from "@crm/ui/components/dropdown-menu"; +import { UserMenu, UserMenuSkeleton } from "@crm/ui/components/user-menu"; +import { SettingsIcon } from "lucide-react"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { toast } from "sonner"; +import { signOutAndRedirect } from "@/lib/sign-out"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +export type AccountUser = { name: string; email: string; image: string | null }; + +export function AccountMenu({ user }: { user: AccountUser }) { + const { theme, setTheme } = useTheme(); + const workspaceUrl = useWorkspaceUrl(); + + return ( + + signOutAndRedirect().catch(() => { + toast.error("Could not sign out."); + }) + } + actions={ + + + + Settings + + + } + /> + ); +} + +export function AccountMenuFallback() { + return ; +} diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index e57bdf877..5d85dfbc4 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -1,20 +1,5 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import Application from "@carbon/icons-react/es/Application"; -import ArrowRight from "@carbon/icons-react/es/ArrowRight"; -import Building from "@carbon/icons-react/es/Building"; -import Checkmark from "@carbon/icons-react/es/Checkmark"; -import Copy from "@carbon/icons-react/es/Copy"; -import Partnership from "@carbon/icons-react/es/Partnership"; -import Play from "@carbon/icons-react/es/Play"; -import Renew from "@carbon/icons-react/es/Renew"; -import Reply from "@carbon/icons-react/es/Reply"; -import Security from "@carbon/icons-react/es/Security"; -import ThumbsDown from "@carbon/icons-react/es/ThumbsDown"; -import ThumbsUp from "@carbon/icons-react/es/ThumbsUp"; -import User from "@carbon/icons-react/es/User"; -import WarningAlt from "@carbon/icons-react/es/WarningAlt"; import { AsyncButtonContent, useAsyncAction, @@ -48,6 +33,23 @@ import { type UseEveAgentStatus, useEveAgent, } from "eve/react"; +import { + AppWindowIcon, + ArrowRightIcon, + Building2Icon, + CheckIcon, + CopyIcon, + HandshakeIcon, + PlayIcon, + PlusIcon, + RefreshCwIcon, + ReplyIcon, + ShieldIcon, + ThumbsDownIcon, + ThumbsUpIcon, + TriangleAlertIcon, + UserIcon, +} from "lucide-react"; import Link from "next/link"; import { Fragment, type ReactNode, useState } from "react"; import { toast } from "sonner"; @@ -935,7 +937,7 @@ function ChatHeader({ {working ? ( @@ -948,7 +950,7 @@ function ChatHeader({
    @@ -1015,13 +1017,13 @@ function UserSubmission({ > {response ? (
    - + Answer to follow-up
    ) : null} {submission.commandType === "CREATE_AGENT" ? (
    - +
    ) : null}

    {text}

    @@ -1211,7 +1213,7 @@ function PermissionModeMenu({ disabled={pending} aria-label={`Permission mode: ${selected?.label ?? "Manual"}`} > - + {selected?.label ?? "Manual"} @@ -1338,7 +1340,7 @@ function CopyResponseAction({ markdown }: { markdown: string }) { toast.success("Response copied as Markdown."); }} > - +
    ); @@ -1381,7 +1383,7 @@ function ResponseActions({ toast.success("Response copied as Markdown."); }} > - +
    ); @@ -1457,7 +1459,7 @@ function BuildingAgentCard({ )} > {done ? ( - + ) : active ? ( ) : ( @@ -1662,7 +1664,10 @@ function BuilderFailureCard({ className="flex flex-col items-stretch gap-3 rounded-lg border border-destructive/30 bg-card px-4 py-3 sm:flex-row sm:items-start" >
    - +

    {creatingAgent ? "Agent creation stopped" : "Response stopped"} @@ -1725,14 +1730,14 @@ function ReviewAgentCard({ />

    - + @@ -1834,7 +1839,7 @@ function DeployedAgentCard({ const nextRun = enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = - enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + enabledTriggers.map((trigger) => trigger.name).join(", ") || "Manual only"; return (
    @@ -1865,8 +1870,9 @@ function DeployedAgentCard({ ) } /> - - + + +
    @@ -1883,14 +1889,14 @@ function DeployedAgentCard({ successLabel="Queued" errorLabel="Try again" > - + Run now
    @@ -1911,7 +1917,10 @@ function DeployedAgentCard({ {suggestion} - + ))}
    @@ -1938,10 +1947,10 @@ function ChatUnavailable() { } const RESOURCE_ICONS = { - integration: Application, - company: Building, - contact: User, - deal: Partnership, + integration: AppWindowIcon, + company: Building2Icon, + contact: UserIcon, + deal: HandshakeIcon, } as const; function hasQueuedInputResponse( @@ -2018,7 +2027,7 @@ function manifestOf(manifest: AgentManifestSummary) { trigger.type === "EVENT" ? "On CRM event" : "On schedule", ), ) - .join(" · ") || "On demand", + .join(", ") || "On demand", looksAt: textOf( manifest.dataScope.summary, "CRM records in the approved scope", diff --git a/apps/app/components/agent-builder/agent-builder-home.tsx b/apps/app/components/agent-builder/agent-builder-home.tsx index 1217ca61c..8b93cad80 100644 --- a/apps/app/components/agent-builder/agent-builder-home.tsx +++ b/apps/app/components/agent-builder/agent-builder-home.tsx @@ -1,7 +1,6 @@ "use client"; -import ArrowRight from "@carbon/icons-react/es/ArrowRight"; -import { Icon } from "@crm/ui/components/icon"; +import { ChatSuggestions } from "@crm/ui/components/chat-thread"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -67,27 +66,13 @@ export function AgentBuilderHome({ name }: { name: string }) { it available to the whole team.

    -
    -

    - Suggested agents -

    - {SUGGESTIONS.map((suggestion) => ( - - ))} -
    + + setInitialPrompt(`/Create agent ${suggestion}`) + } + />
    ); diff --git a/apps/app/components/agent-builder/agent-builder-shell.tsx b/apps/app/components/agent-builder/agent-builder-shell.tsx index d0d9a0009..2b2b5f934 100644 --- a/apps/app/components/agent-builder/agent-builder-shell.tsx +++ b/apps/app/components/agent-builder/agent-builder-shell.tsx @@ -1,37 +1,3 @@ -import { Skeleton } from "@crm/ui/components/skeleton"; - -export function AgentBuilderShell({ - children, - sidebar, -}: { - children: React.ReactNode; - sidebar: React.ReactNode; -}) { - return ( -
    - {sidebar} -
    {children}
    -
    - ); -} - -export function AgentBuilderSidebarFallback() { - return ( - - ); +export function AgentBuilderShell({ children }: { children: React.ReactNode }) { + return
    {children}
    ; } diff --git a/apps/app/components/agent-builder/agent-builder-sidebar.tsx b/apps/app/components/agent-builder/agent-builder-sidebar.tsx index 7b58eba03..1ebad37c4 100644 --- a/apps/app/components/agent-builder/agent-builder-sidebar.tsx +++ b/apps/app/components/agent-builder/agent-builder-sidebar.tsx @@ -1,14 +1,19 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import Bot from "@carbon/icons-react/es/Bot"; -import CheckmarkFilled from "@carbon/icons-react/es/CheckmarkFilled"; -import CircleFilled from "@carbon/icons-react/es/CircleFilled"; -import Renew from "@carbon/icons-react/es/Renew"; -import { Button } from "@crm/ui/components/button"; -import { Icon } from "@crm/ui/components/icon"; -import { cn } from "@crm/ui/lib/utils"; +import { useAppShellNav } from "@crm/ui/components/app-shell"; +import { + AppSidebarGroup, + AppSidebarItem, + AppSidebarSection, + AppSidebarSkeleton, +} from "@crm/ui/components/app-sidebar"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + BotIcon, + CircleCheckIcon, + LoaderCircleIcon, + PlusIcon, +} from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { type ChatDateGroup, chatDateGroup } from "@/lib/chat-date-group"; @@ -19,31 +24,16 @@ import { useWorkspaceUrl } from "@/lib/use-workspace-url"; import { DeleteChatAction } from "./delete-chat-action"; type Conversation = RouterOutputs["conversations"]["builderList"][number]; -type TeamAgent = RouterOutputs["agents"]["list"][number]; -type SidebarData = { - conversations: Conversation[]; - agents: TeamAgent[]; - updatedAt: number; -}; - -export function AgentBuilderSidebar({ - className, - onNavigate, - initialData, -}: { - className?: string; - onNavigate?: () => void; - initialData?: SidebarData; -}) { + +export function AgentChatNav() { const pathname = usePathname(); const workspaceUrl = useWorkspaceUrl(); const trpc = useTRPC(); const queryClient = useQueryClient(); const hydrated = useHydrated(); + const nav = useAppShellNav(); const conversations = useQuery({ ...trpc.conversations.builderList.queryOptions(), - initialData: initialData?.conversations, - initialDataUpdatedAt: initialData?.updatedAt, refetchInterval: (query) => query.state.data?.some((conversation) => conversation.state === "working") ? 2500 @@ -51,8 +41,6 @@ export function AgentBuilderSidebar({ }); const agents = useQuery({ ...trpc.agents.list.queryOptions(), - initialData: initialData?.agents, - initialDataUpdatedAt: initialData?.updatedAt, refetchInterval: 60_000, }); const markRead = useMutation( @@ -64,167 +52,115 @@ export function AgentBuilderSidebar({ }), ); - const showData = Boolean(initialData) || hydrated; - const groupNow = - initialData && !hydrated - ? initialData.updatedAt - : conversations.dataUpdatedAt; - const groups = groupConversations( - showData ? (conversations.data ?? []) : [], - showData ? groupNow : 0, - ); - const teamAgents = showData ? (agents.data ?? []) : []; + const newChatHref = workspaceUrl("/chat"); + const agentsHref = workspaceUrl("/agents"); + const loading = !hydrated || conversations.isPending || agents.isPending; + const groups = loading + ? [] + : groupConversations(conversations.data ?? [], conversations.dataUpdatedAt); + const teamAgents = loading ? [] : (agents.data ?? []); return ( - - ); -} - -function TeamAgents({ - agents, - pathname, - onNavigate, -}: { - agents: TeamAgent[]; - pathname: string; - onNavigate?: () => void; -}) { - const workspaceUrl = useWorkspaceUrl(); - - return ( -
    - - Team agents - {agents.length} - - {agents.map((agent) => { - const href = workspaceUrl(`/agents/${agent.id}`); - const active = pathname === href; - return ( - - - - - {agent.name} - - ); - })} -
    + } + onClick={() => { + if (conversation.unread) { + markRead.mutate({ id: conversation.id }); + } + }} + > + + + ); + })} + + ))} + + {!loading && groups.length === 0 ? ( +

    + No chats in the last 7 days. +

    + ) : null} + + + {teamAgents.length}} + > + + + {teamAgents.map((agent) => { + const href = workspaceUrl(`/agents/${agent.id}`); + return ( + + + + ); + })} + + ); } function ConversationState({ state }: { state: Conversation["state"] }) { if (state === "working") { - return ( - - - - ); + return ; } if (state === "unread") { return ( - - - + ); } if (state === "deployed") { - return ( - - - - ); + return ; } return null; diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 369037619..59cef46f7 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -1,8 +1,5 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import Close from "@carbon/icons-react/es/Close"; -import Warning from "@carbon/icons-react/es/Warning"; import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; @@ -14,6 +11,7 @@ import { import { SaveBar } from "@crm/ui/components/save-bar"; import { Switch } from "@crm/ui/components/switch"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleAlertIcon, PlusIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -92,7 +90,7 @@ export function AgentCapabilities({ if (!capabilities.readable) { return ( - + This version's manifest cannot be read {capabilities.problem ?? "The manifest is not in a shape we know."} @@ -235,8 +233,9 @@ export function AgentCapabilities({ >

    {tool.summary}

    -

    - {grant.platformSlug} · {tool.name} +

    + {grant.platformSlug} + {tool.name}

    @@ -286,7 +285,7 @@ export function AgentCapabilities({ } type="button" > - + ) : null} @@ -362,7 +361,7 @@ function ResourcePicker({ onPick }: { onPick: (resource: Resource) => void }) { className="flex h-7 items-center gap-1.5 rounded-md border border-dashed px-2.5 text-muted-foreground text-sm hover:text-foreground" type="button" > - + Add a record type diff --git a/apps/app/components/agent-builder/agent-code-workspace.tsx b/apps/app/components/agent-builder/agent-code-workspace.tsx index 5200bae60..04c34c87b 100644 --- a/apps/app/components/agent-builder/agent-code-workspace.tsx +++ b/apps/app/components/agent-builder/agent-code-workspace.tsx @@ -1,7 +1,5 @@ "use client"; -import Checkmark from "@carbon/icons-react/es/Checkmark"; -import Renew from "@carbon/icons-react/es/Renew"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; import { File, MultiFileDiff } from "@pierre/diffs/react"; @@ -10,6 +8,7 @@ import { useFileTree, useFileTreeSelection, } from "@pierre/trees/react"; +import { CheckIcon, RefreshCwIcon } from "lucide-react"; import { useTheme } from "next-themes"; import { useState } from "react"; import { latestBuilderArtifacts } from "@/lib/agent-builder-state"; @@ -114,7 +113,7 @@ function AgentCodeWorkspaceSurface({ role={working ? "status" : undefined} > Stopping
    } errorLabel={Stop failed} > - + ) : ( @@ -592,7 +599,7 @@ export function AgentComposer({ successLabel={Sent} errorLabel={Send failed} > - + )} @@ -818,7 +825,7 @@ function ComposerContextToken({ content = ( @@ -858,7 +865,7 @@ function ComposerContextToken({ contentEditable={false} data-composer-token-key={anchor.key} data-composer-token-order={anchor.order} - className="inline-flex align-middle" + className="inline-flex max-w-full align-middle" > {content} @@ -1299,7 +1306,7 @@ function ResourcePicker({ aria-label="Tag CRM records and integrations" disabled={disabled} > - + @@ -1318,7 +1325,7 @@ function ResourcePicker({ disabled={disabled} /> - +
    @@ -1329,7 +1336,7 @@ function ResourcePicker({ return ( @@ -1427,7 +1434,7 @@ function AttachmentPicker({ disabled={disabled} onClick={() => input.current?.click()} > - + ); @@ -1477,7 +1484,7 @@ function CommandPicker({ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none hover:bg-muted focus-visible:bg-muted" disabled={disabled} > - + /{CREATE_AGENT_COMMAND.label} @@ -1501,11 +1508,11 @@ function ResourceResultsSkeleton() { } const RESOURCE_ICONS = { - integration: Application, - company: Building, - contact: User, - deal: Partnership, -} satisfies Record; + integration: AppWindowIcon, + company: Building2Icon, + contact: UserIcon, + deal: HandshakeIcon, +} satisfies Record; function ResourceButton({ icon, @@ -1514,7 +1521,7 @@ function ResourceButton({ disabled, onSelect, }: { - icon: CarbonIcon; + icon: IconComponent; label: string; imageUrl?: string | null; disabled: boolean; diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 7df33a1ea..0d33b61ee 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -1,10 +1,5 @@ "use client"; -import ChevronDown from "@carbon/icons-react/es/ChevronDown"; -import ChevronUp from "@carbon/icons-react/es/ChevronUp"; -import Download from "@carbon/icons-react/es/Download"; -import Renew from "@carbon/icons-react/es/Renew"; -import WarningAlt from "@carbon/icons-react/es/WarningAlt"; import { AlertDialog, AlertDialogAction, @@ -18,6 +13,13 @@ import { import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; import { cn } from "@crm/ui/lib/utils"; +import { + ChevronDownIcon, + ChevronUpIcon, + DownloadIcon, + RefreshCwIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useState } from "react"; import { z } from "zod"; import { runFailureReason } from "@/lib/agent-run-failure"; @@ -133,14 +135,15 @@ export function AgentRuns({ {humanStatus(run.status)} - - {humanStatus(run.triggerType)} · {formatDate(run.createdAt)} · - Version {run.version.number} + + {humanStatus(run.triggerType)} + {formatDate(run.createdAt)} + Version {run.version.number} {run.status === "FAILED" || run.status === "CANCELLED" ? ( @@ -156,7 +159,7 @@ export function AgentRuns({ {run.actions.length === 1 ? "action" : "actions"} @@ -170,7 +173,7 @@ export function AgentRuns({ disabled={retryingRunId === run.id} onClick={() => onRetry(run.id)} > - + Retry @@ -203,8 +206,8 @@ export function AgentRuns({ Stop this run? The agent stops where it is and the run is recorded as cancelled. - Anything it has already done — a note, a task, a Slack message — - stays done. + Anything it has already done, including notes, tasks and Slack + messages, stays done. @@ -288,11 +291,12 @@ function ExpandedRun({ run }: { run: RunRow }) { {entry.action.summary} - - {entry.action.provider} · {humanStatus(entry.action.status)} - {entry.action.targetLabel - ? ` · ${entry.action.targetLabel}` - : ""} + + {entry.action.provider} + {humanStatus(entry.action.status)} + {entry.action.targetLabel ? ( + {entry.action.targetLabel} + ) : null} @@ -358,7 +362,7 @@ export function AgentActivity({ activity }: { activity: Activity }) { size="sm" onClick={() => exportJson("agent-activity.json", visible)} > - + Export
    @@ -388,13 +392,13 @@ export function AgentActivity({ activity }: { activity: Activity }) {
    ) : null}
    - - Actor · + + Actor {event.actorUser?.name ?? event.actorId ?? event.actorType} - - Request · - {event.requestId?.slice(0, 12) ?? "—"} + + Request + {event.requestId?.slice(0, 12) ?? ""}
))} @@ -424,7 +428,7 @@ function formatTime(value: string): string { } function duration(startedAt: string | null, finishedAt: string | null): string { - if (!startedAt) return "—"; + if (!startedAt) return ""; if (!finishedAt) return "In progress"; const milliseconds = diff --git a/apps/app/components/agent-builder/chat-chips.tsx b/apps/app/components/agent-builder/chat-chips.tsx index e583f120d..6b4143173 100644 --- a/apps/app/components/agent-builder/chat-chips.tsx +++ b/apps/app/components/agent-builder/chat-chips.tsx @@ -1,5 +1,3 @@ -import Close from "@carbon/icons-react/es/Close"; -import Document from "@carbon/icons-react/es/Document"; import { Attachment, AttachmentAction, @@ -9,12 +7,13 @@ import { AttachmentTitle, } from "@crm/ui/components/attachment"; import { Avatar, AvatarFallback, AvatarImage } from "@crm/ui/components/avatar"; -import { type CarbonIcon, Icon } from "@crm/ui/components/icon"; +import { Icon, type IconComponent } from "@crm/ui/components/icon"; import { TokenFieldAction, TokenFieldItem, } from "@crm/ui/components/token-field"; import { cn } from "@crm/ui/lib/utils"; +import { FileTextIcon, XIcon } from "lucide-react"; import Image from "next/image"; type ChatChipVariant = "default" | "composer"; @@ -43,7 +42,7 @@ export function ChatReferenceChip({ variant = "default", }: { resource: ChatChipResource; - icon: CarbonIcon; + icon: IconComponent; onRemove?: () => void; variant?: ChatChipVariant; }) { @@ -65,7 +64,7 @@ export function ChatReferenceChip({ aria-label={`Remove ${resource.label}`} onClick={onRemove} > - + ) : null} @@ -82,7 +81,7 @@ export function ChatReferenceChip({ onClick={onRemove} className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60" > - + ) : null} @@ -96,7 +95,7 @@ export function ChatReferenceIdentity({ compact = false, }: { resource: ChatChipResource; - icon: CarbonIcon; + icon: IconComponent; showDetail?: boolean; compact?: boolean; }) { @@ -133,7 +132,6 @@ export function ChatAttachmentChip({ onRemove?: () => void; variant?: ChatChipVariant; }) { - const label = `${attachment.name} · ${formatBytes(attachment.size)}`; const image = isPreviewableImage(attachment.type) ? (attachment.previewUrl ?? (attachment.contentBase64 @@ -157,11 +155,16 @@ export function ChatAttachmentChip({ className="size-full object-cover" /> ) : ( - + )} - - {label} + + + {attachment.name} + + + {formatBytes(attachment.size)} + {onRemove ? ( @@ -170,7 +173,7 @@ export function ChatAttachmentChip({ aria-label={`Remove ${attachment.name}`} onClick={onRemove} > - + ) : null} @@ -185,7 +188,7 @@ export function ChatCommandChip({ variant = "default", }: { label: string; - icon: CarbonIcon; + icon: IconComponent; onRemove?: () => void; variant?: ChatChipVariant; }) { @@ -196,7 +199,7 @@ export function ChatCommandChip({ {label} {onRemove ? ( - + ) : null} @@ -214,7 +217,7 @@ export function ChatCommandChip({ onClick={onRemove} className="-mr-1 flex size-5 items-center justify-center rounded-sm outline-none hover:bg-primary-foreground/15 focus-visible:ring-2 focus-visible:ring-primary-foreground/60" > - + ) : null} diff --git a/apps/app/components/agent-builder/deal-list-result.tsx b/apps/app/components/agent-builder/deal-list-result.tsx index cd14ececb..a961f823a 100644 --- a/apps/app/components/agent-builder/deal-list-result.tsx +++ b/apps/app/components/agent-builder/deal-list-result.tsx @@ -62,7 +62,15 @@ export function DealListResultTable({ result }: { result: DealListResult }) { openRecord(record)} + onClick={() => + openRecord( + record, + result.deals.map((entry) => ({ + kind: "deal", + id: entry.id, + })), + ) + } onFocus={() => prefetchRecord(record)} onMouseEnter={() => prefetchRecord(record)} > @@ -117,7 +125,11 @@ export function DealListResultTable({ result }: { result: DealListResult }) { )}
- {tableMeta(result)} + + {tableMeta(result).map((detail) => ( + {detail} + ))} + As of @@ -147,7 +159,7 @@ function tableTitle(result: DealListResult): string { : `${count} ${stale}${status}deal${count === 1 ? "" : "s"}`; } -function tableMeta(result: DealListResult): string { +function tableMeta(result: DealListResult): string[] { const details = [ `${result.deals.length} deal${result.deals.length === 1 ? "" : "s"}`, pipelineTotal(result.deals), @@ -157,7 +169,7 @@ function tableMeta(result: DealListResult): string { result.hasMore ? "More results available" : null, ].filter((detail): detail is string => Boolean(detail)); - return details.join(" · "); + return details; } function humaniseStage(stage: string): string { diff --git a/apps/app/components/agent-builder/delete-chat-action.tsx b/apps/app/components/agent-builder/delete-chat-action.tsx index 1af2ddb66..03ee21047 100644 --- a/apps/app/components/agent-builder/delete-chat-action.tsx +++ b/apps/app/components/agent-builder/delete-chat-action.tsx @@ -1,8 +1,5 @@ "use client"; -import Close from "@carbon/icons-react/es/Close"; -import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; import { AlertDialog, AlertDialogCancel, @@ -25,6 +22,7 @@ import { } from "@crm/ui/components/dropdown-menu"; import { Icon } from "@crm/ui/components/icon"; import { useMutation } from "@tanstack/react-query"; +import { EllipsisVerticalIcon, Trash2Icon, XIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -82,7 +80,7 @@ export function DeleteChatAction({ aria-label={`Delete ${title}`} onClick={() => setConfirming(true)} > - + ) : ( @@ -92,7 +90,7 @@ export function DeleteChatAction({ size="icon-sm" disabled={removeAction.pending} > - + More chat actions @@ -101,7 +99,7 @@ export function DeleteChatAction({ variant="destructive" onSelect={() => setConfirming(true)} > - + Delete chat diff --git a/apps/app/components/agent-builder/new-agent-dialog.tsx b/apps/app/components/agent-builder/new-agent-dialog.tsx index 22c6bc598..58d7b4649 100644 --- a/apps/app/components/agent-builder/new-agent-dialog.tsx +++ b/apps/app/components/agent-builder/new-agent-dialog.tsx @@ -1,6 +1,5 @@ "use client"; -import Checkmark from "@carbon/icons-react/es/Checkmark"; import { Button } from "@crm/ui/components/button"; import { Dialog, @@ -24,6 +23,7 @@ import { import { Textarea } from "@crm/ui/components/textarea"; import { InvalidInput, type Permission, parse, schemas } from "@crm/validation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -181,7 +181,7 @@ export function NewAgentDialog({ children }: { children: React.ReactNode }) { {on ? ( ) : null} diff --git a/apps/app/components/agent-builder/share-chat-dialog.tsx b/apps/app/components/agent-builder/share-chat-dialog.tsx index 53a2cddda..b61c05101 100644 --- a/apps/app/components/agent-builder/share-chat-dialog.tsx +++ b/apps/app/components/agent-builder/share-chat-dialog.tsx @@ -1,7 +1,5 @@ "use client"; -import Close from "@carbon/icons-react/es/Close"; -import Copy from "@carbon/icons-react/es/Copy"; import { AsyncButtonContent, useAsyncAction, @@ -20,6 +18,7 @@ import { import { Icon } from "@crm/ui/components/icon"; import { cn } from "@crm/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CopyIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; @@ -127,7 +126,7 @@ export function ShareChatDialog({ size="icon-sm" className="absolute top-4 right-4" > - + Close @@ -211,7 +210,7 @@ export function ShareChatDialog({ errorLabel="Try again" > {shareToken ? ( - + ) : null} {shareToken ? "Copy link" : "Replace link"} diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index fa4c0e3e1..62e2fa0de 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -1,9 +1,5 @@ "use client"; -import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; -import Pause from "@carbon/icons-react/es/Pause"; -import Play from "@carbon/icons-react/es/Play"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; import { AlertDialog, AlertDialogCancel, @@ -25,12 +21,6 @@ import { DropdownMenuTrigger, } from "@crm/ui/components/dropdown-menu"; import { Icon } from "@crm/ui/components/icon"; -import { SaveBarViewport } from "@crm/ui/components/save-bar"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { type ReactNode, useState } from "react"; -import { toast } from "sonner"; import { PageShell, PageShellActions, @@ -39,7 +29,19 @@ import { PageShellHeader, PageShellHeading, PageShellTitle, -} from "@/components/page-shell"; +} from "@crm/ui/components/page-shell"; +import { SaveBarViewport } from "@crm/ui/components/save-bar"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + EllipsisVerticalIcon, + PauseIcon, + PlayIcon, + Trash2Icon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useState } from "react"; +import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; @@ -200,7 +202,7 @@ export function TeamAgentDetail({ const nextRun = enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = - enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + enabledTriggers.map((trigger) => trigger.name).join(", ") || "Manual only"; return ( @@ -211,10 +213,10 @@ export function TeamAgentDetail({ {displayedDescription} - - Created by {data.createdBy.name} ·{" "} - {isDraft ? "Private draft" : "Team agent"} · Version{" "} - {displayedVersionNumber ?? "—"} + + Created by {data.createdBy.name} + {isDraft ? "Private draft" : "Team agent"} + Version {displayedVersionNumber ?? ""} @@ -259,7 +261,7 @@ export function TeamAgentDetail({ successLabel="Queued" errorLabel="Try again" > - + Run now @@ -277,7 +279,7 @@ export function TeamAgentDetail({ successLabel="Paused" errorLabel="Try again" > - + Pause @@ -295,7 +297,7 @@ export function TeamAgentDetail({ successLabel="Resumed" errorLabel="Try again" > - + Resume @@ -464,7 +466,7 @@ function DeleteAgentAction({ size="icon-sm" disabled={removeAction.pending} > - + More agent actions @@ -473,7 +475,7 @@ function DeleteAgentAction({ variant="destructive" onSelect={() => setConfirming(true)} > - + Delete agent diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..2306edb81 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -1,9 +1,9 @@ "use client"; -import ArrowRight from "@carbon/icons-react/es/ArrowRight"; -import Bot from "@carbon/icons-react/es/Bot"; import { Icon } from "@crm/ui/components/icon"; +import Bot from "@crm/ui/components/icons/bot"; import { useQuery } from "@tanstack/react-query"; +import { ArrowRightIcon } from "lucide-react"; import Link from "next/link"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; @@ -54,7 +54,7 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.runCount} runs diff --git a/apps/app/components/app-header.tsx b/apps/app/components/app-header.tsx deleted file mode 100644 index 807d15bec..000000000 --- a/apps/app/components/app-header.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import Asleep from "@carbon/icons-react/es/Asleep"; -import Light from "@carbon/icons-react/es/Light"; -import Logout from "@carbon/icons-react/es/Logout"; -import Menu from "@carbon/icons-react/es/Menu"; -import UserAvatar from "@carbon/icons-react/es/UserAvatar"; -import { Avatar, AvatarFallback, AvatarImage } from "@crm/ui/components/avatar"; -import { Button } from "@crm/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@crm/ui/components/dropdown-menu"; -import Logo from "@crm/ui/components/logo"; -import { Separator } from "@crm/ui/components/separator"; -import { Skeleton } from "@crm/ui/components/skeleton"; -import { useQuery } from "@tanstack/react-query"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -import { toast } from "sonner"; -import { EnrichmentQueue } from "@/components/enrichment-queue"; -import { useMobileNav } from "@/components/mobile-nav"; -import { signOutAndRedirect } from "@/lib/sign-out"; -import { useTRPC } from "@/lib/trpc/client"; -import { useWorkspaceUrl } from "@/lib/use-workspace-url"; -import { workspaceLabel } from "@/lib/workspace-label"; - -type User = { name: string; email: string; image: string | null }; - -export function AppHeader({ user }: { user: User }) { - const { setOpen: setMobileNavOpen } = useMobileNav(); - const trpc = useTRPC(); - const workspaceUrl = useWorkspaceUrl(); - const workspace = useQuery(trpc.workspace.get.queryOptions()); - const label = workspaceLabel(workspace.data?.name); - - return ( -
-
- - - - - - {label} -
- -
- - { - signOutAndRedirect().catch(() => - toast.error("Could not sign out."), - ); - }} - /> -
-
- ); -} - -export function AppHeaderFallback() { - return ( -
-
- - - - - -
- -
- - - -
- - Loading workspace header… - -
- ); -} - -function UserMenu({ user, onSignOut }: { user: User; onSignOut: () => void }) { - const { resolvedTheme, setTheme } = useTheme(); - const isDark = resolvedTheme === "dark"; - - return ( - - - - - - - - {user.email} - - - { - event.preventDefault(); - setTheme(isDark ? "light" : "dark"); - }} - > - {isDark ? : } - {isDark ? "Light mode" : "Dark mode"} - - - - - Sign out - - - - ); -} - -function initials(name: string): string { - return ( - name - .split(" ") - .map((part) => part[0]) - .filter(Boolean) - .slice(0, 2) - .join("") - .toUpperCase() || "?" - ); -} diff --git a/apps/app/components/app-icon-rail.tsx b/apps/app/components/app-icon-rail.tsx deleted file mode 100644 index 62e42503d..000000000 --- a/apps/app/components/app-icon-rail.tsx +++ /dev/null @@ -1,310 +0,0 @@ -"use client"; - -import Building from "@carbon/icons-react/es/Building"; -import Close from "@carbon/icons-react/es/Close"; -import Dashboard from "@carbon/icons-react/es/Dashboard"; -import Partnership from "@carbon/icons-react/es/Partnership"; -import Settings from "@carbon/icons-react/es/Settings"; -import Task from "@carbon/icons-react/es/Task"; -import UserMultiple from "@carbon/icons-react/es/UserMultiple"; -import { Button } from "@crm/ui/components/button"; -import type { CarbonIcon } from "@crm/ui/components/icon"; -import { Icon } from "@crm/ui/components/icon"; -import Bot from "@crm/ui/components/icons/bot"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@crm/ui/components/sheet"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@crm/ui/components/tooltip"; -import { cn } from "@crm/ui/lib/utils"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useMemo } from "react"; -import { AgentBuilderSidebar } from "@/components/agent-builder/agent-builder-sidebar"; -import { usePrefetchSection } from "@/components/crm/section-prefetch"; -import { useMobileNav } from "@/components/mobile-nav"; -import { useWorkspaceUrl } from "@/lib/use-workspace-url"; - -type RailItem = { - title: string; - href: string; - icon: CarbonIcon; - iconClassName?: string; - match: "exact" | "prefix"; - related?: string[]; -}; - -const ITEMS: RailItem[] = [ - { title: "Overview", href: "/", icon: Dashboard, match: "exact" }, - { - title: "Chat", - href: "/chat", - icon: Bot, - iconClassName: "size-5", - match: "prefix", - related: ["/agents"], - }, - { title: "Companies", href: "/companies", icon: Building, match: "prefix" }, - { - title: "Contacts", - href: "/contacts", - icon: UserMultiple, - match: "prefix", - }, - { title: "Deals", href: "/deals", icon: Partnership, match: "prefix" }, - { title: "Tasks", href: "/tasks", icon: Task, match: "prefix" }, - { title: "Settings", href: "/settings", icon: Settings, match: "prefix" }, -]; - -function isActive(item: RailItem, pathname: string): boolean { - return ( - pathname === item.href || - (item.match === "prefix" && pathname.startsWith(item.href)) || - Boolean(item.related?.some((prefix) => pathname.startsWith(prefix))) - ); -} - -function RailLink({ - item, - active, - onPrefetch, -}: { - item: RailItem; - active: boolean; - onPrefetch: () => void; -}) { - return ( - - - - - {item.title} - - ); -} - -function MobileRailLink({ - item, - active, - onNavigate, - onPrefetch, -}: { - item: RailItem; - active: boolean; - onNavigate: () => void; - onPrefetch: () => void; -}) { - return ( - - ); -} - -function MobileRailIconLink({ - item, - active, - onNavigate, - onPrefetch, -}: { - item: RailItem; - active: boolean; - onNavigate: () => void; - onPrefetch: () => void; -}) { - return ( - - ); -} - -export function AppIconRailFallback() { - return ( - - ); -} - -export function AppIconRail() { - const pathname = usePathname(); - const workspaceUrl = useWorkspaceUrl(); - const { open, setOpen } = useMobileNav(); - const prefetchSection = usePrefetchSection(); - - const items = useMemo( - () => - ITEMS.map((item) => ({ - ...item, - section: item.href, - href: workspaceUrl(item.href), - related: item.related?.map((path) => workspaceUrl(path)), - })), - [workspaceUrl], - ); - const inChat = items.some( - (item) => item.title === "Chat" && isActive(item, pathname), - ); - - return ( - <> - - - - {inChat ? ( - - - Navigation and agent chats - - - setOpen(false)} - /> - - ) : ( - - - Navigation - - - - )} - - - ); -} diff --git a/apps/app/components/app-toaster.tsx b/apps/app/components/app-toaster.tsx new file mode 100644 index 000000000..6dd82a74e --- /dev/null +++ b/apps/app/components/app-toaster.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Toaster } from "@crm/ui/components/sonner"; +import { usePathname } from "next/navigation"; + +const CLASSIC_TOAST_ROUTES = new Set([ + "/sign-in", + "/grant-access", + "/onboarding", + "/onboarding/research", +]); + +export function AppToaster() { + const pathname = usePathname(); + const variant = CLASSIC_TOAST_ROUTES.has(pathname) ? "classic" : "default"; + + return ; +} diff --git a/apps/app/components/auth-shell.tsx b/apps/app/components/auth-shell.tsx index 72a493866..856798198 100644 --- a/apps/app/components/auth-shell.tsx +++ b/apps/app/components/auth-shell.tsx @@ -5,7 +5,7 @@ import { AuthShader } from "@/components/auth-shader"; export function AuthShell({ children }: { children: ReactNode }) { return ( -
+
diff --git a/apps/app/components/crm/agent-conversations.tsx b/apps/app/components/crm/agent-conversations.tsx index f2079dd19..2d266ecf4 100644 --- a/apps/app/components/crm/agent-conversations.tsx +++ b/apps/app/components/crm/agent-conversations.tsx @@ -1,8 +1,5 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import ChevronDown from "@carbon/icons-react/es/ChevronDown"; -import TrashCan from "@carbon/icons-react/es/TrashCan"; import { Button } from "@crm/ui/components/button"; import { DropdownMenu, @@ -13,6 +10,7 @@ import { } from "@crm/ui/components/dropdown-menu"; import { Icon } from "@crm/ui/components/icon"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { LocalDateTime } from "@/components/local-date-time"; import { useTRPC } from "@/lib/trpc/client"; @@ -43,16 +41,16 @@ export function ConversationPicker({ const label = current?.title ?? "New conversation"; return ( -
+
@@ -80,7 +78,7 @@ export function ConversationPicker({ - + New conversation @@ -123,7 +121,7 @@ function Forget({ disabled={busy || remove.isPending} onClick={() => remove.mutate({ id: conversation.id })} > - + Delete this conversation ); diff --git a/apps/app/components/crm/agent-panel.tsx b/apps/app/components/crm/agent-panel.tsx index 1df9c32e9..71708d9ff 100644 --- a/apps/app/components/crm/agent-panel.tsx +++ b/apps/app/components/crm/agent-panel.tsx @@ -1,12 +1,5 @@ "use client"; -import Checkmark from "@carbon/icons-react/es/Checkmark"; -import CircleDash from "@carbon/icons-react/es/CircleDash"; -import Document from "@carbon/icons-react/es/Document"; -import LogoGithub from "@carbon/icons-react/es/LogoGithub"; -import LogoLinkedin from "@carbon/icons-react/es/LogoLinkedin"; -import Send from "@carbon/icons-react/es/Send"; -import Warning from "@carbon/icons-react/es/Warning"; import { Attachment, AttachmentContent, @@ -15,18 +8,16 @@ import { AttachmentTitle, AttachmentTrigger, } from "@crm/ui/components/attachment"; +import GitHubLogo from "@crm/ui/components/brand-logos/github"; +import LinkedInLogo from "@crm/ui/components/brand-logos/linkedin"; import { Bubble, BubbleContent } from "@crm/ui/components/bubble"; import { Button } from "@crm/ui/components/button"; +import { ChatComposer } from "@crm/ui/components/chat-composer"; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@crm/ui/components/empty"; -import { type CarbonIcon, Icon } from "@crm/ui/components/icon"; -import { Input } from "@crm/ui/components/input"; + ChatEmptyState, + ChatSuggestions, +} from "@crm/ui/components/chat-thread"; +import { Icon, type IconComponent } from "@crm/ui/components/icon"; import Logo from "@crm/ui/components/logo"; import { Markdown } from "@crm/ui/components/markdown"; import { Marker, MarkerContent, MarkerIcon } from "@crm/ui/components/marker"; @@ -43,10 +34,19 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from "@crm/ui/components/message-scroller"; +import { SkeletonField, SkeletonLine } from "@crm/ui/components/skeleton-lines"; import { Spinner } from "@crm/ui/components/spinner"; +import { useMountEffect } from "@crm/ui/hooks/use-mount-effect"; +import { SKELETON, skeletonRows } from "@crm/ui/lib/skeleton-config"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEveAgent } from "eve/react"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { + CheckIcon, + CircleAlertIcon, + CircleDashedIcon, + FileTextIcon, +} from "lucide-react"; +import { useRef, useState } from "react"; import { AgentClarificationComposer } from "@/components/agent-clarification-composer"; import { type Conversation, @@ -75,6 +75,7 @@ import { type TranscriptItem, toTranscript, } from "@/lib/agent-transcript"; +import { claimCursor, conversationCursor } from "@/lib/conversation-cursor"; import { useTRPC } from "@/lib/trpc/client"; import { useRecordSheetView } from "./record-sheet/record-stack"; @@ -185,8 +186,18 @@ function ThreadWithHistory({ function Loading() { return ( -
- +
+
+ {skeletonRows(SKELETON.rows.messages).map((row) => ( +
+ + +
+ ))} +
+
+ +
); } @@ -213,14 +224,6 @@ function Thread({ const opening = useRef(conversation?.title ?? null); - useSavedConversation({ - record: recordFilter(record), - conversation, - opening, - session: agent.session ?? null, - messages: agent.data.messages.length, - }); - const busy = agent.status === "submitted" || agent.status === "streaming"; const messages = toTranscript(agent.data.messages); const question = pendingQuestion(agent.data.messages); @@ -236,44 +239,53 @@ function Thread({ return (
- - - - - {messages.length === 0 && !busy ? ( - - ) : null} - - {messages.map((message) => ( - -
- {message.items.map((item) => - item.kind === "asked" && - item.question.requestId === question?.requestId ? null : ( - - ), - )} -
-
- ))} -
-
- - -
-
+ + + {messages.length === 0 && !busy ? ( + + ) : ( + + + + + {messages.map((message) => ( + +
+ {message.items.map((item) => + item.kind === "asked" && + item.question.requestId === + question?.requestId ? null : ( + + ), + )} +
+
+ ))} +
+
+ + +
+
+ )} {agent.error ? : null} {thread?.status === "working" && !busy ? ( -

+

Still working on the last question. Your next one can go in when it finishes.

) : null} {ended ? ( -
+

This conversation has ended.

@@ -283,7 +295,7 @@ function Thread({
) : null} -
+
{question ? ( agent.respond([response])} /> ) : ( -
{ - event.preventDefault(); - ask(draft); - }} - > - setDraft(event.target.value)} - placeholder={copy.placeholder} - disabled={locked} - /> - -
+ )}
@@ -331,30 +330,14 @@ function Idle({ const copy = recordCopy(kind); return ( - - - - - - - - {copy.title} - {copy.blurb} - - - - {copy.suggestions.map((suggestion) => ( - - ))} - - +
+ + +
); } @@ -366,7 +349,7 @@ function Failure({ message }: { message: string }) { : null; return ( -
+

{message}

{hint ? (

{hint}

@@ -376,16 +359,16 @@ function Failure({ message }: { message: string }) { } const TONE_ICONS = { - neutral: CircleDash, - success: Checkmark, - warning: Warning, -} satisfies Record; + neutral: CircleDashedIcon, + success: CheckIcon, + warning: CircleAlertIcon, +} satisfies Record; const SOURCE_ICONS = { - linkedin: LogoLinkedin, - github: LogoGithub, - web: Document, -} satisfies Record; + linkedin: LinkedInLogo, + github: GitHubLogo, + web: FileTextIcon, +} satisfies Record; function Item({ item }: { item: TranscriptItem }) { if (item.kind === "said") { @@ -471,7 +454,7 @@ function AgentAvatar() { ); } -function useSavedConversation({ +function SavedConversation({ record, conversation, opening, @@ -491,16 +474,19 @@ function useSavedConversation({ const trpc = useTRPC(); const queryClient = useQueryClient(); const save = useMutation(trpc.conversations.save.mutationOptions({})); + const written = useRef(null); const sessionId = session?.sessionId ?? null; const token = session?.continuationToken ?? null; const streamIndex = session?.streamIndex ?? 0; - const { contactId, companyId, dealId } = record; + const cursor = conversationCursor({ sessionId, token, messages }); + if (!cursor) return null; + const { contactId, companyId, dealId } = record; const isNew = conversation === null || conversation.sessionId !== sessionId; - const written = useRef(null); - const persist = useEffectEvent(() => { + const persist = () => { + if (!claimCursor(written, cursor)) return; save.mutate( { contactId: contactId || undefined, @@ -521,14 +507,12 @@ function useSavedConversation({ }, }, ); - }); + }; - useEffect(() => { - if (!sessionId) return; + return ; +} - const cursor = `${sessionId}:${token ?? ""}:${messages}`; - if (written.current === cursor) return; - written.current = cursor; - persist(); - }, [sessionId, token, messages]); +function SaveCursor({ onMount }: { onMount: () => void }) { + useMountEffect(onMount); + return null; } diff --git a/apps/app/components/crm/bulk-actions.tsx b/apps/app/components/crm/bulk-actions.tsx index 7c15c8999..225072fc2 100644 --- a/apps/app/components/crm/bulk-actions.tsx +++ b/apps/app/components/crm/bulk-actions.tsx @@ -1,6 +1,5 @@ "use client"; -import ChevronDown from "@carbon/icons-react/es/ChevronDown"; import { AlertDialog, AlertDialogAction, @@ -24,6 +23,7 @@ import { DropdownMenuTrigger, } from "@crm/ui/components/dropdown-menu"; import { Spinner } from "@crm/ui/components/spinner"; +import { ChevronDownIcon } from "lucide-react"; import type { ReactNode } from "react"; import { toast } from "sonner"; @@ -47,7 +47,7 @@ export function reportBulk( toast.error( `${done(result.succeeded)} ${result.failed} ${ result.failed === 1 ? "was" : "were" - } left alone${result.message ? ` — ${result.message}` : "."}`, + } left alone${result.message ? `. ${result.message}` : "."}`, ); return; } @@ -70,9 +70,12 @@ export function BulkActionsMenu({ diff --git a/apps/app/components/crm/company-picker.tsx b/apps/app/components/crm/company-picker.tsx index 8847a9a11..96fa010ed 100644 --- a/apps/app/components/crm/company-picker.tsx +++ b/apps/app/components/crm/company-picker.tsx @@ -13,7 +13,7 @@ import { Spinner } from "@crm/ui/components/spinner"; import { useSearchInput } from "@crm/ui/hooks/use-search-input"; import { cn } from "@crm/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { type Ref, useId, useState } from "react"; +import { type AriaAttributes, type Ref, useId, useState } from "react"; import { PROPERTY_LABEL, PROPERTY_ROW } from "@/components/detail-sheet"; import { useTRPC } from "@/lib/trpc/client"; @@ -27,6 +27,8 @@ export function CompanyPicker({ disabled, variant, className, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, }: { id?: string; value: string; @@ -37,6 +39,8 @@ export function CompanyPicker({ disabled?: boolean; variant?: "default" | "ghost"; className?: string; + "aria-invalid"?: AriaAttributes["aria-invalid"]; + "aria-describedby"?: string; }) { const trpc = useTRPC(); @@ -69,6 +73,8 @@ export function CompanyPicker({ return ( { setChosen(options.find((option) => option.value === next) ?? null); diff --git a/apps/app/components/crm/enrichment-actions.tsx b/apps/app/components/crm/enrichment-actions.tsx index f7d10d301..6a70b4df4 100644 --- a/apps/app/components/crm/enrichment-actions.tsx +++ b/apps/app/components/crm/enrichment-actions.tsx @@ -1,11 +1,10 @@ "use client"; -import MagicWand from "@carbon/icons-react/es/MagicWand"; -import Renew from "@carbon/icons-react/es/Renew"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; import { Spinner } from "@crm/ui/components/spinner"; import { useMutation } from "@tanstack/react-query"; +import { RefreshCwIcon, WandSparklesIcon } from "lucide-react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -26,7 +25,7 @@ export function EnrichmentActions({ await cache.company(companyId); toast.success( result.queued - ? "Looking it up — this page will update when it finishes." + ? "Looking it up. This page updates when it finishes." : "Already running.", ); }, @@ -40,7 +39,7 @@ export function EnrichmentActions({ await cache.activity(); toast.success( result.queued - ? "Researching — the brief lands on the timeline when it finishes." + ? "Researching. The brief appears on the timeline when it finishes." : "Already researching.", ); }, @@ -59,7 +58,7 @@ export function EnrichmentActions({ {enrich.isPending ? ( ) : ( - + )} Re-enrich @@ -72,7 +71,7 @@ export function EnrichmentActions({ {research.isPending ? ( ) : ( - + )} Research @@ -90,7 +89,7 @@ export function ContactEnrichmentAction({ contactId }: { contactId: string }) { await cache.contact(contactId); toast.success( result.queued - ? "Taking another look — this page will update when it finishes." + ? "Taking another look. This page updates when it finishes." : "Already running.", ); }, @@ -108,7 +107,7 @@ export function ContactEnrichmentAction({ contactId }: { contactId: string }) { {enrich.isPending ? ( ) : ( - + )} Re-enrich diff --git a/apps/app/components/crm/facts.tsx b/apps/app/components/crm/facts.tsx index 97c802bcb..0528e0891 100644 --- a/apps/app/components/crm/facts.tsx +++ b/apps/app/components/crm/facts.tsx @@ -41,7 +41,7 @@ export function FactSuggestion({ toast.success( result.applied ? "Added to the record." - : "Dismissed — it won't be suggested again.", + : "Dismissed. This suggestion stays hidden.", ); return cache.contact(contactId, { settle: "record" }); }, @@ -52,7 +52,11 @@ export function FactSuggestion({ return ( item.detail).join(" · ")} + rationale={fact.evidence.map((item) => ( + + {item.detail} + + ))} pending={decide.isPending} onAccept={() => decide.mutate({ factId: fact.id, decision: "accept" })} onDismiss={() => decide.mutate({ factId: fact.id, decision: "dismiss" })} diff --git a/apps/app/components/crm/fields/field-columns.tsx b/apps/app/components/crm/fields/field-columns.tsx index f46b2797b..5ed52592d 100644 --- a/apps/app/components/crm/fields/field-columns.tsx +++ b/apps/app/components/crm/fields/field-columns.tsx @@ -56,10 +56,10 @@ export function useFieldColumns( .map((field) => ({ id: `field:${field.key}`, header: field.label, - width: "w-[12%]", + width: "36" as const, hideBelow: "lg" as const, cell: (row: Row) => ( - + {render(field.type, row.fields[field.key] ?? null, byId)} ), diff --git a/apps/app/components/crm/fields/field-editor.tsx b/apps/app/components/crm/fields/field-editor.tsx index ab0e5ef9c..eb46e6ab9 100644 --- a/apps/app/components/crm/fields/field-editor.tsx +++ b/apps/app/components/crm/fields/field-editor.tsx @@ -1,7 +1,5 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import Close from "@carbon/icons-react/es/Close"; import { FIELD_TYPES, fieldKeyFromLabel, @@ -39,6 +37,7 @@ import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { Switch } from "@crm/ui/components/switch"; import { Textarea } from "@crm/ui/components/textarea"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PlusIcon, XIcon } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -88,16 +87,16 @@ type Draft = { }; const TYPE_HINTS = { - TEXT: "Text — a short line", - LONG_TEXT: "Long text — a paragraph", + TEXT: "Text: a short line", + LONG_TEXT: "Long text: a paragraph", NUMBER: "Number", DATE: "Date", - CHECKBOX: "Checkbox — yes or no", - SELECT: "Select — one of a fixed list", + CHECKBOX: "Checkbox: yes or no", + SELECT: "Select: one of a fixed list", URL: "URL", EMAIL: "Email", PHONE: "Phone", - USER: "User — someone in the workspace", + USER: "User: someone in the workspace", } satisfies Record<(typeof FIELD_TYPES)[number], string>; function optionId(option: { id?: string }, index: number): string { @@ -262,7 +261,7 @@ export function FieldEditor({
{KEY_LABEL} - {key || "—"} + {key || ""}
{KEY_HELP} @@ -364,7 +363,7 @@ export function FieldEditor({ }) } > - + Remove {optionLabel(index)} @@ -381,7 +380,7 @@ export function FieldEditor({ patch({ options: [...draft.options, { label: "" }] }) } > - + {ADD_OPTION} diff --git a/apps/app/components/crm/fields/fields-copy.ts b/apps/app/components/crm/fields/fields-copy.ts index 7f209b58b..bbee1babe 100644 --- a/apps/app/components/crm/fields/fields-copy.ts +++ b/apps/app/components/crm/fields/fields-copy.ts @@ -40,7 +40,7 @@ export const RETRY = "Try again"; export const LABEL_LABEL = "Label"; export const KEY_LABEL = "Key"; export const KEY_HELP = - "What the API and your agents call it. Set from the label, fixed once saved — renaming the label later never breaks a caller."; + "What the API and your agents call it. Set from the label, fixed once saved. Renaming the label never breaks a caller."; export const AGENT_LABEL = "Let your agents fill this"; export const AGENT_HELP = "They propose a value with a source, and never overwrite yours."; diff --git a/apps/app/components/crm/fields/fields-list.tsx b/apps/app/components/crm/fields/fields-list.tsx index 83521701c..025150d87 100644 --- a/apps/app/components/crm/fields/fields-list.tsx +++ b/apps/app/components/crm/fields/fields-list.tsx @@ -1,10 +1,5 @@ "use client"; -import Add from "@carbon/icons-react/es/Add"; -import ChevronRight from "@carbon/icons-react/es/ChevronRight"; -import OverflowMenuVertical from "@carbon/icons-react/es/OverflowMenuVertical"; -import Renew from "@carbon/icons-react/es/Renew"; -import Warning from "@carbon/icons-react/es/Warning"; import { fieldKeyFromLabel, typeLabel } from "@crm/db/fields-shape"; import { Badge } from "@crm/ui/components/badge"; import { Button } from "@crm/ui/components/button"; @@ -29,10 +24,19 @@ import { EmptyTitle, } from "@crm/ui/components/empty"; import { Icon } from "@crm/ui/components/icon"; +import { Skeleton } from "@crm/ui/components/skeleton"; +import { SkeletonLine } from "@crm/ui/components/skeleton-lines"; import { SortableItem, SortableList } from "@crm/ui/components/sortable-list"; -import { Spinner } from "@crm/ui/components/spinner"; +import { SKELETON, skeletonRows } from "@crm/ui/lib/skeleton-config"; import { FIELD_TEMPLATES } from "@crm/validation/field-templates"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ChevronRightIcon, + CircleAlertIcon, + EllipsisVerticalIcon, + PlusIcon, + RefreshCwIcon, +} from "lucide-react"; import { toast } from "sonner"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -65,7 +69,7 @@ type Field = RouterOutputs["fields"]["list"][number]; const ROW = "flex items-center gap-2.5 border-b px-5 py-2"; -function summaryOf(field: Field): string { +function summaryOf(field: Field): string[] { const parts: string[] = []; if (field.agentFilled) { @@ -83,7 +87,7 @@ function summaryOf(field: Field): string { if (field.showOnTable) parts.push(TABLE_NOTE); if (field.showOnFilter) parts.push(FILTER_NOTE); - return parts.join(" · "); + return parts; } function reordered(fields: Field[], ids: string[]): Field[] { @@ -102,10 +106,12 @@ function reordered(fields: Field[], ids: string[]): Field[] { function DisclosureRow({ title, + count, note, children, }: { title: string; + count: number; note: string; children: React.ReactNode; }) { @@ -117,13 +123,16 @@ function DisclosureRow({ className="flex w-full items-center gap-2 border-b px-5 py-2.5 text-left" > {title} - {note} + + {count} + {note} + {children} @@ -206,7 +215,8 @@ export function FieldsList({
    {standard.map((field) => ( @@ -223,7 +233,8 @@ export function FieldsList({ {suggestions.length > 0 && (
      {suggestions.map((template) => ( @@ -232,10 +243,14 @@ export function FieldsList({ className="flex items-center gap-2.5 px-5 py-1.5" > - {template.label} - {template.options && template.options.length > 0 - ? ` · ${template.options.join(", ")}` - : ` · ${typeLabel(template.type)}`} + + {template.label} + + + {template.options && template.options.length > 0 + ? template.options.join(", ") + : typeLabel(template.type)} + @@ -269,14 +284,22 @@ export function FieldsList({ )} {query.isPending ? ( -
      - -
      +
        + {skeletonRows(SKELETON.rows.fields).map((row) => ( +
      • +
        + + +
        + +
      • + ))} +
      ) : query.isError ? ( - + {ERROR_TITLE} {ERROR_BODY} @@ -287,7 +310,7 @@ export function FieldsList({ disabled={query.isFetching} onClick={() => query.refetch()} > - + {RETRY} @@ -298,14 +321,14 @@ export function FieldsList({ - + {EMPTY_TITLE} {EMPTY_BODY} @@ -340,8 +363,10 @@ export function FieldsList({ {field.label} - - {summaryOf(field)} + + {summaryOf(field).map((part) => ( + {part} + ))} @@ -355,7 +380,7 @@ export function FieldsList({ diff --git a/apps/app/components/crm/fields/fields-sheet.tsx b/apps/app/components/crm/fields/fields-sheet.tsx index 5db374777..cf23d342a 100644 --- a/apps/app/components/crm/fields/fields-sheet.tsx +++ b/apps/app/components/crm/fields/fields-sheet.tsx @@ -1,7 +1,8 @@ "use client"; -import { Spinner } from "@crm/ui/components/spinner"; +import { SkeletonField, SkeletonLine } from "@crm/ui/components/skeleton-lines"; import { Tabs, TabsList, TabsTrigger } from "@crm/ui/components/tabs"; +import { SKELETON, skeletonRows } from "@crm/ui/lib/skeleton-config"; import { useQuery } from "@tanstack/react-query"; import { type RecordKind, @@ -58,16 +59,31 @@ function FieldsSheetBody({ + {entityLabel} + + {filled.filled} of {filled.total} filled + + + ) : ( + entityLabel + ) } onBack={() => onEdit(null)} onClose={onClose} /> {editingKey && query.isPending ? ( -
      - +
      + {skeletonRows(SKELETON.rows.editor).map((row) => ( +
      + + +
      + ))}
      ) : ( diff --git a/apps/app/components/crm/inline-field.tsx b/apps/app/components/crm/inline-field.tsx index 2673fb959..159e9b196 100644 --- a/apps/app/components/crm/inline-field.tsx +++ b/apps/app/components/crm/inline-field.tsx @@ -22,10 +22,7 @@ const ROW = cn(PROPERTY_ROW, "items-center"); const LABEL = PROPERTY_LABEL; const CONTROL = "h-8 w-full justify-start px-2 font-normal hover:border-input hover:bg-muted/40 border border-transparent"; -const BLOCK_CONTROL = cn( - CONTROL, - "h-auto min-h-16 items-start whitespace-pre-wrap py-2 text-left leading-5", -); +const BLOCK_CONTROL = "hover:border-input hover:bg-muted/40"; export function savingValue(update: { isPending: boolean; @@ -240,6 +237,7 @@ export function InlineTextArea({ if (editing) { return (