From a152b3063d376c6435dceee297feab0e31edd50f Mon Sep 17 00:00:00 2001 From: agualdron Date: Thu, 28 May 2026 15:44:00 -0500 Subject: [PATCH 01/49] feat(i18n): add locale-aware routing and translate app copy Wire *next-intl* into app routing, request handling, layouts, and metadata generation. Move public, auth, admin, chat, invite, unsubscribe, workspace, and workflow widget copy into shared *en*, *es*, and *zh-CN* message catalogs. Localize navigation, auth callbacks, markdown and legal routes, blog metadata, and workflow tooling, and add audit or sync scripts plus regression tests for locale-aware behavior. --- .../(auth)/components/auth-waitlist-note.tsx | 10 +- .../components/social-login-buttons.tsx | 61 +- .../(auth)/components/sso-login-button.tsx | 16 +- apps/tradinggoose/app/(auth)/error/page.tsx | 22 +- apps/tradinggoose/app/(auth)/layout.tsx | 7 +- .../app/(auth)/login/login-form.tsx | 262 +- .../app/(auth)/reset-password/page.tsx | 41 +- .../reset-password/reset-password-form.tsx | 37 +- apps/tradinggoose/app/(auth)/signup/page.tsx | 26 +- .../app/(auth)/signup/signup-form.tsx | 257 +- apps/tradinggoose/app/(auth)/sso/page.tsx | 7 +- apps/tradinggoose/app/(auth)/sso/sso-form.tsx | 158 +- .../app/(auth)/verify/use-verification.ts | 114 +- .../app/(auth)/verify/verify-content.tsx | 52 +- .../tradinggoose/app/(auth)/waitlist/page.tsx | 38 +- .../(auth)/waitlist/waitlist-form.test.tsx | 138 + .../app/(auth)/waitlist/waitlist-form.tsx | 74 +- .../app/(landing)/blog/[slug]/page.tsx | 32 +- .../blog/components/ai-summarize.tsx | 12 +- .../blog/components/breadcrumb-nav.tsx | 13 +- .../blog/components/filtered-posts.tsx | 18 +- .../blog/components/markdown-content.tsx | 5 +- .../(landing)/blog/components/post-card.tsx | 17 +- .../blog/components/social-share.tsx | 19 +- .../blog/components/table-of-contents.tsx | 8 +- .../app/(landing)/blog/lib/heading-slugs.ts | 8 +- apps/tradinggoose/app/(landing)/blog/page.tsx | 50 +- .../app/(landing)/careers/careers-form.tsx | 132 +- .../app/(landing)/careers/page.tsx | 25 +- .../app/(landing)/components/blog-layout.tsx | 73 +- .../app/(landing)/components/cta/cta.tsx | 101 +- .../layout-preview/layout-preview.tsx | 17 +- .../landing-indicator-dropdown.tsx | 21 +- .../market-preview/landing-widget-shell.tsx | 10 +- .../workflow-preview-canvas.tsx | 10 +- .../workflow-preview-demos.ts | 871 +- .../workflow-preview/workflow-preview.tsx | 28 +- .../(landing)/components/feature/feature.tsx | 61 +- .../(landing)/components/footer/footer.tsx | 80 +- .../app/(landing)/components/hero/hero.tsx | 51 +- .../components/how-it-works/how-it-works.tsx | 60 +- .../components/integrations/integrations.tsx | 38 +- .../landing-pricing/landing-pricing.tsx | 35 +- .../app/(landing)/components/legal-layout.tsx | 27 +- .../(landing)/components/legal-markdown.tsx | 68 + .../monitor-preview/monitor-preview.tsx | 134 +- .../monitor-preview/monitor-section.tsx | 20 +- .../components/nav/locale-switcher.ts | 13 + .../app/(landing)/components/nav/nav.test.tsx | 105 +- .../app/(landing)/components/nav/nav.tsx | 127 +- .../(landing)/components/structured-data.tsx | 28 +- apps/tradinggoose/app/(landing)/layout.tsx | 7 +- .../app/(landing)/licenses/page.tsx | 147 +- .../tradinggoose/app/{ => (landing)}/page.tsx | 29 +- .../app/(landing)/privacy/page.tsx | 343 +- .../tradinggoose/app/(landing)/terms/page.tsx | 312 +- .../app/admin/billing/billing-admin.tsx | 284 +- .../app/admin/billing/billing-unavailable.tsx | 24 +- .../tradinggoose/app/admin/billing/layout.tsx | 12 +- .../app/admin/billing/tier-detail.tsx | 57 +- .../app/admin/billing/tier-editor.tsx | 608 +- apps/tradinggoose/app/admin/constants.ts | 13 + apps/tradinggoose/app/admin/copy.ts | 3 + apps/tradinggoose/app/admin/errors.ts | 36 + .../admin/integrations/integrations-admin.tsx | 140 +- apps/tradinggoose/app/admin/layout.tsx | 9 +- apps/tradinggoose/app/admin/page.tsx | 70 +- .../admin/registration/registration-admin.tsx | 142 +- .../app/admin/services/services-admin.tsx | 149 +- .../app/admin/system-settings-section.tsx | 93 +- .../api/admin/system-settings/route.test.ts | 22 +- .../app/api/admin/system-settings/route.ts | 48 +- .../app/api/chat/[identifier]/otp/route.ts | 72 +- .../app/api/chat/[identifier]/route.ts | 104 +- apps/tradinggoose/app/api/chat/utils.test.ts | 13 +- apps/tradinggoose/app/api/chat/utils.ts | 147 +- .../users/me/settings/unsubscribe/route.ts | 33 +- .../app/api/waitlist/route.test.ts | 3 +- apps/tradinggoose/app/api/waitlist/route.ts | 7 +- .../invitations/[invitationId]/route.test.ts | 20 +- .../invitations/[invitationId]/route.ts | 31 +- .../components/changelog-content.tsx | 24 +- .../changelog/components/timeline-list.tsx | 24 +- apps/tradinggoose/app/changelog/layout.tsx | 11 +- apps/tradinggoose/app/changelog/page.tsx | 116 +- .../app/chat/[identifier]/chat.tsx | 50 +- .../chat/components/auth/email/email-auth.tsx | 109 +- .../auth/password/password-auth.tsx | 52 +- .../app/chat/components/auth/sso/sso-auth.tsx | 83 +- .../components/error-state/error-state.tsx | 25 +- .../app/chat/components/header/header.tsx | 27 +- .../app/chat/components/input/input.tsx | 113 +- .../app/chat/components/input/voice-input.tsx | 11 +- .../message-container/message-container.tsx | 19 +- .../app/chat/components/message/message.tsx | 24 +- .../voice-interface/voice-interface.tsx | 7 +- apps/tradinggoose/app/chat/constants.ts | 68 +- apps/tradinggoose/app/chat/copy.ts | 3 + apps/tradinggoose/app/chat/errors.ts | 128 + .../app/chat/hooks/use-chat-streaming.ts | 25 +- apps/tradinggoose/app/chat/layout.tsx | 6 + apps/tradinggoose/app/intl-provider.tsx | 26 + apps/tradinggoose/app/invite/[id]/invite.tsx | 132 +- apps/tradinggoose/app/invite/[id]/utils.ts | 32 +- .../app/invite/components/status-card.tsx | 31 +- apps/tradinggoose/app/invite/layout.tsx | 6 + apps/tradinggoose/app/layout.tsx | 6 +- .../esm/[...assetPath]/route.test.ts | 40 + .../esm/{vs => }/[...assetPath]/route.ts | 24 +- apps/tradinggoose/app/not-found-content.tsx | 76 + apps/tradinggoose/app/not-found.tsx | 72 +- apps/tradinggoose/app/unsubscribe/layout.tsx | 6 + .../app/unsubscribe/unsubscribe.tsx | 97 +- .../[workspaceId]/api-keys/api-keys.tsx | 28 +- .../api-keys/workspace-api-keys-card.tsx | 153 +- .../components/use-keyboard-shortcuts.ts | 31 +- .../dashboard/dashboard-client.tsx | 28 +- .../[workspaceId]/dashboard/layout-tabs.tsx | 14 +- .../components/environment-variables.tsx | 60 +- .../[workspaceId]/environment/environment.tsx | 24 +- .../workspace/[workspaceId]/files/files.tsx | 52 +- .../files/hooks/use-workspace-files.ts | 14 +- .../workspace/[workspaceId]/files/utils.ts | 11 +- .../integrations/integrations.tsx | 47 +- .../create-chunk-modal/create-chunk-modal.tsx | 40 +- .../components/document-loading.tsx | 8 +- .../edit-chunk-modal/edit-chunk-modal.tsx | 49 +- .../knowledge/[id]/[documentId]/document.tsx | 16 +- .../[workspaceId]/knowledge/[id]/base.tsx | 77 +- .../knowledge-base-loading.tsx | 8 +- .../components/upload-modal/upload-modal.tsx | 29 +- .../base-overview/base-overview.tsx | 53 +- .../copy-to-workspace/copy-to-workspace.tsx | 14 +- .../components/create-modal/create-modal.tsx | 145 +- .../document-tag-entry/document-tag-entry.tsx | 46 +- .../empty-state-card/empty-state-card.tsx | 4 +- .../knowledge-base-tags.tsx | 69 +- .../knowledge-header/knowledge-header.tsx | 8 +- .../knowledge-tags/knowledge-tags.tsx | 36 +- .../knowledge/components/shared.ts | 16 +- .../components/tag-input/tag-input.tsx | 35 +- .../[workspaceId]/knowledge/knowledge.tsx | 48 +- .../[workspaceId]/knowledge/loading.tsx | 8 +- .../app/workspace/[workspaceId]/layout.tsx | 10 +- .../monitor/components/board/board-state.ts | 63 +- .../components/board/monitor-board.tsx | 78 +- .../components/config/config-board-state.ts | 43 +- .../components/config/config-card-model.ts | 15 +- .../components/config/config-domain.test.ts | 4 +- .../components/config/config-search.tsx | 42 +- .../config/monitor-config-board.tsx | 45 +- .../components/data/execution-ordering.ts | 55 +- .../data/use-monitor-reference-data.ts | 36 +- .../data/use-monitor-workspace-logs.ts | 40 +- .../management/indicator-input-fields.tsx | 4 +- .../management/monitor-editor-form.tsx | 725 +- .../management/monitor-editor-panel.tsx | 30 +- .../management/use-monitor-editor-state.ts | 13 +- .../monitor/components/shared/monitor-ui.tsx | 30 +- .../components/timeline/gantt.test.tsx | 7 +- .../monitor/components/timeline/gantt.tsx | 83 +- .../components/timeline/timeline-state.ts | 6 +- .../monitor-timezone-menu.tsx | 17 +- .../monitor/components/view/view-bootstrap.ts | 38 +- .../workspace/monitor-config-workspace.tsx | 156 +- .../workspace/monitor-execution-workspace.tsx | 258 +- .../workspace/[workspaceId]/monitor/copy.ts | 248 + .../[workspaceId]/monitor/monitor.tsx | 172 +- .../app/workspace/[workspaceId]/page.tsx | 7 +- .../workspace-permissions-provider.test.tsx | 1 + .../workspace-permissions-provider.tsx | 35 +- .../components/log-details/log-details.tsx | 109 +- .../components/logs-list/logs-list.tsx | 43 +- .../filters/components/workflow.tsx | 19 +- .../components/filters/filters.tsx | 20 +- .../logs-toolbar/components/search/search.tsx | 5 +- .../components/orders/order-details.test.tsx | 2 +- .../components/orders/order-details.tsx | 78 +- .../components/orders/order-empty-state.tsx | 5 +- .../components/orders/order-filters.tsx | 61 +- .../components/orders/order-formatters.ts | 26 +- .../components/orders/order-row-actions.tsx | 10 +- .../components/orders/orders-table.tsx | 46 +- .../stats/components/line-chart.tsx | 11 +- .../components/logs-filters/logs-filters.tsx | 10 +- .../stats/components/workflow-details.tsx | 68 +- .../stats/components/workflows-list.tsx | 21 +- .../[workspaceId]/records/records.tsx | 21 +- .../workspace/[workspaceId]/records/utils.ts | 51 +- .../[workspaceId]/templates/[id]/page.tsx | 16 +- .../[workspaceId]/templates/[id]/template.tsx | 40 +- .../templates/components/template-card.tsx | 19 +- .../[workspaceId]/templates/page.tsx | 7 +- .../[workspaceId]/templates/templates.tsx | 91 +- apps/tradinggoose/app/workspace/layout.tsx | 9 +- apps/tradinggoose/app/workspace/page.tsx | 101 +- apps/tradinggoose/blocks/blocks/stagehand.ts | 2 +- .../blocks/blocks/stagehand_agent.ts | 2 +- apps/tradinggoose/blocks/types.ts | 14 + .../components/json-display/json-display.tsx | 7 +- .../components/oauth/oauth-required-modal.tsx | 24 +- .../trading-selector/provider-controls.tsx | 4 +- .../components/sidebar-nav.test.tsx | 126 + .../global-navbar/components/sidebar-nav.tsx | 45 +- .../components/user-menu.test.tsx | 247 + .../global-navbar/components/user-menu.tsx | 262 +- .../components/workspace-switcher.tsx | 27 +- .../global-navbar/global-navbar.tsx | 64 +- .../components/account/account-settings.tsx | 5 +- .../global-navbar/use-workspace-switcher.ts | 7 +- apps/tradinggoose/global-navbar/utils.test.ts | 31 +- apps/tradinggoose/global-navbar/utils.ts | 85 +- .../hooks/queries/admin-system-settings.ts | 31 +- .../hooks/queries/knowledge.test.ts | 22 + apps/tradinggoose/hooks/queries/knowledge.ts | 57 +- apps/tradinggoose/hooks/use-knowledge.ts | 14 +- apps/tradinggoose/hooks/use-mcp-tools.ts | 17 +- .../use-workflow-editor-actions.test.tsx | 55 + .../workflow/use-workflow-editor-actions.ts | 16 +- apps/tradinggoose/i18n.json | 19 + apps/tradinggoose/i18n.lock | 11204 ++++++++ apps/tradinggoose/i18n/block-editor.test.ts | 579 + apps/tradinggoose/i18n/block-editor.ts | 240 + .../i18n/client-messages.test.tsx | 95 + apps/tradinggoose/i18n/client-messages.ts | 73 + apps/tradinggoose/i18n/formatters.test.ts | 27 + apps/tradinggoose/i18n/formatters.ts | 62 + apps/tradinggoose/i18n/message-types.ts | 17 + apps/tradinggoose/i18n/messages/en.json | 21124 ++++++++++++++++ apps/tradinggoose/i18n/messages/es.json | 21124 ++++++++++++++++ apps/tradinggoose/i18n/messages/zh-CN.json | 21124 ++++++++++++++++ apps/tradinggoose/i18n/navigation.ts | 4 + apps/tradinggoose/i18n/public-copy.test.ts | 521 + apps/tradinggoose/i18n/public-copy.ts | 116 + apps/tradinggoose/i18n/request.ts | 13 + apps/tradinggoose/i18n/routing.ts | 16 + apps/tradinggoose/i18n/template.ts | 6 + apps/tradinggoose/i18n/utils.test.ts | 111 + apps/tradinggoose/i18n/utils.ts | 157 + apps/tradinggoose/i18n/widgets-extra-copy.ts | 2370 ++ .../i18n/workflow-inspector-core.ts | 928 + apps/tradinggoose/i18n/workflow-inspector.ts | 10 + .../i18n/workspace-widget-hooks.ts | 84 + apps/tradinggoose/lib/auth.ts | 8 +- .../lib/auth/auth-error-copy.test.ts | 35 +- apps/tradinggoose/lib/auth/auth-error-copy.ts | 325 +- .../lib/auth/auth-error-handler.ts | 10 +- .../lib/discovery/link-headers.ts | 49 +- apps/tradinggoose/lib/registration/shared.ts | 27 +- .../lib/workflows/trigger-utils.ts | 14 +- apps/tradinggoose/next.config.ts | 5 +- apps/tradinggoose/package.json | 4 + apps/tradinggoose/proxy.test.ts | 104 + apps/tradinggoose/proxy.ts | 186 +- apps/tradinggoose/stores/mcp-servers/store.ts | 25 +- apps/tradinggoose/stores/mcp-servers/types.ts | 10 +- apps/tradinggoose/tools/params.ts | 10 + apps/tradinggoose/triggers/index.ts | 76 +- apps/tradinggoose/vitest.config.mts | 2 +- apps/tradinggoose/vitest.setup.ts | 57 + .../hooks/use-workflow-widget-state.ts | 13 +- apps/tradinggoose/widgets/registry.test.ts | 8 +- apps/tradinggoose/widgets/registry.tsx | 42 +- .../components/custom-tool-list-item.tsx | 35 +- .../skill/components/skill-list-item.tsx | 23 +- .../components/custom-tool-dropdown.tsx | 49 +- .../widgets/components/mcp-dropdown.tsx | 105 +- .../components/pair-color-dropdown.tsx | 20 +- .../components/pine-indicator-dropdown.tsx | 67 +- .../widgets/components/skill-dropdown.tsx | 46 +- .../widget-header-refresh-button.tsx | 10 +- .../widgets/components/widget-selector.tsx | 9 +- .../widgets/components/workflow-dropdown.tsx | 151 +- .../data_chart/components/chart-body.tsx | 40 +- .../data_chart/components/chart-controls.tsx | 23 +- .../components/chart-copy-render.test.tsx | 96 + .../data_chart/components/chart-legend.tsx | 12 +- .../data_chart/components/draw-control.tsx | 16 +- .../components/draw-tool-icon-registry.ts | 31 - .../components/draw-tools-sidebar.tsx | 39 +- .../widgets/data_chart/components/footer.tsx | 103 +- .../components/indicator-control.tsx | 49 +- .../components/indicator-settings-modal.tsx | 33 +- .../data_chart/components/listing-overlay.tsx | 7 +- .../data_chart/components/pane-control.tsx | 10 +- .../components/provider-controls.tsx | 3 + .../widgets/widgets/data_chart/copy.test.ts | 46 + .../widgets/widgets/data_chart/copy.ts | 134 + .../data_chart/hooks/use-chart-data-loader.ts | 12 +- .../data_chart/hooks/use-chart-styles.ts | 11 +- .../data_chart/hooks/use-indicator-legend.ts | 18 +- .../data_chart/hooks/use-indicator-sync.ts | 13 +- .../widgets/widgets/data_chart/options.ts | 13 +- .../widgets/widgets/data_chart/series-data.ts | 19 +- .../widgets/data_chart/utils/chart-styles.ts | 10 +- .../widgets/data_chart/utils/series-loader.ts | 9 +- .../editor_custom_tool/custom-tool-editor.tsx | 80 +- .../widgets/editor_custom_tool/index.tsx | 37 +- .../components/indicator-editor-header.tsx | 22 +- .../editor-indicator-body.tsx | 16 +- .../widgets/editor_mcp/editor-mcp-body.tsx | 124 +- .../widgets/widgets/editor_mcp/index.tsx | 29 +- .../components/skill-editor-header.tsx | 18 +- .../editor_skill/editor-skill-body.tsx | 16 +- .../widgets/editor_skill/skill-editor.tsx | 29 +- .../api-key-selector/api-key-selector.tsx | 99 +- .../components/chat-deploy/chat-deploy.tsx | 67 +- .../chat-deploy/components/auth-selector.tsx | 66 +- .../components/identifier-input.tsx | 7 +- .../hooks/use-identifier-validation.ts | 17 +- .../components/deploy-form/deploy-form.tsx | 18 +- .../components/api-endpoint/api-endpoint.tsx | 4 +- .../components/api-key/api-key.tsx | 12 +- .../deploy-status/deploy-status.tsx | 6 +- .../example-command/example-command.tsx | 4 +- .../deployment-info/deployment-info.tsx | 5 +- .../components/deploy-modal/deploy-modal.tsx | 231 +- .../components/deployed-workflow-card.tsx | 10 +- .../components/deployed-workflow-modal.tsx | 23 +- .../deployment-controls.tsx | 24 +- .../export-controls/export-controls.tsx | 12 +- .../template-modal/template-modal.tsx | 302 +- .../connection-status/connection-status.tsx | 10 +- .../webhook-settings/webhook-settings.tsx | 293 +- .../components/control-bar/control-bar.tsx | 38 +- .../components/error/index.tsx | 28 +- .../floating-controls/floating-controls.tsx | 10 +- .../components/subflows/config.ts | 98 +- .../components/subflows/subflow-node.tsx | 12 +- .../toolbar/toolbar-block/toolbar-block.tsx | 11 +- .../toolbar-loop-block/toolbar-loop-block.tsx | 19 +- .../toolbar-parallel-block.tsx | 19 +- .../components/trigger-list/trigger-list.tsx | 57 +- .../components/trigger-warning-dialog.tsx | 9 +- .../wand-prompt-bar/wand-prompt-bar.tsx | 9 +- .../components/action-bar/action-bar.tsx | 60 +- .../channel-selector-input.tsx | 6 +- .../components/slack-channel-selector.tsx | 63 +- .../components/sub-block/components/code.tsx | 122 +- .../sub-block/components/combobox.tsx | 11 +- .../sub-block/components/condition-input.tsx | 102 +- .../credential-selector.tsx | 40 +- .../document-selector/document-selector.tsx | 71 +- .../document-tag-entry/document-tag-entry.tsx | 26 +- .../sub-block/components/dropdown.tsx | 16 +- .../sub-block/components/eval-input.tsx | 27 +- .../components/confluence-file-selector.tsx | 79 +- .../components/google-calendar-selector.tsx | 81 +- .../components/google-drive-picker.tsx | 22 +- .../components/jira-issue-selector.tsx | 77 +- .../components/microsoft-file-selector.tsx | 84 +- .../components/teams-message-selector.tsx | 120 +- .../components/wealthbox-file-selector.tsx | 36 +- .../file-selector/file-selector-input.tsx | 39 +- .../sub-block/components/file-upload.tsx | 60 +- .../components/folder-selector-input.tsx | 6 +- .../folder-selector/folder-selector.tsx | 52 +- .../components/grouped-checkbox-list.tsx | 36 +- .../components/input-format/input-format.tsx | 6 +- .../input-mapping/input-mapping.tsx | 12 +- .../knowledge-base-selector.tsx | 46 +- .../knowledge-tag-filters.tsx | 31 +- .../sub-block/components/long-input.tsx | 22 +- .../mcp-dynamic-args/mcp-dynamic-args.tsx | 35 +- .../mcp-server-modal/mcp-server-selector.tsx | 24 +- .../mcp-server-modal/mcp-tool-selector.tsx | 38 +- .../components/order-id-selector/dropdown.tsx | 10 +- .../components/order-id-selector/fetchers.ts | 4 +- .../order-id-selector/order-id-selector.tsx | 8 +- .../order-id-selector/order-row.test.tsx | 69 + .../order-id-selector/order-row.tsx | 86 +- .../components/jira-project-selector.tsx | 68 +- .../components/linear-project-selector.tsx | 60 +- .../components/linear-team-selector.tsx | 56 +- .../project-selector-input.tsx | 16 +- .../components/schedule/schedule-config.tsx | 54 +- .../sub-block/components/short-input.tsx | 8 +- .../components/skill-input/skill-input.tsx | 21 +- .../components/code-editor/code-editor.tsx | 30 +- .../components/tool-credential-selector.tsx | 58 +- .../components/tool-input/tool-input.tsx | 146 +- .../components/trigger-save/trigger-save.tsx | 47 +- .../variables-input/variables-input.tsx | 20 +- .../components/sub-block/sub-block.tsx | 14 +- .../sub-block/trigger-editing-layout.test.ts | 142 +- .../sub-block/trigger-editing-layout.ts | 24 +- .../workflow-block/workflow-block.tsx | 48 +- .../workflow-controlbar/controlbar.tsx | 8 +- .../panel/node-editor-panel.test.tsx | 224 + .../panel/node-editor-panel.tsx | 36 +- .../preview/preview-node.test.tsx | 42 + .../workflow-editor/preview/preview-node.tsx | 19 +- .../preview/preview-subflow.tsx | 8 +- .../preview/read-only-node-editor-panel.tsx | 92 +- .../workflow-editor/workflow-canvas.tsx | 45 +- .../sub-block-summary-rows.tsx | 105 +- .../workflow-toolbar/workflow-toolbar.tsx | 157 +- .../widgets/widgets/editor_workflow/copy.ts | 150 + .../widgets/editor_workflow/index.test.tsx | 96 + .../widgets/widgets/editor_workflow/index.tsx | 10 +- .../widgets/widgets/empty/index.tsx | 77 +- .../widgets/heatmap/components/body.tsx | 41 +- .../widgets/heatmap/components/header.tsx | 6 +- .../widgets/list_custom_tool/index.tsx | 27 +- .../components/indicator-create-menu.tsx | 14 +- .../components/indicator-list-item.tsx | 24 +- .../indicator-list/indicator-list.tsx | 10 +- .../widgets/widgets/list_indicator/index.tsx | 27 +- .../widgets/widgets/list_mcp/index.tsx | 73 +- .../components/skill-create-menu.tsx | 14 +- .../components/skill-list/skill-list.tsx | 13 +- .../widgets/widgets/list_skill/index.tsx | 29 +- .../widgets/widgets/list_workflow/index.tsx | 13 +- .../portfolio_snapshot/components/body.tsx | 171 +- .../portfolio_snapshot/components/header.tsx | 11 +- .../widgets/quick_order/components/body.tsx | 149 +- .../widgets/quick_order/components/header.tsx | 13 +- .../watchlist/components/watchlist-body.tsx | 10 +- .../components/watchlist-header-controls.tsx | 45 +- .../watchlist-list-actions-button.tsx | 28 +- .../components/watchlist-list-selector.tsx | 50 +- .../watchlist/components/watchlist-table.tsx | 106 +- .../chat-file-upload.test.tsx | 70 + .../chat-file-upload/chat-file-upload.tsx | 28 +- .../workflow_chat/components/chat/chat.tsx | 51 +- .../output-select/output-select.tsx | 36 +- .../widgets/widgets/workflow_chat/copy.ts | 7 + .../widgets/widgets/workflow_chat/index.tsx | 18 +- .../components/console/console.tsx | 4 +- .../terminal/components/filter-popover.tsx | 12 +- .../components/output-panel/output-panel.tsx | 36 +- .../terminal/components/status-display.tsx | 6 +- .../components/terminal/terminal.tsx | 4 +- .../widgets/widgets/workflow_console/copy.ts | 7 + .../widgets/workflow_console/index.tsx | 85 +- .../components/variables/variables.tsx | 12 +- .../widgets/workflow_variables/copy.ts | 7 + .../widgets/workflow_variables/index.test.tsx | 100 + .../widgets/workflow_variables/index.tsx | 13 +- bun.lock | 83 +- package.json | 7 +- 441 files changed, 94463 insertions(+), 8265 deletions(-) create mode 100644 apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx create mode 100644 apps/tradinggoose/app/(landing)/components/legal-markdown.tsx create mode 100644 apps/tradinggoose/app/(landing)/components/nav/locale-switcher.ts rename apps/tradinggoose/app/{ => (landing)}/page.tsx (88%) create mode 100644 apps/tradinggoose/app/admin/constants.ts create mode 100644 apps/tradinggoose/app/admin/copy.ts create mode 100644 apps/tradinggoose/app/admin/errors.ts create mode 100644 apps/tradinggoose/app/chat/copy.ts create mode 100644 apps/tradinggoose/app/chat/errors.ts create mode 100644 apps/tradinggoose/app/chat/layout.tsx create mode 100644 apps/tradinggoose/app/intl-provider.tsx create mode 100644 apps/tradinggoose/app/invite/layout.tsx create mode 100644 apps/tradinggoose/app/monaco-editor/esm/[...assetPath]/route.test.ts rename apps/tradinggoose/app/monaco-editor/esm/{vs => }/[...assetPath]/route.ts (81%) create mode 100644 apps/tradinggoose/app/not-found-content.tsx create mode 100644 apps/tradinggoose/app/unsubscribe/layout.tsx create mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/monitor/copy.ts create mode 100644 apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx create mode 100644 apps/tradinggoose/global-navbar/components/user-menu.test.tsx create mode 100644 apps/tradinggoose/hooks/queries/knowledge.test.ts create mode 100644 apps/tradinggoose/i18n.json create mode 100644 apps/tradinggoose/i18n.lock create mode 100644 apps/tradinggoose/i18n/block-editor.test.ts create mode 100644 apps/tradinggoose/i18n/block-editor.ts create mode 100644 apps/tradinggoose/i18n/client-messages.test.tsx create mode 100644 apps/tradinggoose/i18n/client-messages.ts create mode 100644 apps/tradinggoose/i18n/formatters.test.ts create mode 100644 apps/tradinggoose/i18n/formatters.ts create mode 100644 apps/tradinggoose/i18n/message-types.ts create mode 100644 apps/tradinggoose/i18n/messages/en.json create mode 100644 apps/tradinggoose/i18n/messages/es.json create mode 100644 apps/tradinggoose/i18n/messages/zh-CN.json create mode 100644 apps/tradinggoose/i18n/navigation.ts create mode 100644 apps/tradinggoose/i18n/public-copy.test.ts create mode 100644 apps/tradinggoose/i18n/public-copy.ts create mode 100644 apps/tradinggoose/i18n/request.ts create mode 100644 apps/tradinggoose/i18n/routing.ts create mode 100644 apps/tradinggoose/i18n/template.ts create mode 100644 apps/tradinggoose/i18n/utils.test.ts create mode 100644 apps/tradinggoose/i18n/utils.ts create mode 100644 apps/tradinggoose/i18n/widgets-extra-copy.ts create mode 100644 apps/tradinggoose/i18n/workflow-inspector-core.ts create mode 100644 apps/tradinggoose/i18n/workflow-inspector.ts create mode 100644 apps/tradinggoose/i18n/workspace-widget-hooks.ts create mode 100644 apps/tradinggoose/widgets/widgets/data_chart/components/chart-copy-render.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/data_chart/copy.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/data_chart/copy.ts create mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/order-row.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts create mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/index.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/workflow_chat/components/chat-file-upload/chat-file-upload.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/workflow_chat/copy.ts create mode 100644 apps/tradinggoose/widgets/widgets/workflow_console/copy.ts create mode 100644 apps/tradinggoose/widgets/widgets/workflow_variables/copy.ts create mode 100644 apps/tradinggoose/widgets/widgets/workflow_variables/index.test.tsx diff --git a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx index 3e2fa7cfa..7438457ea 100644 --- a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx +++ b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx @@ -1,11 +1,19 @@ +'use client' + +import { useLocale } from 'next-intl' import { inter } from '@/app/fonts/inter' +import { useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' export function AuthWaitlistNote() { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + return (
- Use the same waitlist-approved email for any sign-in method. + {copy.auth.note.waitlistApprovedEmail}
) } diff --git a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx index 602326f92..76665f0bc 100644 --- a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx +++ b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx @@ -2,9 +2,16 @@ import { type ReactNode, useEffect, useState } from 'react' import { GithubIcon, GoogleIcon } from '@/components/icons/icons' +import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { client } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console/logger' +import { useLocale } from 'next-intl' import { inter } from '@/app/fonts/inter' +import { useAppMessages } from '@/i18n/client-messages' +import { localizeHref, localizePathname, type LocaleCode } from '@/i18n/utils' + +const logger = createLogger('SocialLoginButtons') interface SocialLoginButtonsProps { githubAvailable: boolean @@ -17,40 +24,37 @@ interface SocialLoginButtonsProps { export function SocialLoginButtons({ githubAvailable, googleAvailable, - callbackURL = '/workspace', - isProduction, + callbackURL, + isProduction: _isProduction, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') const [mounted, setMounted] = useState(false) + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const socialCopy = copy.auth.social + const resolvedCallbackURL = callbackURL + ? localizeHref(locale, callbackURL) + : localizePathname(locale, '/workspace') - // Set mounted state to true on client-side useEffect(() => { setMounted(true) }, []) - // Only render on the client side to avoid hydration errors if (!mounted) return null async function signInWithGithub() { if (!githubAvailable) return setIsGithubLoading(true) + setErrorMessage('') try { - await client.signIn.social({ provider: 'github', callbackURL }) + await client.signIn.social({ provider: 'github', callbackURL: resolvedCallbackURL }) } catch (err: any) { - let errorMessage = 'Failed to sign in with GitHub' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'GitHub sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + logger.error('GitHub social sign-in failed', { error: err }) + setErrorMessage(copy.auth.error.default.description) } finally { setIsGithubLoading(false) } @@ -60,20 +64,12 @@ export function SocialLoginButtons({ if (!googleAvailable) return setIsGoogleLoading(true) + setErrorMessage('') try { - await client.signIn.social({ provider: 'google', callbackURL }) + await client.signIn.social({ provider: 'google', callbackURL: resolvedCallbackURL }) } catch (err: any) { - let errorMessage = 'Failed to sign in with Google' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Google sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + logger.error('Google social sign-in failed', { error: err }) + setErrorMessage(copy.auth.error.default.description) } finally { setIsGoogleLoading(false) } @@ -87,7 +83,7 @@ export function SocialLoginButtons({ onClick={signInWithGithub} > - {isGithubLoading ? 'Connecting...' : 'GitHub'} + {isGithubLoading ? socialCopy.connecting : socialCopy.github} ) @@ -99,7 +95,7 @@ export function SocialLoginButtons({ onClick={signInWithGoogle} > - {isGoogleLoading ? 'Connecting...' : 'Google'} + {isGoogleLoading ? socialCopy.connecting : socialCopy.google} ) @@ -113,6 +109,11 @@ export function SocialLoginButtons({
{googleAvailable && googleButton} {githubAvailable && githubButton} + {errorMessage ? ( + + {errorMessage} + + ) : null} {children}
) diff --git a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx index f4ad6e969..80f982390 100644 --- a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx +++ b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx @@ -1,9 +1,12 @@ 'use client' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { getEnv, isTruthy } from '@/lib/env' import { cn } from '@/lib/utils' +import { useAppMessages } from '@/i18n/client-messages' +import { localizeHref, normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' interface SSOLoginButtonProps { callbackURL?: string @@ -20,14 +23,21 @@ export function SSOLoginButton({ variant = 'outline', }: SSOLoginButtonProps) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const commonCopy = copy.auth.common if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { return null } + const resolvedCallbackURL = callbackURL ? normalizeCallbackUrl(callbackURL) : undefined + const handleSSOClick = () => { - const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}` - router.push(ssoUrl) + const ssoUrl = `/sso${ + resolvedCallbackURL ? `?callbackUrl=${encodeURIComponent(resolvedCallbackURL)}` : '' + }` + router.push(localizeHref(locale, ssoUrl)) } const primaryBtnClasses = @@ -42,7 +52,7 @@ export function SSOLoginButton({ variant={variant === 'outline' ? 'outline' : undefined} className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)} > - Sign in with SSO + {commonCopy.signInWithSso} ) } diff --git a/apps/tradinggoose/app/(auth)/error/page.tsx b/apps/tradinggoose/app/(auth)/error/page.tsx index 512a10ce4..816bf46d4 100644 --- a/apps/tradinggoose/app/(auth)/error/page.tsx +++ b/apps/tradinggoose/app/(auth)/error/page.tsx @@ -1,9 +1,12 @@ -import Link from 'next/link' +import { getLocale } from 'next-intl/server' import { Button } from '@/components/ui/button' import { getAuthErrorContent } from '@/lib/auth/auth-error-copy' import { getBrandConfig } from '@/lib/branding/branding' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { inter } from '@/app/fonts/inter' +import { Link } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' @@ -22,14 +25,17 @@ export default async function AuthErrorPage({ const resolvedSearchParams = (await searchParams) ?? {} const error = getSingleSearchParam(resolvedSearchParams.error) const errorDescription = getSingleSearchParam(resolvedSearchParams.error_description) - const { code, content } = getAuthErrorContent(error, errorDescription) + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) + const { code, content } = getAuthErrorContent(copy, error, errorDescription) const brand = getBrandConfig() const supportEmail = brand.supportEmail + const errorCopy = copy.auth.error return (
@@ -39,23 +45,23 @@ export default async function AuthErrorPage({

- Error code + {errorCopy.codeLabel}

- {error} + {code}
) : null}

- If this keeps happening, contact{' '} + {errorCopy.supportPrefix}{' '} - support + {errorCopy.supportLinkLabel} {' '} - and include the error code. + {errorCopy.supportSuffix}

diff --git a/apps/tradinggoose/app/(auth)/layout.tsx b/apps/tradinggoose/app/(auth)/layout.tsx index e92a7f060..76858b9ed 100644 --- a/apps/tradinggoose/app/(auth)/layout.tsx +++ b/apps/tradinggoose/app/(auth)/layout.tsx @@ -1,6 +1,11 @@ import type React from 'react' +import IntlProvider from '@/app/intl-provider' import AuthLayoutClient from './layout-client' export default function AuthLayout({ children }: { children: React.ReactNode }) { - return {children} + return ( + + {children} + + ) } diff --git a/apps/tradinggoose/app/(auth)/login/login-form.tsx b/apps/tradinggoose/app/(auth)/login/login-form.tsx index 372b54b49..7dff44b6e 100644 --- a/apps/tradinggoose/app/(auth)/login/login-form.tsx +++ b/apps/tradinggoose/app/(auth)/login/login-form.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' +import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, @@ -16,16 +16,16 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' import { handleAuthError } from '@/lib/auth/auth-error-handler' +import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { quickValidateEmail } from '@/lib/email/validation' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { - getAuthRegistrationHref, - getAuthRegistrationLabel, - type RegistrationMode, -} from '@/lib/registration/shared' +import { getAuthRegistrationHref, type RegistrationMode } from '@/lib/registration/shared' import { getBaseUrl } from '@/lib/urls/utils' import { cn } from '@/lib/utils' +import { Link, useRouter } from '@/i18n/navigation' +import { useAppMessages } from '@/i18n/client-messages' +import { localizeHref, localizePathname, normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' @@ -34,61 +34,48 @@ import { inter } from '@/app/fonts/inter' const logger = createLogger('LoginForm') -const validateEmailField = (emailValue: string): string[] => { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } - const validation = quickValidateEmail(emailValue.trim().toLowerCase()) - if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + if (!quickValidateEmail(emailValue.trim().toLowerCase()).isValid) { + errors.push(messages.invalid) } return errors } const PASSWORD_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Password is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Password cannot be empty.', - }, + required: { test: (value: string) => Boolean(value && typeof value === 'string') }, + notEmpty: { test: (value: string) => value.trim().length > 0 }, } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false +const validatePassword = ( + passwordValue: string, + messages: { + required: string + empty: string } -} - -const validatePassword = (passwordValue: string): string[] => { +): string[] => { const errors: string[] = [] if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.required.message) + errors.push(messages.required) return errors } if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.notEmpty.message) + errors.push(messages.empty) return errors } @@ -107,6 +94,12 @@ export default function LoginPage({ registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const loginCopy = copy.auth.login + const commonCopy = copy.auth.common + const authRegistrationLabel = copy.registration[registrationMode].auth + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) @@ -116,8 +109,9 @@ export default function LoginPage({ const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' - const [callbackUrl, setCallbackUrl] = useState('/workspace') + const [callbackUrl, setCallbackUrl] = useState(defaultCallbackPath) const [isInviteFlow, setIsInviteFlow] = useState(false) + const localizedCallbackUrl = localizeHref(locale, callbackUrl) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') @@ -135,8 +129,13 @@ export default function LoginPage({ if (searchParams) { const callback = searchParams.get('callbackUrl') if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) + const normalizedCallback = normalizeCallbackUrl( + callback, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedCallback) { + setCallbackUrl(normalizedCallback) } else { logger.warn('Invalid callback URL detected and blocked:', { url: callback }) } @@ -164,7 +163,10 @@ export default function LoginPage({ const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: loginCopy.validation.emailRequired, + invalid: loginCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) } @@ -173,7 +175,10 @@ export default function LoginPage({ const newPassword = e.target.value setPassword(newPassword) - const errors = validatePassword(newPassword) + const errors = validatePassword(newPassword, { + required: loginCopy.validation.passwordRequired, + empty: loginCopy.validation.passwordEmpty, + }) setPasswordErrors(errors) setShowValidationError(false) } @@ -186,11 +191,17 @@ export default function LoginPage({ const emailRaw = formData.get('email') as string const email = emailRaw.trim().toLowerCase() - const emailValidationErrors = validateEmailField(email) + const emailValidationErrors = validateEmailField(email, { + required: loginCopy.validation.emailRequired, + invalid: loginCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) - const passwordValidationErrors = validatePassword(password) + const passwordValidationErrors = validatePassword(password, { + required: loginCopy.validation.passwordRequired, + empty: loginCopy.validation.passwordEmpty, + }) setPasswordErrors(passwordValidationErrors) setShowValidationError(passwordValidationErrors.length > 0) @@ -200,76 +211,65 @@ export default function LoginPage({ } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' - const result = await client.signIn.email( { email, password, - callbackURL: safeCallbackUrl, + callbackURL: localizedCallbackUrl, }, { onError: (ctx) => { console.error('Login error:', ctx.error) const errorMessage: string[] = [] + const authErrorCode = + normalizeAuthErrorCode(ctx.error.code) ?? normalizeAuthErrorCode(ctx.error.message) const status = (ctx.error as any)?.status ?? (ctx.error as any)?.statusCode ?? (ctx.error as any)?.response?.status - const message = - (ctx.error as any)?.message ?? - (ctx.error as any)?.response?.statusText ?? - (ctx.error as any)?.response?.data?.error // If the backend rejected the request due to an invalid/expired auth state, hard reset auth. if (status === 401) { handleAuthError('login-unauthorized').catch(() => {}) - errorMessage.push('Your session expired. Please try signing in again.') + errorMessage.push(loginCopy.errors.sessionExpired) } - if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) { + if (authErrorCode === 'EMAIL_NOT_VERIFIED') { return } if ( - ctx.error.code?.includes('BAD_REQUEST') || - ctx.error.message?.includes('Email and password sign in is not enabled') - ) { - errorMessage.push('Email sign in is currently disabled.') - } else if ( - ctx.error.code?.includes('INVALID_CREDENTIALS') || - ctx.error.message?.includes('invalid password') + authErrorCode === 'EMAIL_AND_PASSWORD_SIGN_IN_IS_NOT_ENABLED' || + authErrorCode === 'BAD_REQUEST' ) { - errorMessage.push('Invalid email or password. Please try again.') + errorMessage.push(loginCopy.errors.emailSignInDisabled) } else if ( - ctx.error.code?.includes('USER_NOT_FOUND') || - ctx.error.message?.includes('not found') + authErrorCode === 'INVALID_CREDENTIALS' || + authErrorCode === 'INVALID_PASSWORD' ) { - errorMessage.push('No account found with this email. Please sign up first.') - } else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) { - errorMessage.push('Please enter both email and password.') - } else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) { - errorMessage.push('Email and password login is disabled.') - } else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) { - errorMessage.push('Failed to create session. Please try again later.') - } else if (ctx.error.code?.includes('too many attempts')) { - errorMessage.push( - 'Too many login attempts. Please try again later or reset your password.' - ) - } else if (ctx.error.code?.includes('account locked')) { - errorMessage.push( - 'Your account has been locked for security. Please reset your password.' - ) - } else if (ctx.error.code?.includes('network')) { - errorMessage.push('Network error. Please check your connection and try again.') - } else if (ctx.error.message?.includes('rate limit')) { - errorMessage.push('Too many requests. Please wait a moment before trying again.') - } else if (message) { - errorMessage.push(typeof message === 'string' ? message : 'Unable to sign in.') + errorMessage.push(loginCopy.errors.invalidCredentials) + } else if (authErrorCode === 'USER_NOT_FOUND') { + errorMessage.push(loginCopy.errors.noAccount) + } else if (authErrorCode === 'MISSING_CREDENTIALS') { + errorMessage.push(loginCopy.errors.missingCredentials) + } else if (authErrorCode === 'EMAIL_PASSWORD_DISABLED') { + errorMessage.push(loginCopy.errors.emailPasswordDisabled) + } else if (authErrorCode === 'FAILED_TO_CREATE_SESSION') { + errorMessage.push(loginCopy.errors.failedToCreateSession) + } else if (authErrorCode === 'TOO_MANY_ATTEMPTS') { + errorMessage.push(loginCopy.errors.tooManyAttempts) + } else if (authErrorCode === 'ACCOUNT_LOCKED') { + errorMessage.push(loginCopy.errors.accountLocked) + } else if (authErrorCode === 'NETWORK_ERROR') { + errorMessage.push(loginCopy.errors.network) + } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { + errorMessage.push(loginCopy.errors.rateLimit) + } else { + errorMessage.push(loginCopy.errors.unableToSignIn) } if (errorMessage.length === 0) { - errorMessage.push('Unable to sign in right now. Please try again.') + errorMessage.push(loginCopy.errors.unableToSignInNow) } setPasswordErrors(errorMessage) @@ -279,13 +279,7 @@ export default function LoginPage({ ) if (!result || result.error) { - const message = - result?.error?.message || - (result?.error as any)?.response?.statusText || - (result?.error as any)?.response?.data?.error || - 'Unable to sign in right now. Please try again.' - - setPasswordErrors([message]) + setPasswordErrors([loginCopy.errors.unableToSignInNow]) setShowValidationError(true) setIsLoading(false) return @@ -295,7 +289,7 @@ export default function LoginPage({ if (typeof window !== 'undefined') { sessionStorage.setItem('verificationEmail', email) } - router.push('/verify') + router.push(localizeHref(locale, '/verify')) return } @@ -309,7 +303,7 @@ export default function LoginPage({ if (!forgotPasswordEmail) { setResetStatus({ type: 'error', - message: 'Please enter your email address', + message: loginCopy.resetDialog.emailRequired, }) return } @@ -318,7 +312,7 @@ export default function LoginPage({ if (!emailValidation.isValid) { setResetStatus({ type: 'error', - message: 'Please enter a valid email address', + message: loginCopy.resetDialog.emailInvalid, }) return } @@ -334,34 +328,17 @@ export default function LoginPage({ }, body: JSON.stringify({ email: forgotPasswordEmail, - redirectTo: `${getBaseUrl()}/reset-password`, + redirectTo: `${getBaseUrl()}${localizePathname(locale, '/reset-password')}`, }), }) if (!response.ok) { - const errorData = await response.json() - let errorMessage = errorData.message || 'Failed to request password reset' - - if ( - errorMessage.includes('Invalid body parameters') || - errorMessage.includes('invalid email') - ) { - errorMessage = 'Please enter a valid email address' - } else if (errorMessage.includes('Email is required')) { - errorMessage = 'Please enter your email address' - } else if ( - errorMessage.includes('user not found') || - errorMessage.includes('User not found') - ) { - errorMessage = 'No account found with this email address' - } - - throw new Error(errorMessage) + throw new Error(loginCopy.resetDialog.error) } setResetStatus({ type: 'success', - message: 'Password reset link sent to your email', + message: loginCopy.resetDialog.success, }) setTimeout(() => { @@ -372,7 +349,7 @@ export default function LoginPage({ logger.error('Error requesting password reset:', { error }) setResetStatus({ type: 'error', - message: error instanceof Error ? error.message : 'Failed to request password reset', + message: loginCopy.resetDialog.error, }) } finally { setIsSubmittingReset(false) @@ -385,13 +362,17 @@ export default function LoginPage({ const showDivider = showBottomSection const showWaitlistNote = registrationMode === 'waitlist' && !isInviteFlow const registrationHref = isInviteFlow - ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` + ? `/signup?invite_flow=true&callbackUrl=${encodeURIComponent(callbackUrl)}` : getAuthRegistrationHref(registrationMode) - const registrationLabel = isInviteFlow ? 'Sign up' : getAuthRegistrationLabel(registrationMode) + const registrationLabel = isInviteFlow ? commonCopy.signUp : authRegistrationLabel return ( <> - + {showWaitlistNote ? : null} @@ -399,13 +380,13 @@ export default function LoginPage({
- +
- +
@@ -448,7 +429,7 @@ export default function LoginPage({ autoCapitalize='none' autoComplete='current-password' autoCorrect='off' - placeholder='Enter your password' + placeholder={commonCopy.enterYourPassword} value={password} onChange={handlePasswordChange} className={cn( @@ -462,7 +443,7 @@ export default function LoginPage({ type='button' onClick={() => setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? commonCopy.hidePassword : commonCopy.showPassword} > {showPassword ? : } @@ -478,7 +459,7 @@ export default function LoginPage({
@@ -490,7 +471,7 @@ export default function LoginPage({
- Or continue with + {loginCopy.divider}
@@ -511,7 +492,7 @@ export default function LoginPage({ {registrationHref && registrationLabel && (
- Don't have an account? + {commonCopy.dontHaveAccount} - By signing in, you agree to our{' '} + {commonCopy.termsLeadSigningIn}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
@@ -548,23 +529,22 @@ export default function LoginPage({ - Reset Password + {loginCopy.resetDialog.title} - Enter your email address and we'll send you a link to reset your password if your - account exists. + {loginCopy.resetDialog.description}
- +
setForgotPasswordEmail(e.target.value)} - placeholder='Enter your email' + placeholder={loginCopy.resetDialog.emailPlaceholder} required type='email' className={cn( @@ -590,7 +570,7 @@ export default function LoginPage({ className={primaryButtonClasses} disabled={isSubmittingReset} > - {isSubmittingReset ? 'Sending...' : 'Send Reset Link'} + {isSubmittingReset ? loginCopy.resetDialog.submitting : loginCopy.resetDialog.submit}
diff --git a/apps/tradinggoose/app/(auth)/reset-password/page.tsx b/apps/tradinggoose/app/(auth)/reset-password/page.tsx index 064891754..a42f728a0 100644 --- a/apps/tradinggoose/app/(auth)/reset-password/page.tsx +++ b/apps/tradinggoose/app/(auth)/reset-password/page.tsx @@ -1,17 +1,23 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' +import { useSearchParams } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' +import { Link, useRouter } from '@/i18n/navigation' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form' +import { useAppMessages } from '@/i18n/client-messages' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { inter } from '@/app/fonts/inter' const logger = createLogger('ResetPasswordPage') function ResetPasswordContent() { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const resetCopy = copy.auth.resetPassword const searchParams = useSearchParams() const token = searchParams.get('token') @@ -28,10 +34,10 @@ function ResetPasswordContent() { if (!token) { setStatusMessage({ type: 'error', - text: 'Invalid or missing reset token. Please request a new password reset link.', + text: resetCopy.invalidToken, }) } - }, [token]) + }, [resetCopy.invalidToken, token]) const handleResetPassword = async (password: string) => { try { @@ -51,22 +57,22 @@ function ResetPasswordContent() { if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.message || 'Failed to reset password') + throw new Error(errorData.message || resetCopy.failure) } setStatusMessage({ type: 'success', - text: 'Password reset successful! Redirecting to login...', + text: resetCopy.success, }) setTimeout(() => { - router.push('/login?resetSuccess=true') + router.push(localizeHref(locale, '/login?resetSuccess=true')) }, 1500) } catch (error) { logger.error('Error resetting password:', { error }) setStatusMessage({ type: 'error', - text: error instanceof Error ? error.message : 'Failed to reset password', + text: error instanceof Error ? error.message : resetCopy.failure, }) } finally { setIsSubmitting(false) @@ -76,9 +82,9 @@ function ResetPasswordContent() { return ( <>
@@ -96,18 +102,23 @@ function ResetPasswordContent() { href='/login' className='font-medium text-primary underline-offset-4 transition hover:text-primary-hover hover:underline' > - Back to login + {resetCopy.backToLogin}
) } +function ResetPasswordLoadingFallback() { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + + return
{copy.auth.common.loading}
+} + export default function ResetPasswordPage() { return ( - Loading...
} - > + }> ) diff --git a/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx b/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx index c5cd74e71..01018d003 100644 --- a/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx @@ -1,11 +1,14 @@ 'use client' +import { useLocale } from 'next-intl' import { useState } from 'react' import { Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' +import { useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' import { inter } from '@/app/fonts/inter' const primaryButtonClasses = @@ -30,6 +33,9 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { + const locale = useLocale() as LocaleCode + const copy = useAppMessages().auth.resetPassword + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() onSubmit(email) @@ -40,24 +46,21 @@ export function RequestResetForm({
- +
onEmailChange(e.target.value)} - placeholder='Enter your email' + placeholder={copy.request.emailPlaceholder} type='email' disabled={isSubmitting} required className='rounded-md shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100' /> -

- We'll send a password reset link to this email address. -

+

{copy.request.helperText}

- {/* Status message display */} {statusType && statusMessage && (
) @@ -91,6 +94,8 @@ export function SetNewPasswordForm({ statusMessage, className, }: SetNewPasswordFormProps) { + const locale = useLocale() as LocaleCode + const copy = useAppMessages().auth.resetPassword const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [validationMessage, setValidationMessage] = useState('') @@ -101,12 +106,12 @@ export function SetNewPasswordForm({ e.preventDefault() if (password.length < 8) { - setValidationMessage('Password must be at least 8 characters long') + setValidationMessage(copy.setNew.validation.passwordTooShort) return } if (password !== confirmPassword) { - setValidationMessage('Passwords do not match') + setValidationMessage(copy.setNew.validation.passwordMismatch) return } @@ -119,7 +124,7 @@ export function SetNewPasswordForm({
- +
setPassword(e.target.value)} required - placeholder='Enter new password' + placeholder={copy.setNew.passwordPlaceholder} className={cn( 'rounded-md pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', validationMessage && @@ -143,7 +148,7 @@ export function SetNewPasswordForm({ type='button' onClick={() => setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? copy.setNew.hidePassword : copy.setNew.showPassword} > {showPassword ? : } @@ -151,7 +156,7 @@ export function SetNewPasswordForm({
- +
setConfirmPassword(e.target.value)} required - placeholder='Confirm new password' + placeholder={copy.setNew.confirmPasswordPlaceholder} className={cn( 'rounded-md pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', validationMessage && @@ -175,7 +180,7 @@ export function SetNewPasswordForm({ type='button' onClick={() => setShowConfirmPassword(!showConfirmPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showConfirmPassword ? 'Hide password' : 'Show password'} + aria-label={showConfirmPassword ? copy.setNew.hidePassword : copy.setNew.showPassword} > {showConfirmPassword ? : } @@ -201,7 +206,7 @@ export function SetNewPasswordForm({
) diff --git a/apps/tradinggoose/app/(auth)/signup/page.tsx b/apps/tradinggoose/app/(auth)/signup/page.tsx index 49f11b578..6e11f3db2 100644 --- a/apps/tradinggoose/app/(auth)/signup/page.tsx +++ b/apps/tradinggoose/app/(auth)/signup/page.tsx @@ -1,10 +1,12 @@ -import Link from 'next/link' +import { getLocale } from 'next-intl/server' +import { Link } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' import { Button } from '@/components/ui/button' import { getRegistrationModeForRender } from '@/lib/registration/service' -import { REGISTRATION_DISABLED_MESSAGE } from '@/lib/registration/shared' export const dynamic = 'force-dynamic' @@ -13,10 +15,14 @@ export default async function SignupPage({ }: { searchParams?: Promise<{ invite_flow?: string }> }) { - const [{ githubAvailable, googleAvailable, isProduction }, registrationMode] = await Promise.all([ - getOAuthProviderStatus(), - getRegistrationModeForRender(), + const [providers, locale] = await Promise.all([ + Promise.all([getOAuthProviderStatus(), getRegistrationModeForRender()]), + getLocale(), ]) + const [{ githubAvailable, googleAvailable, isProduction }, registrationMode] = providers + const copy = getPublicCopy(locale as LocaleCode) + const commonCopy = copy.auth.common + const disabledCopy = copy.auth.disabled const resolvedSearchParams = (await searchParams) ?? {} const isInviteFlow = resolvedSearchParams.invite_flow === 'true' @@ -24,16 +30,16 @@ export default async function SignupPage({ return (
diff --git a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx index fd3b86f51..d25614550 100644 --- a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx +++ b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx @@ -2,8 +2,8 @@ import { Suspense, useEffect, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' +import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -12,11 +12,20 @@ import { quickValidateEmail } from '@/lib/email/validation' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { - REGISTRATION_DISABLED_MESSAGE, - REGISTRATION_WAITLIST_MESSAGE, - type RegistrationMode, -} from '@/lib/registration/shared' + isRegistrationDisabledReason, + isRegistrationWaitlistReason, + normalizeAuthErrorCode, +} from '@/lib/auth/auth-error-copy' +import { type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' +import { Link, useRouter } from '@/i18n/navigation' +import { useAppMessages } from '@/i18n/client-messages' +import { + localizeHref, + normalizeCallbackUrl, + stripLocaleFromPathname, + type LocaleCode, +} from '@/i18n/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' @@ -25,53 +34,45 @@ import { inter } from '@/app/fonts/inter' const logger = createLogger('SignupForm') +function SignupFormLoadingFallback() { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + + return
{copy.auth.common.loading}
+} + const PASSWORD_VALIDATIONS = { - minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' }, - uppercase: { - regex: /(?=.*?[A-Z])/, - message: 'Password must include at least one uppercase letter.', - }, - lowercase: { - regex: /(?=.*?[a-z])/, - message: 'Password must include at least one lowercase letter.', - }, - number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' }, - special: { - regex: /(?=.*?[#?!@$%^&*-])/, - message: 'Password must include at least one special character.', - }, + minLength: /.{8,}/, + uppercase: /(?=.*?[A-Z])/, + lowercase: /(?=.*?[a-z])/, + number: /(?=.*?[0-9])/, + special: /(?=.*?[#?!@$%^&*-])/, } const NAME_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Name is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Name cannot be empty.', - }, - validCharacters: { - regex: /^[\p{L}\s\-']+$/u, - message: 'Name can only contain letters, spaces, hyphens, and apostrophes.', - }, - noConsecutiveSpaces: { - regex: /^(?!.*\s\s).*$/, - message: 'Name cannot contain consecutive spaces.', - }, + required: (value: string) => Boolean(value && typeof value === 'string'), + notEmpty: (value: string) => value.trim().length > 0, + validCharacters: /^[\p{L}\s\-']+$/u, + noConsecutiveSpaces: /^(?!.*\s\s).*$/, } -const validateEmailField = (emailValue: string): string[] => { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } const validation = quickValidateEmail(emailValue.trim().toLowerCase()) if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + errors.push(messages.invalid) } return errors @@ -89,6 +90,11 @@ function SignupFormContent({ registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const commonCopy = copy.auth.common + const signupCopy = copy.auth.signup + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) @@ -101,7 +107,7 @@ function SignupFormContent({ const [emailError, setEmailError] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [redirectUrl, setRedirectUrl] = useState('') + const [redirectUrl, setRedirectUrl] = useState(null) const [isInviteFlow, setIsInviteFlow] = useState(false) const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' @@ -117,12 +123,23 @@ function SignupFormContent({ setEmail(emailParam) } - const redirectParam = searchParams.get('redirect') + const redirectParam = searchParams.get('redirect') ?? searchParams.get('callbackUrl') if (redirectParam) { - setRedirectUrl(redirectParam) + const normalizedRedirectUrl = normalizeCallbackUrl( + redirectParam, + typeof window !== 'undefined' ? window.location.origin : undefined + ) - if (redirectParam.startsWith('/invite/')) { - setIsInviteFlow(true) + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + + const redirectPathname = new URL(normalizedRedirectUrl, 'http://tradinggoose.local') + .pathname + if (stripLocaleFromPathname(redirectPathname).pathname.startsWith('/invite/')) { + setIsInviteFlow(true) + } + } else { + logger.warn('Invalid signup redirect URL detected and blocked:', { url: redirectParam }) } } @@ -135,24 +152,24 @@ function SignupFormContent({ const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] - if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.minLength.message) + if (!PASSWORD_VALIDATIONS.minLength.test(passwordValue)) { + errors.push(signupCopy.validation.passwordMinLength) } - if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.uppercase.message) + if (!PASSWORD_VALIDATIONS.uppercase.test(passwordValue)) { + errors.push(signupCopy.validation.passwordUppercase) } - if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.lowercase.message) + if (!PASSWORD_VALIDATIONS.lowercase.test(passwordValue)) { + errors.push(signupCopy.validation.passwordLowercase) } - if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.number.message) + if (!PASSWORD_VALIDATIONS.number.test(passwordValue)) { + errors.push(signupCopy.validation.passwordNumber) } - if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.special.message) + if (!PASSWORD_VALIDATIONS.special.test(passwordValue)) { + errors.push(signupCopy.validation.passwordSpecial) } return errors @@ -161,22 +178,22 @@ function SignupFormContent({ const validateName = (nameValue: string): string[] => { const errors: string[] = [] - if (!NAME_VALIDATIONS.required.test(nameValue)) { - errors.push(NAME_VALIDATIONS.required.message) + if (!NAME_VALIDATIONS.required(nameValue)) { + errors.push(signupCopy.validation.nameRequired) return errors } - if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) { - errors.push(NAME_VALIDATIONS.notEmpty.message) + if (!NAME_VALIDATIONS.notEmpty(nameValue)) { + errors.push(signupCopy.validation.nameEmpty) return errors } - if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) { - errors.push(NAME_VALIDATIONS.validCharacters.message) + if (!NAME_VALIDATIONS.validCharacters.test(nameValue.trim())) { + errors.push(signupCopy.validation.nameCharacters) } - if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) { - errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message) + if (!NAME_VALIDATIONS.noConsecutiveSpaces.test(nameValue)) { + errors.push(signupCopy.validation.nameSpaces) } return errors @@ -204,7 +221,10 @@ function SignupFormContent({ const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: signupCopy.validation.emailRequired, + invalid: signupCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) @@ -229,7 +249,10 @@ function SignupFormContent({ setNameErrors(nameValidationErrors) setShowNameValidationError(nameValidationErrors.length > 0) - const emailValidationErrors = validateEmailField(emailValue) + const emailValidationErrors = validateEmailField(emailValue, { + required: signupCopy.validation.emailRequired, + invalid: signupCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) @@ -261,7 +284,7 @@ function SignupFormContent({ } if (trimmedName.length > 100) { - setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.']) + setNameErrors([signupCopy.validation.nameTooLong]) setShowNameValidationError(true) setIsLoading(false) return @@ -278,44 +301,44 @@ function SignupFormContent({ { onError: (ctx) => { logger.error('Signup error:', ctx.error) - const errorMessage: string[] = ['Failed to create account'] + const errorMessage: string[] = [signupCopy.errors.failedToCreateAccount] + const authErrorCode = + normalizeAuthErrorCode(ctx.error.code) ?? normalizeAuthErrorCode(ctx.error.message) - if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) + if (authErrorCode === 'USER_ALREADY_EXISTS') { + errorMessage.push(signupCopy.errors.accountExists) setEmailError(errorMessage[errorMessage.length - 1]) } else if ( - ctx.error.code?.includes('BAD_REQUEST') || - ctx.error.message?.includes('Email and password sign up is not enabled') + authErrorCode === 'EMAIL_AND_PASSWORD_SIGN_UP_IS_NOT_ENABLED' || + authErrorCode === 'BAD_REQUEST' || + isRegistrationDisabledReason(ctx.error.message) || + isRegistrationWaitlistReason(ctx.error.message) ) { - if (ctx.error.message?.includes(REGISTRATION_DISABLED_MESSAGE)) { - errorMessage.push(REGISTRATION_DISABLED_MESSAGE) - } else if (ctx.error.message?.includes(REGISTRATION_WAITLIST_MESSAGE)) { - errorMessage.push( - 'This email is not approved for signup yet. Join the waitlist first.' - ) + if (isRegistrationDisabledReason(ctx.error.message)) { + errorMessage.push(signupCopy.errors.emailSignupDisabled) + } else if (isRegistrationWaitlistReason(ctx.error.message)) { + errorMessage.push(signupCopy.errors.waitlistRequired) } else { - errorMessage.push('Email signup is currently disabled.') + errorMessage.push(signupCopy.errors.signupNotEnabled) } setEmailError(errorMessage[errorMessage.length - 1]) - } else if (ctx.error.code?.includes('INVALID_EMAIL')) { - errorMessage.push('Please enter a valid email address.') + } else if (authErrorCode === 'INVALID_EMAIL') { + errorMessage.push(signupCopy.errors.invalidEmail) setEmailError(errorMessage[errorMessage.length - 1]) - } else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) { - errorMessage.push('Password must be at least 8 characters long.') + } else if (authErrorCode === 'PASSWORD_TOO_SHORT') { + errorMessage.push(signupCopy.errors.passwordTooShort) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) { - errorMessage.push('Password must be less than 128 characters long.') + } else if (authErrorCode === 'PASSWORD_TOO_LONG') { + errorMessage.push(signupCopy.errors.passwordTooLong) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('network')) { - errorMessage.push('Network error. Please check your connection and try again.') + } else if (authErrorCode === 'NETWORK_ERROR') { + errorMessage.push(signupCopy.errors.network) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('rate limit')) { - errorMessage.push('Too many requests. Please wait a moment before trying again.') + } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { + errorMessage.push(signupCopy.errors.rateLimit) setPasswordErrors(errorMessage) setShowValidationError(true) } else { @@ -355,7 +378,7 @@ function SignupFormContent({ logger.warn('Failed to send sign-in OTP after signup; user can press Resend', otpErr) } - router.push('/verify?fromSignup=true') + router.push(localizeHref(locale, '/verify?fromSignup=true')) } catch (error) { logger.error('Signup error:', error) setIsLoading(false) @@ -370,12 +393,12 @@ function SignupFormContent({ return ( <> @@ -385,16 +408,16 @@ function SignupFormContent({
- +
- +
- +
setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? commonCopy.hidePassword : commonCopy.showPassword} > {showPassword ? : } @@ -486,7 +509,7 @@ function SignupFormContent({
@@ -497,7 +520,7 @@ function SignupFormContent({
- Or continue with + {signupCopy.divider}
@@ -508,46 +531,50 @@ function SignupFormContent({ {ssoEnabled && ( - + )}
)}
- Already have an account? + {commonCopy.alreadyHaveAccount} - Sign in + {commonCopy.signIn}
- By creating an account, you agree to our{' '} + {commonCopy.termsLeadCreatingAccount}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
@@ -566,9 +593,7 @@ export default function SignupPage({ registrationMode: RegistrationMode }) { return ( - Loading...
} - > + }> { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } const validation = quickValidateEmail(emailValue.trim().toLowerCase()) if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + errors.push(messages.invalid) } return errors } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false - } -} - export default function SSOForm({ registrationMode }: { registrationMode: RegistrationMode }) { - const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const commonCopy = copy.auth.common + const ssoCopy = copy.auth.sso + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [email, setEmail] = useState('') @@ -64,47 +56,59 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist const [showEmailValidationError, setShowEmailValidationError] = useState(false) const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' - const [callbackUrl, setCallbackUrl] = useState('/workspace') + const [callbackUrl, setCallbackUrl] = useState(defaultCallbackPath) const registrationHref = getAuthRegistrationHref(registrationMode) - const registrationLabel = getAuthRegistrationLabel(registrationMode) + const registrationLabel = copy.registration[registrationMode].auth + const localizedCallbackUrl = localizeHref(locale, callbackUrl) + const callbackUrlParam = encodeURIComponent(callbackUrl) useEffect(() => { if (searchParams) { const callback = searchParams.get('callbackUrl') if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) + const normalizedCallback = normalizeCallbackUrl( + callback, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedCallback) { + setCallbackUrl(normalizedCallback) } else { logger.warn('Invalid callback URL detected and blocked:', { url: callback }) } } - // Pre-fill email if provided in URL (e.g., from deployed chat SSO) const emailParam = searchParams.get('email') if (emailParam) { setEmail(emailParam) } - // Check for SSO error from redirect const error = searchParams.get('error') if (error) { const errorMessages: Record = { - account_not_found: - 'No account found. Please contact your administrator to set up SSO access.', - sso_failed: 'SSO authentication failed. Please try again.', - invalid_provider: 'SSO provider not configured correctly.', + account_not_found: ssoCopy.errors.accountNotFound, + sso_failed: ssoCopy.errors.ssoFailed, + invalid_provider: ssoCopy.errors.providerNotConfigured, } - setEmailErrors([errorMessages[error] || 'SSO authentication failed. Please try again.']) + setEmailErrors([errorMessages[error] || ssoCopy.errors.ssoFailed]) setShowEmailValidationError(true) } } - }, [searchParams]) + }, [ + searchParams, + ssoCopy.errors.accountNotFound, + ssoCopy.errors.providerNotConfigured, + ssoCopy.errors.ssoFailed, + ]) const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: ssoCopy.validation.emailRequired, + invalid: ssoCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) } @@ -117,7 +121,10 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist const emailRaw = formData.get('email') as string const emailValue = emailRaw.trim().toLowerCase() - const emailValidationErrors = validateEmailField(emailValue) + const emailValidationErrors = validateEmailField(emailValue, { + required: ssoCopy.validation.emailRequired, + invalid: ssoCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) @@ -127,30 +134,29 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' - await client.signIn.sso({ email: emailValue, - callbackURL: safeCallbackUrl, - errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, + callbackURL: localizedCallbackUrl, + errorCallbackURL: `${localizePathname(locale, '/sso')}?error=sso_failed&callbackUrl=${callbackUrlParam}`, }) } catch (err) { logger.error('SSO sign-in failed', { error: err, email: emailValue }) + const authErrorCode = err instanceof Error ? normalizeAuthErrorCode(err.message) : null - let errorMessage = 'SSO sign-in failed. Please try again.' + let errorMessage = ssoCopy.errors.failed if (err instanceof Error) { - if (err.message.includes('NO_PROVIDER_FOUND')) { - errorMessage = 'SSO provider not found. Please check your configuration.' - } else if (err.message.includes('INVALID_EMAIL_DOMAIN')) { - errorMessage = 'Email domain not configured for SSO. Please contact your administrator.' - } else if (err.message.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message.includes('rate limit')) { - errorMessage = 'Too many requests. Please wait a moment before trying again.' - } else if (err.message.includes('SSO_DISABLED')) { - errorMessage = 'SSO authentication is disabled. Please use another sign-in method.' + if (authErrorCode === 'NO_PROVIDER_FOUND' || authErrorCode === 'INVALID_PROVIDER') { + errorMessage = ssoCopy.errors.providerNotConfigured + } else if (authErrorCode === 'INVALID_EMAIL_DOMAIN') { + errorMessage = ssoCopy.errors.invalidEmailDomain + } else if (authErrorCode === 'NETWORK_ERROR') { + errorMessage = ssoCopy.errors.network + } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { + errorMessage = ssoCopy.errors.rateLimit + } else if (authErrorCode === 'SSO_DISABLED') { + errorMessage = ssoCopy.errors.ssoDisabled } else { - errorMessage = err.message + errorMessage = ssoCopy.errors.failed } } @@ -163,9 +169,9 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist return ( <> {registrationMode === 'waitlist' ? : null} @@ -174,12 +180,12 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- +
@@ -214,29 +220,29 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- Or + + {ssoCopy.divider} +
- +
{registrationHref && registrationLabel && (
- Don't have an account? + {commonCopy.dontHaveAccount} {registrationLabel} @@ -247,23 +253,23 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- By signing in, you agree to our{' '} + {commonCopy.termsLeadSigningIn}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.ts index af88eb428..7616a444c 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.ts +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.ts @@ -1,16 +1,76 @@ 'use client' import { useEffect, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' +import { useSearchParams } from 'next/navigation' +import { useRouter } from '@/i18n/navigation' +import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { localizeHref, normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' +import type { PublicCopy } from '@/i18n/client-messages' const logger = createLogger('useVerification') +type VerifyCopy = PublicCopy['auth']['verify'] + +const VERIFICATION_ERROR_CODE_GROUPS = { + expired: new Set([ + 'TOKEN_EXPIRED', + 'EXPIRED_TOKEN', + 'VERIFICATION_CODE_EXPIRED', + 'EXPIRED_VERIFICATION_CODE', + 'OTP_EXPIRED', + 'CODE_EXPIRED', + ]), + invalid: new Set([ + 'INVALID_TOKEN', + 'INVALID_VERIFICATION_CODE', + 'INVALID_OTP', + 'OTP_INVALID', + 'INVALID_CODE', + ]), + attempts: new Set([ + 'TOO_MANY_ATTEMPTS', + 'TOO_MANY_FAILED_ATTEMPTS', + 'MAX_ATTEMPTS_EXCEEDED', + 'OTP_TOO_MANY_ATTEMPTS', + 'RATE_LIMIT', + ]), +} as const + +export function getVerificationErrorMessage(copy: VerifyCopy, error: unknown) { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : '' + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error && typeof error === 'object' && 'message' in error + ? String((error as { message?: unknown }).message ?? '') + : '' + + const normalizedErrorCode = normalizeAuthErrorCode(code) ?? normalizeAuthErrorCode(message) + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.expired.has(normalizedErrorCode)) { + return copy.errors.expired + } + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.invalid.has(normalizedErrorCode)) { + return copy.errors.invalid + } + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.attempts.has(normalizedErrorCode)) { + return copy.errors.attempts + } + + return copy.errors.generic +} interface UseVerificationParams { hasEmailService: boolean isProduction: boolean isEmailVerificationEnabled: boolean + copy: VerifyCopy } interface UseVerificationReturn { @@ -33,8 +93,10 @@ export function useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled, + copy, }: UseVerificationParams): UseVerificationReturn { const router = useRouter() + const locale = useLocale() as LocaleCode const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [otp, setOtp] = useState('') @@ -56,7 +118,16 @@ export function useVerification({ const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') if (storedRedirectUrl) { - setRedirectUrl(storedRedirectUrl) + const normalizedRedirectUrl = normalizeCallbackUrl( + storedRedirectUrl, + window.location.origin + ) + + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + } else { + logger.warn('Invalid stored verification redirect blocked', { url: storedRedirectUrl }) + } } const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') @@ -67,7 +138,16 @@ export function useVerification({ const redirectParam = searchParams.get('redirectAfter') if (redirectParam) { - setRedirectUrl(redirectParam) + const normalizedRedirectUrl = normalizeCallbackUrl( + redirectParam, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + } else { + logger.warn('Invalid verification redirect blocked', { url: redirectParam }) + } } const inviteFlowParam = searchParams.get('invite_flow') @@ -118,14 +198,14 @@ export function useVerification({ setTimeout(() => { if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl + window.location.href = localizeHref(locale, redirectUrl) } else { - window.location.href = '/workspace' + router.push(localizeHref(locale, '/workspace')) } }, 1000) } else { logger.info('Setting invalid OTP state - API error response') - const message = 'Invalid verification code. Please check and try again.' + const message = copy.errors.invalid setIsInvalidOtp(true) setErrorMessage(message) logger.info('Error state after API error:', { @@ -134,17 +214,8 @@ export function useVerification({ }) setOtp('') } - } catch (error: any) { - let message = 'Verification failed. Please check your code and try again.' - - if (error.message?.includes('expired')) { - message = 'The verification code has expired. Please request a new one.' - } else if (error.message?.includes('invalid')) { - logger.info('Setting invalid OTP state - caught error') - message = 'Invalid verification code. Please check and try again.' - } else if (error.message?.includes('attempts')) { - message = 'Too many failed attempts. Please request a new code.' - } + } catch (error: unknown) { + const message = getVerificationErrorMessage(copy, error) setIsInvalidOtp(true) setErrorMessage(message) @@ -171,9 +242,8 @@ export function useVerification({ email: normalizedEmail, type: 'sign-in', }) - .then(() => {}) .catch(() => { - setErrorMessage('Failed to resend verification code. Please try again later.') + setErrorMessage(copy.errors.resendFailed) }) .finally(() => { setIsLoading(false) @@ -211,16 +281,16 @@ export function useVerification({ } if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl + window.location.href = localizeHref(locale, redirectUrl) } else { - router.push('/workspace') + router.push(localizeHref(locale, '/workspace')) } } handleRedirect() } } - }, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl]) + }, [isEmailVerificationEnabled, locale, redirectUrl, router, isInviteFlow]) return { otp, diff --git a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx index 219c4d186..080a9df82 100644 --- a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx +++ b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx @@ -1,10 +1,13 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { cn } from '@/lib/utils' +import { useRouter } from '@/i18n/navigation' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { useVerification } from '@/app/(auth)/verify/use-verification' import { inter } from '@/app/fonts/inter' @@ -24,6 +27,10 @@ function VerificationForm({ isProduction: boolean isEmailVerificationEnabled: boolean }) { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const verifyCopy = copy.auth.verify + const commonCopy = copy.auth.common const { otp, email, @@ -35,7 +42,12 @@ function VerificationForm({ verifyCode, resendCode, handleOtpChange, - } = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled }) + } = useVerification({ + hasEmailService, + isProduction, + isEmailVerificationEnabled, + copy: verifyCopy, + }) const [countdown, setCountdown] = useState(0) const [isResendDisabled, setIsResendDisabled] = useState(false) @@ -64,18 +76,20 @@ function VerificationForm({ return ( <> @@ -83,8 +97,9 @@ function VerificationForm({

- Enter the 6-digit code to verify your account. - {hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''} + {hasEmailService + ? verifyCopy.instructionsWithService + : verifyCopy.instructionsWithoutService}

@@ -154,7 +169,6 @@ function VerificationForm({
- {/* Error message */} {errorMessage && (

{errorMessage}

@@ -167,24 +181,22 @@ function VerificationForm({ className={primaryButtonClasses} disabled={!isOtpComplete || isLoading} > - {isLoading ? 'Verifying...' : 'Verify Email'} + {isLoading ? verifyCopy.verifyingButton : verifyCopy.verifyButton} {hasEmailService && (

- Didn't receive a code?{' '} + {verifyCopy.resendPrompt}{' '} {countdown > 0 ? ( - - Resend in {countdown}s - + {formatTemplate(verifyCopy.resendIn, { countdown })} ) : ( )}

@@ -199,11 +211,11 @@ function VerificationForm({ sessionStorage.removeItem('inviteRedirectUrl') sessionStorage.removeItem('isInviteFlow') } - router.push('/signup') + router.push(localizeHref(locale, '/signup')) }} className='font-medium text-primary underline-offset-4 transition hover:text-primary-hover hover:underline' > - Back to signup + {commonCopy.backToSignup}
diff --git a/apps/tradinggoose/app/(auth)/waitlist/page.tsx b/apps/tradinggoose/app/(auth)/waitlist/page.tsx index b5bfa8131..07fb72886 100644 --- a/apps/tradinggoose/app/(auth)/waitlist/page.tsx +++ b/apps/tradinggoose/app/(auth)/waitlist/page.tsx @@ -1,34 +1,42 @@ -import Link from 'next/link' -import { redirect } from 'next/navigation' +import { getLocale } from 'next-intl/server' import { Button } from '@/components/ui/button' -import { getRegistrationModeForRender } from '@/lib/registration/service' -import { REGISTRATION_DISABLED_MESSAGE } from '@/lib/registration/shared' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' -import { WaitlistForm } from './waitlist-form' +import { WaitlistForm } from '@/app/(auth)/waitlist/waitlist-form' +import { getRegistrationModeForRender } from '@/lib/registration/service' +import { Link, redirect } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' export default async function WaitlistPage() { - const registrationMode = await getRegistrationModeForRender() + const [registrationMode, locale] = await Promise.all([ + getRegistrationModeForRender(), + getLocale(), + ]) + const copy = getPublicCopy(locale as LocaleCode) + const commonCopy = copy.auth.common + const waitlistCopy = copy.auth.waitlist + const disabledCopy = copy.auth.disabled if (registrationMode === 'open') { - redirect('/signup') + redirect({ href: '/signup', locale: locale as LocaleCode }) } if (registrationMode === 'disabled') { return (
@@ -38,9 +46,9 @@ export default async function WaitlistPage() { return (
diff --git a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx new file mode 100644 index 000000000..417ac4e58 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { WaitlistForm } from './waitlist-form' + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { + children?: React.ReactNode + href: string + }) => ( + + {children} + + ), +})) + +describe('WaitlistForm', () => { + let container: HTMLDivElement + let root: Root + const originalFetch = globalThis.fetch + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => { + root.unmount() + }) + } + container?.remove() + vi.restoreAllMocks() + globalThis.fetch = originalFetch + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('preserves the disabled registration error returned by the API', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ code: 'REGISTRATION_DISABLED' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render( + + + + ) + }) + + const input = container.querySelector('#waitlist-email') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected waitlist input to render') + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, 'user@example.com') + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const form = container.querySelector('form') + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected waitlist form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain(getPublicCopy('en').auth.disabled.description) + }) + + it('falls back to the generic rejected copy for non-specific failures', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ code: 'UNEXPECTED_FAILURE' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render( + + + + ) + }) + + const input = container.querySelector('#waitlist-email') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected waitlist input to render') + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, 'user@example.com') + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const form = container.querySelector('form') + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected waitlist form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain(getPublicCopy('en').auth.waitlist.rejected) + }) +}) diff --git a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx index b809a329a..e3b29f000 100644 --- a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx +++ b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx @@ -1,15 +1,22 @@ 'use client' import { useState } from 'react' -import Link from 'next/link' +import { useLocale } from 'next-intl' import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' +import { Link } from '@/i18n/navigation' +import { useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' import { inter } from '@/app/fonts/inter' type WaitlistResponseStatus = 'pending' | 'approved' | 'rejected' | 'signed_up' export function WaitlistForm() { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const commonCopy = copy.auth.common + const waitlistCopy = copy.auth.waitlist const [email, setEmail] = useState('') const [error, setError] = useState('') const [status, setStatus] = useState(null) @@ -17,14 +24,27 @@ export function WaitlistForm() { const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' + const validateEmailField = (emailValue: string): string => { + if (!emailValue || !emailValue.trim()) { + return waitlistCopy.validation.emailRequired + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + return waitlistCopy.validation.emailInvalid + } + + return '' + } + async function onSubmit(event: React.FormEvent) { event.preventDefault() setError('') const normalizedEmail = email.trim().toLowerCase() - const validation = quickValidateEmail(normalizedEmail) - if (!validation.isValid) { - setError(validation.reason || 'Please enter a valid email address.') + const validationMessage = validateEmailField(normalizedEmail) + if (validationMessage) { + setError(validationMessage) return } @@ -38,19 +58,21 @@ export function WaitlistForm() { }) const payload = (await response.json().catch(() => null)) as - | { status?: WaitlistResponseStatus; error?: string } + | { status?: WaitlistResponseStatus; error?: string; code?: string } | null if (!response.ok) { - throw new Error(payload?.error || 'Failed to join the waitlist') + if (payload?.code === 'REGISTRATION_DISABLED') { + throw new Error(copy.auth.disabled.description) + } + + throw new Error(waitlistCopy.rejected) } setStatus(payload?.status ?? 'pending') setEmail(normalizedEmail) } catch (submissionError) { - setError( - submissionError instanceof Error ? submissionError.message : 'Failed to join the waitlist' - ) + setError(submissionError instanceof Error ? submissionError.message : waitlistCopy.rejected) } finally { setIsSubmitting(false) } @@ -61,14 +83,14 @@ export function WaitlistForm() {
- + setEmail(event.target.value)} - placeholder='Enter your email' + placeholder={commonCopy.enterYourEmail} autoComplete='email' autoCapitalize='none' autoCorrect='off' @@ -78,14 +100,12 @@ export function WaitlistForm() { error && 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' )} /> -

- Use the email address you want reviewed for platform access. -

+

{waitlistCopy.helperText}

@@ -97,19 +117,19 @@ export function WaitlistForm() { {status === 'pending' ? ( - - You are on the waitlist. We will review your request and let you know when access is - available. - + {waitlistCopy.pending} ) : null} {status === 'approved' ? ( - Your email is approved. Continue to{' '} - - sign up + {waitlistCopy.approvedPrefix}{' '} + + {waitlistCopy.signUpLink} . @@ -119,9 +139,9 @@ export function WaitlistForm() { {status === 'signed_up' ? ( - This email already has access. Continue to{' '} + {waitlistCopy.signedUpPrefix}{' '} - login + {waitlistCopy.loginLink} . @@ -130,17 +150,17 @@ export function WaitlistForm() { {status === 'rejected' ? ( - This waitlist request is not approved for access. + {waitlistCopy.rejected} ) : null}
- Already have an account? + {commonCopy.alreadyHaveAccount} - Sign in + {commonCopy.signIn}
diff --git a/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx b/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx index f84164f23..9143369ad 100644 --- a/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx +++ b/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx @@ -2,11 +2,19 @@ import Image from 'next/image' import Link from 'next/link' import { notFound } from 'next/navigation' import { Metadata } from 'next' +import { getLocale } from 'next-intl/server' import { Clock } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import BlogLayout from '@/app/(landing)/components/blog-layout' +import { getPublicCopy } from '@/i18n/public-copy' +import { + buildLocalizedAlternates, + getOpenGraphLocale, + localizeSiteUrl, + type LocaleCode, +} from '@/i18n/utils' import { getPostBySlug } from '../lib/posts' import { formatBlogDate } from '../lib/heading-slugs' import BreadcrumbNav from '../components/breadcrumb-nav' @@ -28,6 +36,8 @@ function toPlainTitle(md: string): string { } export async function generateMetadata({ params }: PostPageProps): Promise { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) const { slug } = await params const post = await getPostBySlug(slug) if (!post) return {} @@ -38,16 +48,15 @@ export async function generateMetadata({ params }: PostPageProps): Promise ({ '@type': 'Person', @@ -91,9 +103,9 @@ export default async function PostPage({ params }: PostPageProps) { })), }), publisher: { '@id': 'https://tradinggoose.ai/#organization' }, - mainEntityOfPage: { '@type': 'WebPage', '@id': `https://tradinggoose.ai/blog/${slug}` }, + mainEntityOfPage: { '@type': 'WebPage', '@id': localizeSiteUrl(locale, postPath) }, ...(tags?.length && { keywords: tags.join(', '), articleSection: tags[0] }), - inLanguage: 'en-US', + inLanguage: locale, } return ( @@ -133,11 +145,13 @@ export default async function PostPage({ params }: PostPageProps) { )) : null} · - {date && } + {date && } ·
- {readingTime} min read + + {readingTime} {copy.blog.readTimeSuffix} +
diff --git a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx index b0b102bbc..feb9d66cd 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx @@ -1,11 +1,14 @@ 'use client' import { useEffect, useState } from 'react' +import { useLocale } from 'next-intl' import Link from 'next/link' import { OpenAIIcon, AnthropicIcon, GeminiIcon, xAIIcon as XAIIcon } from '@/components/icons/provider-icons' import { PerplexityIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' interface AiSummarizeProps { path: string @@ -14,6 +17,9 @@ interface AiSummarizeProps { export default function AiSummarize({ path, title }: AiSummarizeProps) { const [url, setUrl] = useState(path) + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const blogCopy = copy.blog useEffect(() => { setUrl(`${window.location.origin}${path}`) @@ -51,7 +57,7 @@ export default function AiSummarize({ path, title }: AiSummarizeProps) { return (
-

Summarize with AI

+

{blogCopy.summarizeTitle}

{platforms.map((platform) => ( @@ -62,7 +68,9 @@ export default function AiSummarize({ path, title }: AiSummarizeProps) { href={platform.href} target="_blank" rel="noopener noreferrer" - aria-label={`Summarize with ${platform.label}`} + aria-label={formatTemplate(blogCopy.summarizeWithPlatform, { + platform: platform.label, + })} > {platform.icon} diff --git a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx index 40c260590..929918a7a 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx @@ -1,7 +1,8 @@ 'use client' -import Link from 'next/link' import { Home } from 'lucide-react' +import { useLocale } from 'next-intl' +import { Link } from '@/i18n/navigation' import { Breadcrumb, BreadcrumbItem, @@ -10,26 +11,32 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb' +import { useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' interface BreadcrumbNavProps { pageTitle: string } export default function BreadcrumbNav({ pageTitle }: Readonly) { + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const blogCopy = copy.blog + return ( - Home + {blogCopy.home} - Blog + {blogCopy.breadcrumbBlog} diff --git a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx index 0c64e5179..96c82cb9e 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx @@ -1,10 +1,13 @@ 'use client' import { useState } from 'react' +import { useLocale } from 'next-intl' import { FileText, SearchIcon, SearchX } from 'lucide-react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' import PostCard from './post-card' import type { Post } from '../lib/types' @@ -14,6 +17,9 @@ interface FilteredPostProps { export default function FilteredPosts({ posts }: FilteredPostProps) { const [searchValue, setSearchValue] = useState('') + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const blogCopy = copy.blog if (posts.length === 0) { return ( @@ -22,8 +28,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { - No posts yet - Check back soon — new articles are on the way. + {blogCopy.emptyTitle} + {blogCopy.emptyDescription} ) @@ -40,8 +46,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { type="text" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} - placeholder="Search articles" - aria-label="Search articles" + placeholder={blogCopy.searchPlaceholder} + aria-label={blogCopy.searchPlaceholder} className="w-full pl-12" id="search" /> @@ -62,8 +68,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { - No posts matching “{searchValue}” - Try a different search term. + {formatTemplate(blogCopy.noMatches, { query: searchValue })} + {blogCopy.noMatchesDescription} )} diff --git a/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx b/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx index a92f2279d..e6b77b214 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx @@ -3,8 +3,8 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import Image from 'next/image' -import Link from 'next/link' import { CodeBlock } from '@/components/ui/code-block' +import { Link } from '@/i18n/navigation' import { flattenNodeText, textToSlug } from '../lib/heading-slugs' interface MarkdownContentProps { @@ -65,7 +65,8 @@ export default function MarkdownContent({ content }: MarkdownContentProps) { ), a: ({ href, children, ...props }) => { const isNonRoute = href ? /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) || href.startsWith('//') : false - if (isNonRoute) { + const isAnchor = href?.startsWith('#') ?? false + if (isNonRoute || isAnchor) { return ( {post.image && ( @@ -43,12 +50,14 @@ export default function PostCard({ post, index }: PostCardProps) { )}
- {formatBlogDate(post.date, 'short')} + {formatBlogDate(post.date, 'short', locale)}
- {post.readingTime} min read + + {post.readingTime} {blogCopy.readTimeSuffix} +
{post.tags && post.tags.length > 0 && ( @@ -61,7 +70,7 @@ export default function PostCard({ post, index }: PostCardProps) {
- View Article + {blogCopy.viewArticle} ) diff --git a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx index c881612d2..e75f3e150 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import { useLocale } from 'next-intl' import Link from 'next/link' import { Check, LinkIcon } from 'lucide-react' import { @@ -11,6 +12,8 @@ import { } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' interface SocialShareProps { path: string @@ -20,6 +23,9 @@ interface SocialShareProps { export default function SocialShare({ path, text }: SocialShareProps) { const [copied, setCopied] = useState(false) const [url, setUrl] = useState(path) + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const blogCopy = copy.blog useEffect(() => { setUrl(`${window.location.origin}${path}`) @@ -59,7 +65,7 @@ export default function SocialShare({ path, text }: SocialShareProps) { return (
-

Share This Article

+

{blogCopy.shareTitle}

{links.map((link) => ( @@ -70,7 +76,7 @@ export default function SocialShare({ path, text }: SocialShareProps) { href={link.href} target="_blank" rel="nofollow noopener noreferrer" - aria-label={`Share on ${link.label}`} + aria-label={formatTemplate(blogCopy.shareOn, { platform: link.label })} > {link.icon} @@ -81,7 +87,12 @@ export default function SocialShare({ path, text }: SocialShareProps) { ))} - - {copied ? 'Copied!' : 'Copy link'} + {copied ? blogCopy.copied : blogCopy.copyLink}
diff --git a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx index caa38b13a..428605676 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx @@ -1,7 +1,10 @@ 'use client' import { useEffect, useState } from 'react' +import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' +import { useAppMessages } from '@/i18n/client-messages' +import { type LocaleCode } from '@/i18n/utils' import type { TOC } from '../lib/types' interface TableOfContentsProps { @@ -36,6 +39,9 @@ function useActiveItem(itemIds: string[]) { export default function TableOfContents({ toc }: TableOfContentsProps) { const [mounted, setMounted] = useState(false) + const locale = useLocale() as LocaleCode + const copy = useAppMessages() + const blogCopy = copy.blog const itemIds = toc.map((item) => item.url) const activeHeading = useActiveItem(itemIds) @@ -49,7 +55,7 @@ export default function TableOfContents({ toc }: TableOfContentsProps) { return (
-

On This Page

+

{blogCopy.tableOfContents}

    {toc.map((item) => (
  • diff --git a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts index 74a19c76a..643b4750f 100644 --- a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts +++ b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts @@ -29,8 +29,12 @@ export function flattenNodeText(node: React.ReactNode): string { return '' } -export function formatBlogDate(dateStr: string, style: 'long' | 'short' = 'long'): string { - return new Date(dateStr).toLocaleDateString('en-US', { +export function formatBlogDate( + dateStr: string, + style: 'long' | 'short' = 'long', + locale: string +): string { + return new Date(dateStr).toLocaleDateString(locale, { month: style === 'long' ? 'long' : 'short', day: 'numeric', year: 'numeric', diff --git a/apps/tradinggoose/app/(landing)/blog/page.tsx b/apps/tradinggoose/app/(landing)/blog/page.tsx index 55e85783c..347de1600 100644 --- a/apps/tradinggoose/app/(landing)/blog/page.tsx +++ b/apps/tradinggoose/app/(landing)/blog/page.tsx @@ -1,25 +1,57 @@ -import { Metadata } from 'next' +import { type Metadata } from 'next' +import { getLocale } from 'next-intl/server' import BlogLayout from '@/app/(landing)/components/blog-layout' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { + buildLocalizedAlternates, + getOpenGraphLocale, + localizeSiteUrl, + type LocaleCode, +} from '@/i18n/utils' import { getAllPosts } from './lib/posts' import PageHeading from './components/page-heading' import FilteredPosts from './components/filtered-posts' -export const metadata: Metadata = { - title: 'Blog | TradingGoose', - description: 'Articles about trading automation, workflow design, and building smarter strategies.', - alternates: { - canonical: '/blog', - }, +export async function generateMetadata(): Promise { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) + + return { + title: copy.meta.blog.title, + description: copy.meta.blog.description, + alternates: buildLocalizedAlternates(locale, '/blog'), + openGraph: { + title: copy.meta.blog.title, + description: copy.meta.blog.description, + type: 'website', + url: localizeSiteUrl(locale, '/blog'), + locale: getOpenGraphLocale(locale), + }, + } } export default async function BlogPage() { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) const posts = await getAllPosts() return ( + - - - - - + + + {children} - - + + diff --git a/apps/tradinggoose/app/[locale]/unsubscribe/layout.tsx b/apps/tradinggoose/app/[locale]/unsubscribe/layout.tsx deleted file mode 100644 index 327de88a3..000000000 --- a/apps/tradinggoose/app/[locale]/unsubscribe/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ReactNode } from 'react' - -export default function UnsubscribeLayout({ children }: { children: ReactNode }) { - return children -} diff --git a/apps/tradinggoose/app/api/careers/submit/route.ts b/apps/tradinggoose/app/api/careers/submit/route.ts index 2f77bd2fd..c73aaa846 100644 --- a/apps/tradinggoose/app/api/careers/submit/route.ts +++ b/apps/tradinggoose/app/api/careers/submit/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderCareersConfirmationEmail } from '@/components/emails/render-email' import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email' -import { persistAnonymousEmailLocale } from '@/lib/email/locale' +import { normalizeEmailLocale } from '@/lib/email/locale' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' @@ -122,7 +122,7 @@ export async function POST(request: NextRequest) { }) ) - const locale = await persistAnonymousEmailLocale(validatedData.email, validatedData.locale) + const locale = normalizeEmailLocale(validatedData.locale) const confirmationEmailHtml = await renderCareersConfirmationEmail({ name: validatedData.name, position: validatedData.position, diff --git a/apps/tradinggoose/app/api/chat/[identifier]/otp/route.ts b/apps/tradinggoose/app/api/chat/[identifier]/otp/route.ts index f25291a08..6ccc76d2f 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/otp/route.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/otp/route.ts @@ -5,7 +5,7 @@ import type { NextRequest } from 'next/server' import { z } from 'zod' import { renderOTPEmail } from '@/components/emails/render-email' import { getEmailSubject } from '@/components/emails/render-email' -import { persistAnonymousEmailLocale } from '@/lib/email/locale' +import { normalizeEmailLocale } from '@/lib/email/locale' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' import { deleteCachedValue, getCachedValue, setCachedValue } from '@/lib/redis' @@ -132,7 +132,7 @@ export async function POST( const otp = generateOTP() await storeOTP(email, deployment.id, otp) - const locale = await persistAnonymousEmailLocale(email, requestLocale) + const locale = normalizeEmailLocale(requestLocale) const emailHtml = await renderOTPEmail( otp, diff --git a/apps/tradinggoose/app/intl-provider.tsx b/apps/tradinggoose/app/intl-provider.tsx deleted file mode 100644 index 93a336881..000000000 --- a/apps/tradinggoose/app/intl-provider.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ReactNode } from 'react' -import { NextIntlClientProvider } from 'next-intl' -import { getLocale } from 'next-intl/server' -import { type LocaleCode } from '@/i18n/utils' - -interface IntlProviderProps { - children: ReactNode -} - -export default async function IntlProvider({ children }: IntlProviderProps) { - const locale = (await getLocale()) as LocaleCode - - return ( - - {children} - - ) -} diff --git a/apps/tradinggoose/components/emails/index.ts b/apps/tradinggoose/components/emails/index.ts index 6f0108a97..1baf08cde 100644 --- a/apps/tradinggoose/components/emails/index.ts +++ b/apps/tradinggoose/components/emails/index.ts @@ -1,5 +1,4 @@ export * from './base-styles' export { default as EmailFooter } from './footer' export { EmailHeader } from './header' -export { LocalizedEmail } from './localized-email' export * from './render-email' diff --git a/apps/tradinggoose/components/market-selector/provider-selector.tsx b/apps/tradinggoose/components/market-selector/provider-selector.tsx index 264941a8e..c99826afb 100644 --- a/apps/tradinggoose/components/market-selector/provider-selector.tsx +++ b/apps/tradinggoose/components/market-selector/provider-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' import type { MarketProviderOption } from '@/providers/market/providers' diff --git a/apps/tradinggoose/components/oauth/oauth-required-modal.tsx b/apps/tradinggoose/components/oauth/oauth-required-modal.tsx index f4df70eb4..279fb7303 100644 --- a/apps/tradinggoose/components/oauth/oauth-required-modal.tsx +++ b/apps/tradinggoose/components/oauth/oauth-required-modal.tsx @@ -19,7 +19,7 @@ import { parseProvider, } from '@/lib/oauth' import { startOAuthConnectFlow } from '@/lib/oauth/connect' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkflowBlockEditorCopy } from '@/widgets/widgets/editor_workflow/copy' const logger = createLogger('OAuthRequiredModal') diff --git a/apps/tradinggoose/components/trading-selector/account-selector.test.tsx b/apps/tradinggoose/components/trading-selector/account-selector.test.tsx index a6d281025..b624ec60b 100644 --- a/apps/tradinggoose/components/trading-selector/account-selector.test.tsx +++ b/apps/tradinggoose/components/trading-selector/account-selector.test.tsx @@ -6,7 +6,7 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { NextIntlClientProvider } from 'next-intl' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { getPublicCopy } from '@/i18n/public-copy' import { TradingAccountSelector } from '@/components/trading-selector/account-selector' import { TooltipProvider } from '@/components/ui/tooltip' diff --git a/apps/tradinggoose/components/trading-selector/account-selector.tsx b/apps/tradinggoose/components/trading-selector/account-selector.tsx index 4b1e0c6bf..d67eeb020 100644 --- a/apps/tradinggoose/components/trading-selector/account-selector.tsx +++ b/apps/tradinggoose/components/trading-selector/account-selector.tsx @@ -21,7 +21,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { usePortfolioIdentities } from '@/hooks/queries/trading-portfolio' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { arePortfolioIdentitiesEqual, diff --git a/apps/tradinggoose/components/trading-selector/provider-selector.tsx b/apps/tradinggoose/components/trading-selector/provider-selector.tsx index 784f7c9ed..552083fde 100644 --- a/apps/tradinggoose/components/trading-selector/provider-selector.tsx +++ b/apps/tradinggoose/components/trading-selector/provider-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' import { OAUTH_PROVIDERS, parseProvider } from '@/lib/oauth' diff --git a/apps/tradinggoose/i18n/client-messages.ts b/apps/tradinggoose/i18n/client-messages.ts index 55643335b..d67265e41 100644 --- a/apps/tradinggoose/i18n/client-messages.ts +++ b/apps/tradinggoose/i18n/client-messages.ts @@ -17,7 +17,7 @@ import type { WorkspaceMessages, } from './message-types' -export { formatTemplate } from './template' +export { formatTemplate } from './utils' export type { PublicCopy, PublicMessages } from './message-types' export function useAppMessages(): PublicMessages { diff --git a/apps/tradinggoose/i18n/public-copy.ts b/apps/tradinggoose/i18n/public-copy.ts index f4907c82a..0c82ca178 100644 --- a/apps/tradinggoose/i18n/public-copy.ts +++ b/apps/tradinggoose/i18n/public-copy.ts @@ -1,8 +1,7 @@ import enCopy from './messages/en.json' import esCopy from './messages/es.json' import zhCopy from './messages/zh.json' -import { formatTemplate } from './template' -import { defaultLocale, type LocaleCode } from './utils' +import { defaultLocale, formatTemplate, type LocaleCode } from './utils' type WidenLiteralValues = T extends string ? string diff --git a/apps/tradinggoose/i18n/template.ts b/apps/tradinggoose/i18n/template.ts deleted file mode 100644 index b7dcee974..000000000 --- a/apps/tradinggoose/i18n/template.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function formatTemplate(template: string, values: Record) { - return Object.entries(values).reduce( - (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)), - template - ) -} diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts index d90383789..ce53ad1f6 100644 --- a/apps/tradinggoose/i18n/utils.ts +++ b/apps/tradinggoose/i18n/utils.ts @@ -117,3 +117,10 @@ export function buildLocalizedAlternates(locale: LocaleCode, pathname: string) { ]), } } + +export function formatTemplate(template: string, values: Record) { + return Object.entries(values).reduce( + (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)), + template + ) +} diff --git a/apps/tradinggoose/i18n/workflow-inspector-core.ts b/apps/tradinggoose/i18n/workflow-inspector-core.ts index eb68f403c..9186da2bd 100644 --- a/apps/tradinggoose/i18n/workflow-inspector-core.ts +++ b/apps/tradinggoose/i18n/workflow-inspector-core.ts @@ -8,8 +8,7 @@ import { } from '@/tools/params' import type { TriggerConfig } from '@/triggers/types' import { getPublicCopy, type PublicCopy } from './public-copy' -import { formatTemplate } from './template' -import { defaultLocale } from './utils' +import { defaultLocale, formatTemplate } from './utils' export type WorkflowInspectorCopy = Pick< PublicCopy['workspace']['widgets'], diff --git a/apps/tradinggoose/lib/email/locale.test.ts b/apps/tradinggoose/lib/email/locale.test.ts new file mode 100644 index 000000000..226faa7ca --- /dev/null +++ b/apps/tradinggoose/lib/email/locale.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDbSelect, mockLimit } = vi.hoisted(() => { + const mockLimit = vi.fn() + const mockDbSelect = vi.fn(() => { + const builder = { + from: vi.fn(() => builder), + leftJoin: vi.fn(() => builder), + limit: () => mockLimit(), + where: vi.fn(() => builder), + } + + return builder + }) + + return { mockDbSelect, mockLimit } +}) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: () => mockDbSelect(), + }, +})) + +vi.mock('@tradinggoose/db/schema', () => ({ + settings: { + preferredLocale: 'settings.preferredLocale', + userId: 'settings.userId', + }, + user: { + email: 'user.email', + id: 'user.id', + }, + waitlist: { + email: 'waitlist.email', + preferredLocale: 'waitlist.preferredLocale', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: (...args: unknown[]) => ({ args, kind: 'eq' }), +})) + +import { normalizeEmailLocale, resolveEmailLocale } from './locale' + +describe('email locale resolution', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('normalizes unsupported locale input to the default locale', () => { + expect(normalizeEmailLocale('fr')).toBe('en') + expect(normalizeEmailLocale(null)).toBe('en') + }) + + it('uses authenticated user settings as the canonical user locale source', async () => { + mockLimit.mockResolvedValueOnce([{ preferredLocale: 'zh' }]) + + await expect(resolveEmailLocale({ fallbackLocale: 'es', userId: 'user-1' })).resolves.toBe( + 'zh' + ) + + expect(mockDbSelect).toHaveBeenCalledTimes(1) + }) + + it('uses waitlist as the canonical anonymous durable locale source', async () => { + mockLimit.mockResolvedValueOnce([]) + mockLimit.mockResolvedValueOnce([{ preferredLocale: 'es' }]) + + await expect(resolveEmailLocale({ email: 'Guest@Example.com' })).resolves.toBe('es') + + expect(mockDbSelect).toHaveBeenCalledTimes(2) + }) + + it('falls back when no user or waitlist locale exists', async () => { + mockLimit.mockResolvedValueOnce([]) + mockLimit.mockResolvedValueOnce([]) + + await expect( + resolveEmailLocale({ email: 'guest@example.com', fallbackLocale: 'zh' }) + ).resolves.toBe('zh') + }) +}) diff --git a/apps/tradinggoose/lib/email/locale.ts b/apps/tradinggoose/lib/email/locale.ts index 76646759f..a2e672972 100644 --- a/apps/tradinggoose/lib/email/locale.ts +++ b/apps/tradinggoose/lib/email/locale.ts @@ -1,7 +1,6 @@ import { db } from '@tradinggoose/db' -import { emailRecipientPreference, settings, user } from '@tradinggoose/db/schema' +import { settings, user, waitlist } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' export function normalizeEmailLocale(locale: string | null | undefined): LocaleCode { @@ -12,54 +11,6 @@ function normalizeEmail(email: string) { return email.trim().toLowerCase() } -export async function persistAuthenticatedPreferredLocale(userId: string, locale: string) { - const preferredLocale = normalizeEmailLocale(locale) - - await db - .insert(settings) - .values({ - id: nanoid(), - userId, - preferredLocale, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: settings.userId, - set: { - preferredLocale, - updatedAt: new Date(), - }, - }) - - return preferredLocale -} - -export async function persistAnonymousEmailLocale(email: string, locale: string | null | undefined) { - const normalizedEmail = normalizeEmail(email) - if (!normalizedEmail) { - return defaultLocale - } - - const preferredLocale = normalizeEmailLocale(locale) - - await db - .insert(emailRecipientPreference) - .values({ - email: normalizedEmail, - preferredLocale, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: emailRecipientPreference.email, - set: { - preferredLocale, - updatedAt: new Date(), - }, - }) - - return preferredLocale -} - export async function resolveEmailLocale({ userId, email, @@ -99,13 +50,13 @@ export async function resolveEmailLocale({ return normalizeEmailLocale(fallbackLocale) } - const anonymousRows = await db - .select({ preferredLocale: emailRecipientPreference.preferredLocale }) - .from(emailRecipientPreference) - .where(eq(emailRecipientPreference.email, normalizedEmail)) + const waitlistRows = await db + .select({ preferredLocale: waitlist.preferredLocale }) + .from(waitlist) + .where(eq(waitlist.email, normalizedEmail)) .limit(1) - const anonymousPreferredLocale = anonymousRows[0]?.preferredLocale + const anonymousPreferredLocale = waitlistRows[0]?.preferredLocale if (anonymousPreferredLocale && isLocaleCode(anonymousPreferredLocale)) { return anonymousPreferredLocale } diff --git a/apps/tradinggoose/lib/registration/service.test.ts b/apps/tradinggoose/lib/registration/service.test.ts index 2a9519bee..999c87cb5 100644 --- a/apps/tradinggoose/lib/registration/service.test.ts +++ b/apps/tradinggoose/lib/registration/service.test.ts @@ -54,6 +54,7 @@ vi.mock('@tradinggoose/db/schema', () => ({ createdAt: 'waitlist.createdAt', email: 'waitlist.email', id: 'waitlist.id', + preferredLocale: 'waitlist.preferredLocale', rejectedAt: 'waitlist.rejectedAt', rejectedByUserId: 'waitlist.rejectedByUserId', signedUpAt: 'waitlist.signedUpAt', @@ -95,8 +96,8 @@ vi.mock('@/lib/email/mailer', () => ({ })) vi.mock('@/lib/email/locale', () => ({ - persistAnonymousEmailLocale: vi.fn().mockResolvedValue('en'), - resolveEmailLocale: vi.fn().mockResolvedValue('en'), + normalizeEmailLocale: (locale?: string | null) => + locale === 'en' || locale === 'es' || locale === 'zh' ? locale : 'en', })) vi.mock('@/lib/logs/console/logger', () => ({ @@ -140,8 +141,8 @@ describe('registration service waitlist approvals', () => { it('sends changed approval emails through the batch mailer', async () => { mockSelectWhere.mockResolvedValueOnce([ - { email: 'alpha@example.com', id: 'entry-1', status: 'pending' }, - { email: 'beta@example.com', id: 'entry-2', status: 'rejected' }, + { email: 'alpha@example.com', id: 'entry-1', preferredLocale: 'es', status: 'pending' }, + { email: 'beta@example.com', id: 'entry-2', preferredLocale: 'zh', status: 'rejected' }, ]) await updateWaitlistStatuses({ @@ -168,12 +169,23 @@ describe('registration service waitlist approvals', () => { ], }) expect(mockSendEmail).not.toHaveBeenCalled() + expect(mockRenderWaitlistApprovedEmail).toHaveBeenCalledWith( + 'alpha@example.com', + 'https://app.tradinggoose.ai/signup?email=alpha%40example.com', + 'es' + ) + expect(mockRenderWaitlistApprovedEmail).toHaveBeenCalledWith( + 'beta@example.com', + 'https://app.tradinggoose.ai/signup?email=beta%40example.com', + 'zh' + ) }) it('chunks approval email batches to the Resend request limit', async () => { const rows = Array.from({ length: 101 }, (_, index) => ({ email: `user-${index}@example.com`, id: `entry-${index}`, + preferredLocale: 'en', status: 'pending', })) @@ -192,7 +204,7 @@ describe('registration service waitlist approvals', () => { it('does not send approval emails for rejected status updates', async () => { mockSelectWhere.mockResolvedValueOnce([ - { email: 'alpha@example.com', id: 'entry-1', status: 'pending' }, + { email: 'alpha@example.com', id: 'entry-1', preferredLocale: 'en', status: 'pending' }, ]) await updateWaitlistStatuses({ diff --git a/apps/tradinggoose/lib/registration/service.ts b/apps/tradinggoose/lib/registration/service.ts index eddb000f0..e18170d3c 100644 --- a/apps/tradinggoose/lib/registration/service.ts +++ b/apps/tradinggoose/lib/registration/service.ts @@ -7,7 +7,7 @@ import { renderWaitlistConfirmationEmail, } from '@/components/emails/render-email' import { type EmailOptions, sendBatchEmails, sendEmail } from '@/lib/email/mailer' -import { persistAnonymousEmailLocale, resolveEmailLocale } from '@/lib/email/locale' +import { normalizeEmailLocale } from '@/lib/email/locale' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { @@ -45,6 +45,7 @@ export interface WaitlistRow { rejectedByUserId: string | null signedUpAt: Date | null userId: string | null + preferredLocale: string createdAt: Date updatedAt: Date } @@ -112,9 +113,24 @@ export async function addToWaitlist(email: string, locale?: string | null): Prom throw new Error(validation.reason || 'Invalid email address') } - const preferredLocale = await persistAnonymousEmailLocale(normalizedEmail, locale) + const preferredLocale = normalizeEmailLocale(locale) const existing = await getWaitlistEntryByEmail(normalizedEmail) if (existing) { + if (existing.preferredLocale !== preferredLocale) { + await db + .update(waitlist) + .set({ + preferredLocale, + updatedAt: new Date(), + }) + .where(eq(waitlist.id, existing.id)) + + return { + ...existing, + preferredLocale, + } + } + return existing } @@ -129,6 +145,7 @@ export async function addToWaitlist(email: string, locale?: string | null): Prom rejectedByUserId: null, signedUpAt: null, userId: null, + preferredLocale, createdAt: now, updatedAt: now, } @@ -149,7 +166,12 @@ export async function updateWaitlistStatuses(params: { } const rows = await db - .select({ id: waitlist.id, email: waitlist.email, status: waitlist.status }) + .select({ + id: waitlist.id, + email: waitlist.email, + preferredLocale: waitlist.preferredLocale, + status: waitlist.status, + }) .from(waitlist) .where(inArray(waitlist.id, ids)) @@ -183,7 +205,12 @@ export async function updateWaitlistStatuses(params: { .where(inArray(waitlist.id, idsToUpdate)) if (params.status === 'approved') { - await sendWaitlistApprovalEmails(rowsToUpdate.map((row) => row.email)) + await sendWaitlistApprovalEmails( + rowsToUpdate.map((row) => ({ + email: row.email, + preferredLocale: row.preferredLocale, + })) + ) } } @@ -304,11 +331,12 @@ async function sendWaitlistConfirmationEmail(email: string, locale: string) { async function createWaitlistApprovalEmail( email: string, - baseUrl: string + baseUrl: string, + preferredLocale: string ): Promise { try { const signupLink = `${baseUrl}/signup?email=${encodeURIComponent(email)}` - const locale = await resolveEmailLocale({ email }) + const locale = normalizeEmailLocale(preferredLocale) return { to: email, subject: getEmailSubject('waitlist-approved', locale), @@ -324,13 +352,17 @@ async function createWaitlistApprovalEmail( } } -async function sendWaitlistApprovalEmails(emails: string[]) { +async function sendWaitlistApprovalEmails( + recipients: Array<{ email: string; preferredLocale: string }> +) { const baseUrl = getBaseUrl() - for (let index = 0; index < emails.length; index += RESEND_BATCH_EMAIL_LIMIT) { - const batch = emails.slice(index, index + RESEND_BATCH_EMAIL_LIMIT) + for (let index = 0; index < recipients.length; index += RESEND_BATCH_EMAIL_LIMIT) { + const batch = recipients.slice(index, index + RESEND_BATCH_EMAIL_LIMIT) const renderedEmails = await Promise.all( - batch.map((email) => createWaitlistApprovalEmail(email, baseUrl)) + batch.map(({ email, preferredLocale }) => + createWaitlistApprovalEmail(email, baseUrl, preferredLocale) + ) ) const batchEmails = renderedEmails.filter((email): email is EmailOptions => email !== null) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx index aa8a72aa4..fc79b7a69 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx @@ -3,7 +3,7 @@ import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react' import { Button, Card, CardContent, Input, Label } from '@/components/ui' import type { ChatAuthType } from '@/lib/chat/deployment-config' import { getEnv, isTruthy } from '@/lib/env' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { cn, generatePassword } from '@/lib/utils' import { useDeploymentCopy } from '@/widgets/widgets/editor_workflow/copy' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx index 20d8d9939..c8d0ace73 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -59,7 +59,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/webhook-settings/webhook-settings.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/webhook-settings/webhook-settings.tsx index 45b6ae499..d43aa16d7 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/webhook-settings/webhook-settings.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/webhook-settings/webhook-settings.tsx @@ -39,7 +39,7 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { cn, generatePassword } from '@/lib/utils' import type { PublicCopy } from '@/i18n/client-messages' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import type { LogLevel as StoreLogLevel, TriggerType as StoreTriggerType, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx index 5454e330b..e08c9fc76 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx @@ -19,7 +19,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { useWorkflowExecution } from '@/hooks/workflow/use-workflow-execution' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { DeploymentControls, ExportControls, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/trigger-list/trigger-list.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/trigger-list/trigger-list.tsx index 973fa3286..97b0fcfb4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/trigger-list/trigger-list.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/trigger-list/trigger-list.tsx @@ -5,7 +5,7 @@ import { Info, Plus, Search, X } from 'lucide-react' import { useLocale } from 'next-intl' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { createLogger } from '@/lib/logs/console/logger' import { getIconTileStyle } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/order-row.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/order-row.test.tsx index e8f0387e3..0426a2ca4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/order-row.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/order-row.test.tsx @@ -5,9 +5,14 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrderIdRow } from './order-row' -vi.mock('next-intl', () => ({ - useLocale: () => 'es', -})) +vi.mock('next-intl', async () => { + const { getPublicCopy } = await import('@/i18n/public-copy') + + return { + useLocale: () => 'es', + useMessages: () => getPublicCopy('es'), + } +}) describe('OrderIdRow', () => { let container: HTMLDivElement diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx index 2ca61acec..28acda68a 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx @@ -5,7 +5,7 @@ import { useLocale } from 'next-intl' import { ToolCase, XIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { translateWorkflowLabel } from '@/i18n/block-editor' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import type { LocaleCode } from '@/i18n/utils' import { useSkills } from '@/hooks/queries/skills' import { Dropdown } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 82b5781ea..ce18c6091 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -27,7 +27,7 @@ import { getToolInputCopy, localizeWorkflowSubBlockConfig, } from '@/i18n/block-editor' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useRouter } from '@/i18n/navigation' import type { LocaleCode } from '@/i18n/utils' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/ai/utils' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx index cd898b81c..6d50b2cf5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx @@ -6,7 +6,7 @@ import { MarketProviderSelector } from '@/components/market-selector/provider-se import { TradingAccountSelector } from '@/components/trading-selector/account-selector' import { TradingProviderSelector } from '@/components/trading-selector/provider-selector' import { Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { DateTimePicker } from '@/components/ui/datetime-picker' import { SimpleTimePicker } from '@/components/ui/simple-time-picker' import { Slider } from '@/components/ui/slider' diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat-file-upload/chat-file-upload.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat-file-upload/chat-file-upload.tsx index 691981c79..88df73a4b 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat-file-upload/chat-file-upload.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat-file-upload/chat-file-upload.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { File, FileText, Image, Paperclip, X } from 'lucide-react' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { createLogger } from '@/lib/logs/console/logger' import { useWorkflowChatMessages } from '@/i18n/workspace-widget-hooks' diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat/chat.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat/chat.tsx index a9436b942..d1f25795b 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat/chat.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/components/chat/chat.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { formatFileSize } from '@/i18n/formatters' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { createChatOutputEventReader } from '@/lib/workflows/chat-output' diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx index 70828351e..e97db8ec0 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx @@ -4,7 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { Check, ChevronDown, Search } from 'lucide-react' import { createPortal } from 'react-dom' import { Input } from '@/components/ui/input' -import { formatTemplate } from '@/i18n/template' +import { formatTemplate } from '@/i18n/utils' import { useWorkflowOutputSelectMessages } from '@/i18n/workspace-widget-hooks' import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' diff --git a/packages/db/migrations/0035_marvelous_avengers.sql b/packages/db/migrations/0035_marvelous_avengers.sql new file mode 100644 index 000000000..9f5cf80bc --- /dev/null +++ b/packages/db/migrations/0035_marvelous_avengers.sql @@ -0,0 +1,4 @@ +ALTER TABLE "settings" ADD COLUMN "preferred_locale" text DEFAULT 'en' NOT NULL;--> statement-breakpoint +ALTER TABLE "waitlist" ADD COLUMN "preferred_locale" text DEFAULT 'en' NOT NULL;--> statement-breakpoint +ALTER TABLE "pending_execution" DROP COLUMN "attempts";--> statement-breakpoint +ALTER TABLE "pending_execution" DROP COLUMN "error_message"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0035_snapshot.json b/packages/db/migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..5ddb3056e --- /dev/null +++ b/packages/db/migrations/meta/0035_snapshot.json @@ -0,0 +1,11573 @@ +{ + "id": "c4a41554-6cca-481b-a0c5-77fd08538b08", + "prevId": "03fa5cbd-4ad5-4f77-9206-4873ca565984", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "env_owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_billing_ledger": { + "name": "organization_billing_ledger", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_billing_ledger_organization_id_idx": { + "name": "organization_billing_ledger_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_billing_ledger_organization_id_organization_id_fk": { + "name": "organization_billing_ledger_organization_id_organization_id_fk", + "tableFrom": "organization_billing_ledger", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_billing_ledger": { + "name": "organization_member_billing_ledger", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_member_billing_ledger_organization_id_idx": { + "name": "organization_member_billing_ledger_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_member_billing_ledger_user_id_idx": { + "name": "organization_member_billing_ledger_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_billing_ledger_organization_id_organization_id_fk": { + "name": "organization_member_billing_ledger_organization_id_organization_id_fk", + "tableFrom": "organization_member_billing_ledger", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_billing_ledger_user_id_user_id_fk": { + "name": "organization_member_billing_ledger_user_id_user_id_fk", + "tableFrom": "organization_member_billing_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_member_billing_ledger_pkey": { + "name": "organization_member_billing_ledger_pkey", + "columns": [ + "organization_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_tier_id": { + "name": "billing_tier_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_type": { + "name": "reference_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_billing_tier_id_idx": { + "name": "subscription_billing_tier_id_idx", + "columns": [ + { + "expression": "billing_tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_billing_tier_id_system_billing_tier_id_fk": { + "name": "subscription_billing_tier_id_system_billing_tier_id_fk", + "tableFrom": "subscription", + "tableTo": "system_billing_tier", + "columnsFrom": [ + "billing_tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_endpoint_requests": { + "name": "api_endpoint_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "granted_onboarding_allowance_usd": { + "name": "granted_onboarding_allowance_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "custom_usage_limit": { + "name": "custom_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "custom_usage_limit_updated_at": { + "name": "custom_usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_items": { + "name": "copilot_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'message'" + }, + "message_role": { + "name": "message_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_calls": { + "name": "tool_calls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "file_attachments": { + "name": "file_attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_review_items_session_id_idx": { + "name": "copilot_review_items_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_turn_id_idx": { + "name": "copilot_review_items_turn_id_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_kind_idx": { + "name": "copilot_review_items_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_session_sequence_unique": { + "name": "copilot_review_items_session_sequence_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_session_item_unique": { + "name": "copilot_review_items_session_item_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_items_session_id_copilot_review_sessions_id_fk": { + "name": "copilot_review_items_session_id_copilot_review_sessions_id_fk", + "tableFrom": "copilot_review_items", + "tableTo": "copilot_review_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_review_items_turn_id_copilot_review_turns_id_fk": { + "name": "copilot_review_items_turn_id_copilot_review_turns_id_fk", + "tableFrom": "copilot_review_items", + "tableTo": "copilot_review_turns", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_sessions": { + "name": "copilot_review_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_kind": { + "name": "entity_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_session_id": { + "name": "draft_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_review_sessions_user_id_idx": { + "name": "copilot_review_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_user_entity_idx": { + "name": "copilot_review_sessions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_workspace_entity_idx": { + "name": "copilot_review_sessions_workspace_entity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_workspace_draft_idx": { + "name": "copilot_review_sessions_workspace_draft_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_user_workspace_channel_idx": { + "name": "copilot_review_sessions_user_workspace_channel_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"workspace_id\", 'global')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NOT NULL AND \"copilot_review_sessions\".\"entity_kind\" = 'copilot'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_saved_entity_unique": { + "name": "copilot_review_sessions_saved_entity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NULL AND \"copilot_review_sessions\".\"entity_kind\" <> 'workflow' AND \"copilot_review_sessions\".\"entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_draft_entity_unique": { + "name": "copilot_review_sessions_draft_entity_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NULL AND \"copilot_review_sessions\".\"entity_kind\" <> 'workflow' AND \"copilot_review_sessions\".\"entity_id\" IS NULL AND \"copilot_review_sessions\".\"draft_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_created_at_idx": { + "name": "copilot_review_sessions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_updated_at_idx": { + "name": "copilot_review_sessions_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_sessions_user_id_user_id_fk": { + "name": "copilot_review_sessions_user_id_user_id_fk", + "tableFrom": "copilot_review_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_turns": { + "name": "copilot_review_turns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "user_message_item_id": { + "name": "user_message_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_review_turns_session_id_idx": { + "name": "copilot_review_turns_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_turns_session_status_idx": { + "name": "copilot_review_turns_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_turns_session_sequence_unique": { + "name": "copilot_review_turns_session_sequence_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_turns_session_id_copilot_review_sessions_id_fk": { + "name": "copilot_review_turns_session_id_copilot_review_sessions_id_fk", + "tableFrom": "copilot_review_turns", + "tableTo": "copilot_review_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_owner_type": { + "name": "billing_owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_owner_user_id": { + "name": "billing_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_owner_organization_id": { + "name": "billing_owner_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billing_owner_user_id_user_id_fk": { + "name": "workspace_billing_owner_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "billing_owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_billing_owner_organization_id_organization_id_fk": { + "name": "workspace_billing_owner_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": [ + "billing_owner_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_billing_owner_check": { + "name": "workspace_billing_owner_check", + "value": "(\n \"workspace\".\"billing_owner_type\" = 'user'\n AND \"workspace\".\"billing_owner_user_id\" IS NOT NULL\n AND \"workspace\".\"billing_owner_organization_id\" IS NULL\n ) OR (\n \"workspace\".\"billing_owner_type\" = 'organization'\n AND \"workspace\".\"billing_owner_user_id\" IS NULL\n AND \"workspace\".\"billing_owner_organization_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_admin": { + "name": "system_admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_admin_user_id_unique": { + "name": "system_admin_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_admin_user_id_user_id_fk": { + "name": "system_admin_user_id_user_id_fk", + "tableFrom": "system_admin", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_billing_settings": { + "name": "system_billing_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "onboarding_allowance_usd": { + "name": "onboarding_allowance_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "overage_threshold_dollars": { + "name": "overage_threshold_dollars", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'50'" + }, + "workflow_execution_charge_usd": { + "name": "workflow_execution_charge_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "function_execution_charge_usd": { + "name": "function_execution_charge_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "usage_warning_threshold_percent": { + "name": "usage_warning_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "free_tier_upgrade_threshold_percent": { + "name": "free_tier_upgrade_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "enterprise_contact_url": { + "name": "enterprise_contact_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_billing_settings_updated_by_user_id_idx": { + "name": "system_billing_settings_updated_by_user_id_idx", + "columns": [ + { + "expression": "updated_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_billing_settings_updated_by_user_id_user_id_fk": { + "name": "system_billing_settings_updated_by_user_id_user_id_fk", + "tableFrom": "system_billing_settings", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_billing_settings_usage_warning_threshold_check": { + "name": "system_billing_settings_usage_warning_threshold_check", + "value": "\"system_billing_settings\".\"usage_warning_threshold_percent\" between 1 and 100" + }, + "system_billing_settings_onboarding_allowance_check": { + "name": "system_billing_settings_onboarding_allowance_check", + "value": "\"system_billing_settings\".\"onboarding_allowance_usd\" >= 0" + }, + "system_billing_settings_workflow_execution_charge_check": { + "name": "system_billing_settings_workflow_execution_charge_check", + "value": "\"system_billing_settings\".\"workflow_execution_charge_usd\" >= 0" + }, + "system_billing_settings_function_execution_charge_check": { + "name": "system_billing_settings_function_execution_charge_check", + "value": "\"system_billing_settings\".\"function_execution_charge_usd\" >= 0" + }, + "system_billing_settings_free_tier_upgrade_threshold_check": { + "name": "system_billing_settings_free_tier_upgrade_threshold_check", + "value": "\"system_billing_settings\".\"free_tier_upgrade_threshold_percent\" between 1 and 100" + } + }, + "isRLSEnabled": false + }, + "public.system_billing_tier": { + "name": "system_billing_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_scope": { + "name": "usage_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_mode": { + "name": "seat_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "monthly_price_usd": { + "name": "monthly_price_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "yearly_price_usd": { + "name": "yearly_price_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "included_usage_limit_usd": { + "name": "included_usage_limit_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_limit_gb": { + "name": "storage_limit_gb", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "concurrency_limit": { + "name": "concurrency_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seat_maximum": { + "name": "seat_maximum", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_monthly_price_id": { + "name": "stripe_monthly_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_yearly_price_id": { + "name": "stripe_yearly_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_rate_limit_per_minute": { + "name": "sync_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "async_rate_limit_per_minute": { + "name": "async_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_endpoint_rate_limit_per_minute": { + "name": "api_endpoint_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_pending_age_seconds": { + "name": "max_pending_age_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_pending_count": { + "name": "max_pending_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "can_edit_usage_limit": { + "name": "can_edit_usage_limit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_configure_sso": { + "name": "can_configure_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "log_retention_days": { + "name": "log_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "workflow_execution_multiplier": { + "name": "workflow_execution_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "workflow_model_cost_multiplier": { + "name": "workflow_model_cost_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "function_execution_multiplier": { + "name": "function_execution_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "copilot_cost_multiplier": { + "name": "copilot_cost_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "pricing_features": { + "name": "pricing_features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_billing_tier_status_idx": { + "name": "system_billing_tier_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_billing_tier_display_order_idx": { + "name": "system_billing_tier_display_order_idx", + "columns": [ + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_billing_tier_updated_by_user_id_idx": { + "name": "system_billing_tier_updated_by_user_id_idx", + "columns": [ + { + "expression": "updated_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_billing_tier_updated_by_user_id_user_id_fk": { + "name": "system_billing_tier_updated_by_user_id_user_id_fk", + "tableFrom": "system_billing_tier", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_billing_tier_status_check": { + "name": "system_billing_tier_status_check", + "value": "\"system_billing_tier\".\"status\" in ('active', 'draft', 'archived')" + }, + "system_billing_tier_owner_type_check": { + "name": "system_billing_tier_owner_type_check", + "value": "\"system_billing_tier\".\"owner_type\" in ('user', 'organization')" + }, + "system_billing_tier_usage_scope_check": { + "name": "system_billing_tier_usage_scope_check", + "value": "\"system_billing_tier\".\"usage_scope\" in ('individual', 'pooled')" + }, + "system_billing_tier_seat_mode_check": { + "name": "system_billing_tier_seat_mode_check", + "value": "\"system_billing_tier\".\"seat_mode\" in ('fixed', 'adjustable')" + }, + "system_billing_tier_seat_count_check": { + "name": "system_billing_tier_seat_count_check", + "value": "\"system_billing_tier\".\"seat_count\" is null or \"system_billing_tier\".\"seat_count\" >= 1" + }, + "system_billing_tier_sync_rate_limit_check": { + "name": "system_billing_tier_sync_rate_limit_check", + "value": "\"system_billing_tier\".\"sync_rate_limit_per_minute\" is null or \"system_billing_tier\".\"sync_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_async_rate_limit_check": { + "name": "system_billing_tier_async_rate_limit_check", + "value": "\"system_billing_tier\".\"async_rate_limit_per_minute\" is null or \"system_billing_tier\".\"async_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_api_endpoint_rate_limit_check": { + "name": "system_billing_tier_api_endpoint_rate_limit_check", + "value": "\"system_billing_tier\".\"api_endpoint_rate_limit_per_minute\" is null or \"system_billing_tier\".\"api_endpoint_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_max_pending_age_check": { + "name": "system_billing_tier_max_pending_age_check", + "value": "\"system_billing_tier\".\"max_pending_age_seconds\" is null or \"system_billing_tier\".\"max_pending_age_seconds\" >= 0" + }, + "system_billing_tier_max_pending_count_check": { + "name": "system_billing_tier_max_pending_count_check", + "value": "\"system_billing_tier\".\"max_pending_count\" is null or \"system_billing_tier\".\"max_pending_count\" >= 0" + }, + "system_billing_tier_log_retention_days_check": { + "name": "system_billing_tier_log_retention_days_check", + "value": "\"system_billing_tier\".\"log_retention_days\" is null or \"system_billing_tier\".\"log_retention_days\" >= 0" + }, + "system_billing_tier_workflow_execution_multiplier_check": { + "name": "system_billing_tier_workflow_execution_multiplier_check", + "value": "\"system_billing_tier\".\"workflow_execution_multiplier\" >= 0" + }, + "system_billing_tier_workflow_model_cost_multiplier_check": { + "name": "system_billing_tier_workflow_model_cost_multiplier_check", + "value": "\"system_billing_tier\".\"workflow_model_cost_multiplier\" >= 0" + }, + "system_billing_tier_function_execution_multiplier_check": { + "name": "system_billing_tier_function_execution_multiplier_check", + "value": "\"system_billing_tier\".\"function_execution_multiplier\" >= 0" + }, + "system_billing_tier_copilot_cost_multiplier_check": { + "name": "system_billing_tier_copilot_cost_multiplier_check", + "value": "\"system_billing_tier\".\"copilot_cost_multiplier\" >= 0" + }, + "system_billing_tier_seat_range_check": { + "name": "system_billing_tier_seat_range_check", + "value": "\"system_billing_tier\".\"seat_maximum\" is null or \"system_billing_tier\".\"seat_count\" is null or \"system_billing_tier\".\"seat_maximum\" >= \"system_billing_tier\".\"seat_count\"" + }, + "system_billing_tier_user_owner_shape_check": { + "name": "system_billing_tier_user_owner_shape_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'organization' or (\"system_billing_tier\".\"usage_scope\" = 'individual' and \"system_billing_tier\".\"seat_mode\" = 'fixed' and \"system_billing_tier\".\"seat_count\" is null and \"system_billing_tier\".\"seat_maximum\" is null)" + }, + "system_billing_tier_org_seat_count_check": { + "name": "system_billing_tier_org_seat_count_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'user' or \"system_billing_tier\".\"seat_count\" is not null" + }, + "system_billing_tier_fixed_seat_maximum_check": { + "name": "system_billing_tier_fixed_seat_maximum_check", + "value": "\"system_billing_tier\".\"seat_mode\" = 'adjustable' or \"system_billing_tier\".\"seat_maximum\" is null" + }, + "system_billing_tier_sso_owner_type_check": { + "name": "system_billing_tier_sso_owner_type_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'organization' or \"system_billing_tier\".\"can_configure_sso\" = false" + } + }, + "isRLSEnabled": false + }, + "public.system_integration_definition": { + "name": "system_integration_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_integration_definition_parent_id_idx": { + "name": "system_integration_definition_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_integration_definition_parent_id_system_integration_definition_id_fk": { + "name": "system_integration_definition_parent_id_system_integration_definition_id_fk", + "tableFrom": "system_integration_definition", + "tableTo": "system_integration_definition", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_integration_definition_parent_check": { + "name": "system_integration_definition_parent_check", + "value": "\"system_integration_definition\".\"parent_id\" is null or \"system_integration_definition\".\"parent_id\" <> \"system_integration_definition\".\"id\"" + }, + "system_integration_definition_availability_check": { + "name": "system_integration_definition_availability_check", + "value": "(\"system_integration_definition\".\"parent_id\" is null and \"system_integration_definition\".\"is_enabled\" is null) or (\"system_integration_definition\".\"parent_id\" is not null and \"system_integration_definition\".\"is_enabled\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.system_integration_secret": { + "name": "system_integration_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_integration_secret_definition_id_idx": { + "name": "system_integration_secret_definition_id_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_integration_secret_definition_key_unique": { + "name": "system_integration_secret_definition_key_unique", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_integration_secret_definition_id_system_integration_definition_id_fk": { + "name": "system_integration_secret_definition_id_system_integration_definition_id_fk", + "tableFrom": "system_integration_secret", + "tableTo": "system_integration_definition", + "columnsFrom": [ + "definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_service_values": { + "name": "system_service_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "system_service_value_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_service_values_service_idx": { + "name": "system_service_values_service_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_service_values_service_kind_idx": { + "name": "system_service_values_service_kind_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_service_values_service_kind_key_unique": { + "name": "system_service_values_service_kind_key_unique", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "registration_mode": { + "name": "registration_mode", + "type": "registration_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "billing_enabled": { + "name": "billing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_dev_enabled": { + "name": "trigger_dev_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_promotion_codes": { + "name": "allow_promotion_codes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'tradinggoose.ai'" + }, + "from_email_address": { + "name": "from_email_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signed_up_at": { + "name": "signed_up_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_email_idx": { + "name": "waitlist_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_user_id_idx": { + "name": "waitlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_approved_by_user_id_user_id_fk": { + "name": "waitlist_approved_by_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "waitlist_rejected_by_user_id_user_id_fk": { + "name": "waitlist_rejected_by_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "rejected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "waitlist_user_id_user_id_fk": { + "name": "waitlist_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_block_id": { + "name": "trigger_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_workflow_trigger_unique": { + "name": "chat_workflow_trigger_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_deployment_version_idx": { + "name": "chat_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "chat_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "chat", + "tableTo": "workflow_deployment_version", + "columnsFrom": [ + "deployment_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orderHistoryTable": { + "name": "orderHistoryTable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "submission_source": { + "name": "submission_source", + "type": "order_submission_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "log_id": { + "name": "log_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_identity": { + "name": "listing_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "normalized_order": { + "name": "normalized_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_history_provider_idx": { + "name": "order_history_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_workspace_idx": { + "name": "order_history_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_log_idx": { + "name": "order_history_log_idx", + "columns": [ + { + "expression": "log_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_recorded_at_idx": { + "name": "order_history_recorded_at_idx", + "columns": [ + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_workspace_recorded_idx": { + "name": "order_history_workspace_recorded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orderHistoryTable_workspace_id_workspace_id_fk": { + "name": "orderHistoryTable_workspace_id_workspace_id_fk", + "tableFrom": "orderHistoryTable", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orderHistoryTable_log_id_workflow_execution_logs_id_fk": { + "name": "orderHistoryTable_log_id_workflow_execution_logs_id_fk", + "tableFrom": "orderHistoryTable", + "tableTo": "workflow_execution_logs", + "columnsFrom": [ + "log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_execution": { + "name": "pending_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "billing_scope_id": { + "name": "billing_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_scope_type": { + "name": "billing_scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_type": { + "name": "execution_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordering_key": { + "name": "ordering_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pending_execution_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_execution_billing_scope_idx": { + "name": "pending_execution_billing_scope_idx", + "columns": [ + { + "expression": "billing_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_workflow_idx": { + "name": "pending_execution_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_ordering_key_idx": { + "name": "pending_execution_ordering_key_idx", + "columns": [ + { + "expression": "billing_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordering_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_source_idx": { + "name": "pending_execution_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_status_idx": { + "name": "pending_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_execution_user_id_user_id_fk": { + "name": "pending_execution_user_id_user_id_fk", + "tableFrom": "pending_execution", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_execution_workflow_id_workflow_id_fk": { + "name": "pending_execution_workflow_id_workflow_id_fk", + "tableFrom": "pending_execution", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_execution_workspace_id_workspace_id_fk": { + "name": "pending_execution_workspace_id_workspace_id_fk", + "tableFrom": "pending_execution", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'FileText'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workflow_id_idx": { + "name": "templates_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_id_idx": { + "name": "templates_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_idx": { + "name": "templates_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_views_idx": { + "name": "templates_category_views_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_stars_idx": { + "name": "templates_category_stars_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_category_idx": { + "name": "templates_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "templates_user_id_user_id_fk": { + "name": "templates_user_id_user_id_fk", + "tableFrom": "templates", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_api_key_id": { + "name": "pinned_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_pinned_api_key_id_api_key_id_fk": { + "name": "workflow_pinned_api_key_id_api_key_id_fk", + "tableFrom": "workflow", + "tableTo": "api_key", + "columnsFrom": [ + "pinned_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_type_idx": { + "name": "workflow_blocks_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_id_idx": { + "name": "workflow_deployment_version_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "source_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "target_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_summary": { + "name": "workflow_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_id_idx": { + "name": "workflow_execution_logs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_idx": { + "name": "workflow_execution_logs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": [ + "state_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workspace_id_idx": { + "name": "workflow_snapshots_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_snapshots_workspace_id_workspace_id_fk": { + "name": "workflow_execution_snapshots_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook": { + "name": "workflow_log_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_workflow_id_idx": { + "name": "workflow_log_webhook_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_active_idx": { + "name": "workflow_log_webhook_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook_delivery": { + "name": "workflow_log_webhook_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_summary": { + "name": "workflow_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "subscription_snapshot": { + "name": "subscription_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_delivery_subscription_id_idx": { + "name": "workflow_log_webhook_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_execution_id_idx": { + "name": "workflow_log_webhook_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_workspace_id_idx": { + "name": "workflow_log_webhook_delivery_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_status_idx": { + "name": "workflow_log_webhook_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_next_attempt_idx": { + "name": "workflow_log_webhook_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk": { + "name": "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow_log_webhook", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_log_webhook_delivery_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_log_webhook_delivery_workspace_id_workspace_id_fk": { + "name": "workflow_log_webhook_delivery_workspace_id_workspace_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_workspace_id_idx": { + "name": "environment_variables_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_user_key_unique": { + "name": "environment_variables_user_key_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_workspace_key_unique": { + "name": "environment_variables_workspace_key_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_user_id_fk": { + "name": "environment_variables_user_id_user_id_fk", + "tableFrom": "environment_variables", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_workspace_id_workspace_id_fk": { + "name": "environment_variables_workspace_id_workspace_id_fk", + "tableFrom": "environment_variables", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "environment_variables_scope_check": { + "name": "environment_variables_scope_check", + "value": "(user_id IS NOT NULL AND workspace_id IS NULL) OR (user_id IS NULL AND workspace_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.layout_map": { + "name": "layout_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "color_pair": { + "name": "color_pair", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "layout_map_workspace_idx": { + "name": "layout_map_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_user_idx": { + "name": "layout_map_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_workspace_user_idx": { + "name": "layout_map_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_workspace_user_active_idx": { + "name": "layout_map_workspace_user_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "layout_map_workspace_id_workspace_id_fk": { + "name": "layout_map_workspace_id_workspace_id_fk", + "tableFrom": "layout_map", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "layout_map_user_id_user_id_fk": { + "name": "layout_map_user_id_user_id_fk", + "tableFrom": "layout_map", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.monitor_view": { + "name": "monitor_view", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monitor_view_workspace_idx": { + "name": "monitor_view_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_user_idx": { + "name": "monitor_view_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_idx": { + "name": "monitor_view_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_active_idx": { + "name": "monitor_view_workspace_user_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_sort_idx": { + "name": "monitor_view_workspace_user_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monitor_view_workspace_id_workspace_id_fk": { + "name": "monitor_view_workspace_id_workspace_id_fk", + "tableFrom": "monitor_view", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_view_user_id_user_id_fk": { + "name": "monitor_view_user_id_user_id_fk", + "tableFrom": "monitor_view", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_indicators": { + "name": "custom_indicators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Indicator'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "pine_code": { + "name": "pine_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_meta": { + "name": "input_meta", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_indicators_workspace_id_idx": { + "name": "custom_indicators_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_indicators_workspace_id_workspace_id_fk": { + "name": "custom_indicators_workspace_id_workspace_id_fk", + "tableFrom": "custom_indicators", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_indicators_user_id_user_id_fk": { + "name": "custom_indicators_user_id_user_id_fk", + "tableFrom": "custom_indicators", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_id_idx": { + "name": "skill_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_item": { + "name": "watchlist_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "watchlist_id": { + "name": "watchlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "listing": { + "name": "listing", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_item_watchlist_idx": { + "name": "watchlist_item_watchlist_idx", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_watchlist_container_sort_idx": { + "name": "watchlist_item_watchlist_container_sort_idx", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "container_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_container_sort_idx": { + "name": "watchlist_item_container_sort_idx", + "columns": [ + { + "expression": "container_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_watchlist_listing_identity_unique": { + "name": "watchlist_item_watchlist_listing_identity_unique", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'listing_type', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'listing_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'base_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'quote_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_item_watchlist_id_watchlist_table_id_fk": { + "name": "watchlist_item_watchlist_id_watchlist_table_id_fk", + "tableFrom": "watchlist_item", + "tableTo": "watchlist_table", + "columnsFrom": [ + "watchlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_item_container_id_watchlist_table_id_fk": { + "name": "watchlist_item_container_id_watchlist_table_id_fk", + "tableFrom": "watchlist_item", + "tableTo": "watchlist_table", + "columnsFrom": [ + "container_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_table": { + "name": "watchlist_table", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_table_workspace_user_idx": { + "name": "watchlist_table_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_workspace_user_parent_idx": { + "name": "watchlist_table_workspace_user_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_parent_sort_idx": { + "name": "watchlist_table_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_workspace_user_name_unique": { + "name": "watchlist_table_workspace_user_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"watchlist_table\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_table_workspace_id_workspace_id_fk": { + "name": "watchlist_table_workspace_id_workspace_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_table_user_id_user_id_fk": { + "name": "watchlist_table_user_id_user_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_table_parent_id_watchlist_table_id_fk": { + "name": "watchlist_table_parent_id_watchlist_table_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "watchlist_table", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": [ + "active", + "pending", + "revoked" + ] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.registration_mode": { + "name": "registration_mode", + "schema": "public", + "values": [ + "open", + "waitlist", + "disabled" + ] + }, + "public.system_service_value_kind": { + "name": "system_service_value_kind", + "schema": "public", + "values": [ + "credential", + "setting" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "signed_up" + ] + }, + "public.order_submission_source": { + "name": "order_submission_source", + "schema": "public", + "values": [ + "manual", + "copilot", + "workflow" + ] + }, + "public.pending_execution_status": { + "name": "pending_execution_status", + "schema": "public", + "values": [ + "pending", + "processing" + ] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": [ + "pending", + "in_progress", + "success", + "failed", + "cancelled" + ] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": [ + "admin", + "write", + "read" + ] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index dc420fd94..84ebeddfb 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1779087104759, "tag": "0034_white_ma_gnuci", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1780432066925, + "tag": "0035_marvelous_avengers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema/auth.ts b/packages/db/schema/auth.ts index 89a640c4a..d238410e1 100644 --- a/packages/db/schema/auth.ts +++ b/packages/db/schema/auth.ts @@ -181,10 +181,3 @@ export const settings = pgTable('settings', { updatedAt: timestamp('updated_at').notNull().defaultNow(), }) - -export const emailRecipientPreference = pgTable('email_recipient_preference', { - email: text('email').primaryKey(), - preferredLocale: text('preferred_locale').notNull().default('en'), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}) diff --git a/packages/db/schema/system.ts b/packages/db/schema/system.ts index 48516ab0d..2cf694328 100644 --- a/packages/db/schema/system.ts +++ b/packages/db/schema/system.ts @@ -413,6 +413,7 @@ export const waitlist = pgTable( }), signedUpAt: timestamp('signed_up_at'), userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + preferredLocale: text('preferred_locale').notNull().default('en'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, From ebc235354fc30c5c97628c6bd745574ac495fdc7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 2 Jun 2026 16:14:42 -0600 Subject: [PATCH 24/49] refactor(i18n): normalize localized copy and template keys Co-authored-by: Codex --- .../layout-preview/layout-preview.tsx | 6 +- .../workflow-preview-demos.ts | 57 +- .../(landing)/components/footer/footer.tsx | 9 +- .../admin/integrations/integrations-admin.tsx | 49 +- .../organizations/[id]/invitations/route.ts | 5 +- .../api/organizations/[id]/members/route.ts | 4 +- .../invitations/[invitationId]/route.ts | 35 +- .../app/api/workspaces/invitations/route.ts | 10 +- .../components/use-keyboard-shortcuts.ts | 2 +- .../monitor/components/view/view-bootstrap.ts | 10 +- apps/tradinggoose/blocks/types.ts | 14 - .../components/emails/email-copy.ts | 18 +- .../tradinggoose/components/emails/footer.tsx | 41 +- .../components/emails/render-email.test.ts | 11 + .../components/emails/render-email.ts | 68 +- .../market-selector/provider-selector.tsx | 4 +- .../trading-selector/account-selector.tsx | 8 +- .../trading-selector/provider-selector.tsx | 6 +- .../global-navbar/components/user-menu.tsx | 13 +- apps/tradinggoose/i18n/block-editor.test.ts | 188 +-- apps/tradinggoose/i18n/block-editor.ts | 43 +- apps/tradinggoose/i18n/messages/en.json | 1177 +++++++++------- apps/tradinggoose/i18n/messages/es.json | 1181 +++++++++------- apps/tradinggoose/i18n/messages/zh.json | 1202 ++++++++++------- apps/tradinggoose/i18n/public-copy.test.ts | 110 +- apps/tradinggoose/i18n/route-boundary.test.ts | 23 - apps/tradinggoose/i18n/route-boundary.ts | 26 - apps/tradinggoose/i18n/utils.test.ts | 24 +- apps/tradinggoose/i18n/utils.ts | 53 +- .../i18n/workflow-inspector-core.ts | 434 +++--- .../lib/auth/auth-error-handler.ts | 9 +- apps/tradinggoose/lib/email/locale.ts | 4 +- apps/tradinggoose/lib/email/mailer.ts | 6 +- apps/tradinggoose/proxy.ts | 31 +- apps/tradinggoose/tools/params.ts | 10 - apps/tradinggoose/vitest.setup.ts | 29 +- .../channel-selector-input.tsx | 6 +- .../components/slack-channel-selector.tsx | 26 +- .../sub-block/components/combobox.tsx | 28 +- .../sub-block/components/condition-input.tsx | 15 +- .../credential-selector.tsx | 20 +- .../document-selector/document-selector.tsx | 36 +- .../document-tag-entry/document-tag-entry.tsx | 16 +- .../components/google-calendar-selector.tsx | 46 +- .../file-selector/file-selector-input.tsx | 40 +- .../sub-block/components/file-upload.tsx | 36 +- .../components/folder-selector-input.tsx | 10 +- .../components/grouped-checkbox-list.tsx | 22 +- .../components/input-format/input-format.tsx | 3 +- .../knowledge-base-selector.tsx | 16 +- .../knowledge-tag-filters.tsx | 25 +- .../components/order-id-selector/dropdown.tsx | 4 +- .../components/jira-project-selector.tsx | 20 +- .../components/linear-project-selector.tsx | 21 +- .../components/linear-team-selector.tsx | 18 +- .../project-selector-input.tsx | 17 +- .../components/schedule/schedule-config.tsx | 4 +- .../components/skill-input/skill-input.tsx | 18 +- .../components/tool-credential-selector.tsx | 34 +- .../components/trigger-save/trigger-save.tsx | 10 +- .../components/sub-block/sub-block.tsx | 7 +- .../widgets/widgets/editor_workflow/copy.ts | 32 +- .../components/skill-list/skill-list.tsx | 6 +- .../output-select/output-select.tsx | 48 +- 64 files changed, 2987 insertions(+), 2517 deletions(-) delete mode 100644 apps/tradinggoose/i18n/route-boundary.test.ts delete mode 100644 apps/tradinggoose/i18n/route-boundary.ts diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx index 81d407123..61658d2c4 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx @@ -4,6 +4,8 @@ import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react' import { useLocale } from 'next-intl' import { Card } from '@/components/ui/card' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import type { LocaleCode } from '@/i18n/utils' import { createDefaultLayoutState, createLayoutNodeId, @@ -11,8 +13,6 @@ import { type WidgetInstance, } from '@/widgets/layout' import { WidgetActionMenu } from '@/widgets/widgets/components/widget-action-menu' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' -import { type LocaleCode } from '@/i18n/utils' const PANEL_MIN_SIZE = 10 const MIN_SPLIT_SIZE = PANEL_MIN_SIZE * 2 @@ -110,7 +110,7 @@ function LayoutPreviewPanelSurface({

- {formatTemplate('{{width}}% {{widthLabel}} · {{height}}% {{heightLabel}}', { + {formatTemplate('{width}% {widthLabel} · {height}% {heightLabel}', { width: Math.round(availableWidth), height: Math.round(availableHeight), widthLabel: layoutCopy.widthLabel, diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-demos.ts b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-demos.ts index d693e621e..e9173609a 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-demos.ts +++ b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-demos.ts @@ -1,15 +1,16 @@ import type { Edge } from '@xyflow/react' +import { buildSubBlockRows } from '@/lib/workflows/sub-block-rows' import { getBlock } from '@/blocks' import { getLocalizedDefaultBlockName, getWorkflowEditorCopy, getWorkflowLabelCopy, + type LocaleCode, localizeWorkflowSubBlockConfig, resolveWorkflowDisplayValue, translateWorkflowLabel, - type LocaleCode, } from '@/i18n/block-editor' -import { buildSubBlockRows } from '@/lib/workflows/sub-block-rows' +import type { PublicCopy } from '@/i18n/public-copy' import type { BlockData, BlockState, @@ -18,7 +19,6 @@ import type { SubBlockState, WorkflowState, } from '@/stores/workflows/workflow/types' -import { type PublicCopy } from '@/i18n/public-copy' import { resolveTriggerIdFromSubBlocks } from '@/triggers/resolution' import { adaptPreviewPayloadToCanvas, @@ -378,7 +378,7 @@ const createConditionBlock = ({ const localizeDefaultName = (locale: LocaleCode, type: string) => getLocalizedDefaultBlockName(locale, type) -const localizeCustomName = (locale: LocaleCode, label: string) => translateWorkflowLabel(locale, label) +const localizeCustomName = (locale: LocaleCode, key: string) => translateWorkflowLabel(locale, key) function buildLocalizedPreviewPayload( locale: LocaleCode, @@ -408,7 +408,10 @@ function buildLocalizedPreviewPayload( } const previewStateRaw = node.data.subBlockValues ?? node.data.blockState?.subBlocks ?? {} - const triggerId = resolveTriggerIdFromSubBlocks(previewStateRaw, blockConfig.triggers?.available) + const triggerId = resolveTriggerIdFromSubBlocks( + previewStateRaw, + blockConfig.triggers?.available + ) const localizedSubBlocks = (blockConfig.subBlocks || []).map((subBlock) => localizeWorkflowSubBlockConfig(locale, subBlock, node.data.type, triggerId ?? undefined) ) @@ -467,11 +470,15 @@ function buildAnalystCoverageState( trigger: createBlock({ id: 'trigger', type: 'indicator_trigger', - name: localizeCustomName(locale, 'Indicator Monitor'), + name: localizeCustomName(locale, 'indicatorMonitor'), position: { x: 150, y: 234 }, height: 132, subBlocks: { - triggerInstructions: createSubBlock('triggerInstructions', 'text', copy.triggerInstructions), + triggerInstructions: createSubBlock( + 'triggerInstructions', + 'text', + copy.triggerInstructions + ), }, }), marketData: createHistoricalDataBlock({ @@ -494,7 +501,7 @@ function buildAnalystCoverageState( }), marketAnalyst: createAgentBlock({ id: 'marketAnalyst', - name: localizeCustomName(locale, 'Market Analyst'), + name: localizeCustomName(locale, 'marketAnalyst'), position: { x: 1570, y: 184 }, systemPrompt: copy.marketAnalystSystemPrompt, userPrompt: copy.marketAnalystUserPrompt, @@ -567,7 +574,7 @@ function buildInvestmentDebateState( }), analystDossier: createAgentBlock({ id: 'analystDossier', - name: localizeCustomName(locale, 'Analyst Dossier'), + name: localizeCustomName(locale, 'analystDossier'), position: { x: 860, y: 191 }, systemPrompt: copy.analystDossierSystemPrompt, userPrompt: copy.analystDossierUserPrompt, @@ -577,13 +584,13 @@ function buildInvestmentDebateState( }), investmentDebate: createLoopBlock({ id: 'investmentDebate', - name: localizeCustomName(locale, 'Bull vs Bear Debate'), + name: localizeCustomName(locale, 'bullVsBearDebate'), position: { x: 1215, y: 150 }, size: { width: 951.75, height: 741 }, }), bullResearcher: createAgentBlock({ id: 'bullResearcher', - name: localizeCustomName(locale, 'Bull Researcher'), + name: localizeCustomName(locale, 'bullResearcher'), position: { x: 180, y: 100 }, parentId: 'investmentDebate', systemPrompt: copy.bullResearcherSystemPrompt, @@ -594,7 +601,7 @@ function buildInvestmentDebateState( }), bearResearcher: createAgentBlock({ id: 'bearResearcher', - name: localizeCustomName(locale, 'Bear Researcher'), + name: localizeCustomName(locale, 'bearResearcher'), position: { x: 481.75, y: 323 }, parentId: 'investmentDebate', systemPrompt: copy.bearResearcherSystemPrompt, @@ -605,7 +612,7 @@ function buildInvestmentDebateState( }), researchManager: createAgentBlock({ id: 'researchManager', - name: localizeCustomName(locale, 'Research Manager'), + name: localizeCustomName(locale, 'researchManager'), position: { x: 2171.75, y: 187 }, systemPrompt: copy.researchManagerSystemPrompt, userPrompt: copy.researchManagerUserPrompt, @@ -710,7 +717,7 @@ function buildRiskRoutingState( }), traderProposal: createAgentBlock({ id: 'traderProposal', - name: localizeCustomName(locale, 'Trader Proposal'), + name: localizeCustomName(locale, 'traderProposal'), position: { x: 860, y: 191 }, systemPrompt: copy.traderProposalSystemPrompt, userPrompt: copy.traderProposalUserPrompt, @@ -720,13 +727,13 @@ function buildRiskRoutingState( }), riskCommittee: createLoopBlock({ id: 'riskCommittee', - name: localizeCustomName(locale, 'Risk Committee'), + name: localizeCustomName(locale, 'riskCommittee'), position: { x: 1215, y: 150 }, size: { width: 1253.5, height: 952 }, }), aggressiveAnalyst: createAgentBlock({ id: 'aggressiveAnalyst', - name: localizeCustomName(locale, 'Aggressive Analyst'), + name: localizeCustomName(locale, 'aggressiveAnalyst'), position: { x: 180, y: 100 }, parentId: 'riskCommittee', systemPrompt: copy.aggressiveAnalystSystemPrompt, @@ -737,7 +744,7 @@ function buildRiskRoutingState( }), conservativeAnalyst: createAgentBlock({ id: 'conservativeAnalyst', - name: localizeCustomName(locale, 'Conservative Analyst'), + name: localizeCustomName(locale, 'conservativeAnalyst'), position: { x: 481.75, y: 319 }, parentId: 'riskCommittee', systemPrompt: copy.conservativeAnalystSystemPrompt, @@ -748,7 +755,7 @@ function buildRiskRoutingState( }), neutralAnalyst: createAgentBlock({ id: 'neutralAnalyst', - name: localizeCustomName(locale, 'Neutral Analyst'), + name: localizeCustomName(locale, 'neutralAnalyst'), position: { x: 783.5, y: 538 }, parentId: 'riskCommittee', systemPrompt: copy.neutralAnalystSystemPrompt, @@ -759,7 +766,7 @@ function buildRiskRoutingState( }), portfolioManager: createAgentBlock({ id: 'portfolioManager', - name: localizeCustomName(locale, 'Portfolio Manager'), + name: localizeCustomName(locale, 'portfolioManager'), position: { x: 2473.5, y: 187 }, systemPrompt: copy.portfolioManagerSystemPrompt, userPrompt: copy.portfolioManagerUserPrompt, @@ -770,7 +777,7 @@ function buildRiskRoutingState( }), decisionRouter: createConditionBlock({ id: 'decisionRouter', - name: localizeCustomName(locale, 'Decision Router'), + name: localizeCustomName(locale, 'decisionRouter'), position: { x: 2828.5, y: 214 }, conditions: [ { @@ -789,7 +796,7 @@ function buildRiskRoutingState( }), increasePosition: createTradingActionBlock({ id: 'increasePosition', - name: localizeCustomName(locale, 'Increase Position'), + name: localizeCustomName(locale, 'increasePosition'), position: { x: 3183.5, y: 150 }, side: 'buy', listing: 'NVDA', @@ -807,7 +814,7 @@ function buildRiskRoutingState( }), reduceExposure: createTradingActionBlock({ id: 'reduceExposure', - name: localizeCustomName(locale, 'Reduce Exposure'), + name: localizeCustomName(locale, 'reduceExposure'), position: { x: 3183.5, y: 536 }, side: 'sell', listing: 'NVDA', @@ -896,7 +903,7 @@ export function buildTradingAgentWorkflowDemos( return [ { id: 'analyst-coverage', - name: localizeCustomName(locale, 'Signal Briefing'), + name: localizeCustomName(locale, 'signalBriefing'), color: '#0f766e', previewPayload: buildLocalizedPreviewPayload( locale, @@ -905,7 +912,7 @@ export function buildTradingAgentWorkflowDemos( }, { id: 'investment-debate', - name: localizeCustomName(locale, 'Investment Debate'), + name: localizeCustomName(locale, 'investmentDebate'), color: '#2563eb', previewPayload: buildLocalizedPreviewPayload( locale, @@ -914,7 +921,7 @@ export function buildTradingAgentWorkflowDemos( }, { id: 'risk-routing', - name: localizeCustomName(locale, 'Risk Routing'), + name: localizeCustomName(locale, 'riskRouting'), color: '#dc2626', previewPayload: buildLocalizedPreviewPayload( locale, diff --git a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx index 343e1b088..dcc2e953e 100644 --- a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx +++ b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx @@ -6,7 +6,7 @@ import FooterHoverText from '@/app/(landing)/components/footer/footer-hover-text import { soehne } from '@/app/fonts/soehne/soehne' import { Link } from '@/i18n/navigation' import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' -import { localizeDocsUrl, type LocaleCode } from '@/i18n/utils' +import { type LocaleCode, localizeDocsUrl } from '@/i18n/utils' type FooterLinkKey = | 'docs' @@ -108,9 +108,10 @@ export default async function Footer({ fullWidth = false }: FooterProps) {

- {copy.landing.footer.copyright - .replace('{{year}}', String(new Date().getFullYear())) - .replace('{{brand}}', brand.name)} + {formatTemplate(copy.landing.footer.copyright, { + year: new Date().getFullYear(), + brand: brand.name, + })}

diff --git a/apps/tradinggoose/app/admin/integrations/integrations-admin.tsx b/apps/tradinggoose/app/admin/integrations/integrations-admin.tsx index 4ebaeef42..898f0b22c 100644 --- a/apps/tradinggoose/app/admin/integrations/integrations-admin.tsx +++ b/apps/tradinggoose/app/admin/integrations/integrations-admin.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react' +import { useLocale } from 'next-intl' import { Alert, AlertDescription, @@ -168,9 +168,7 @@ export function AdminIntegrations() { ) : null} - - {copy.info} - + {copy.info} {!draft && integrationsQuery.isPending ? ( @@ -232,14 +230,19 @@ export function AdminIntegrations() { {formatTemplate(copy.summary.serviceCount, { count: bundleServices.length, - plural: bundleServices.length === 1 ? '' : copy.summary.servicePlural, + plural: + bundleServices.length === 1 + ? '' + : copy.summary.servicePlural, })} - {summary.status === 'ready' ? copy.status.ready : copy.status.review} + {summary.status === 'ready' + ? copy.status.ready + : copy.status.review}

@@ -264,22 +267,22 @@ export function AdminIntegrations() {

-
-

{copy.credentials.title}

-

- {copy.credentials.description} -

-
+
+

{copy.credentials.title}

+

+ {copy.credentials.description} +

+
- {secretFields.length === 0 ? ( -

- {copy.credentials.none} -

- ) : visibleSecretFields.length === 0 ? ( -

- {copy.credentials.noMatches} -

- ) : ( + {secretFields.length === 0 ? ( +

+ {copy.credentials.none} +

+ ) : visibleSecretFields.length === 0 ? ( +

+ {copy.credentials.noMatches} +

+ ) : (
{visibleSecretFields.map((secret) => { const credentialField = getCredentialFieldConfig( @@ -326,7 +329,7 @@ export function AdminIntegrations() {
-
+

{copy.services.title}

{copy.services.description} @@ -583,7 +586,7 @@ function getCredentialFieldConfig( .filter(Boolean) .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) .join(' '), - note: copy.credentials.fallbackDescription, + note: copy.credentials.defaultDescription, placeholder: formatTemplate(copy.placeholders.enterValue, { label: credentialKey.replaceAll('_', ' '), }), diff --git a/apps/tradinggoose/app/api/organizations/[id]/invitations/route.ts b/apps/tradinggoose/app/api/organizations/[id]/invitations/route.ts index 56fe6d4a2..6a549b337 100644 --- a/apps/tradinggoose/app/api/organizations/[id]/invitations/route.ts +++ b/apps/tradinggoose/app/api/organizations/[id]/invitations/route.ts @@ -27,6 +27,7 @@ import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' const logger = createLogger('OrganizationInvitations') @@ -345,7 +346,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ organizationEntry[0]?.name || 'organization', role, workspaceInvitationsWithNames, - `${getBaseUrl()}/invite/${orgInvitation.id}`, + localizeUrl(getBaseUrl(), emailLocale, `/invite/${orgInvitation.id}`), emailLocale ) @@ -361,7 +362,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/${orgInvitation.id}`, + localizeUrl(getBaseUrl(), emailLocale, `/invite/${orgInvitation.id}`), email, emailLocale ) diff --git a/apps/tradinggoose/app/api/organizations/[id]/members/route.ts b/apps/tradinggoose/app/api/organizations/[id]/members/route.ts index fdb21de69..2005e15a3 100644 --- a/apps/tradinggoose/app/api/organizations/[id]/members/route.ts +++ b/apps/tradinggoose/app/api/organizations/[id]/members/route.ts @@ -13,6 +13,7 @@ import { sendEmail } from '@/lib/email/mailer' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' const logger = createLogger('OrganizationMembersAPI') @@ -296,11 +297,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ email: normalizedEmail, fallbackLocale: requestLocale, }) + const invitationLink = `${localizeUrl(getBaseUrl(), locale, '/invite/organization')}?id=${invitationId}` const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/organization?id=${invitationId}`, + invitationLink, normalizedEmail, locale ) diff --git a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts index 8e42ff8cf..fabec2ae6 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts @@ -9,16 +9,12 @@ import { } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - getEmailSubject, - renderWorkspaceInvitationEmail, -} from '@/components/emails/render-email' +import { getEmailSubject, renderWorkspaceInvitationEmail } from '@/components/emails/render-email' import { getSession } from '@/lib/auth' import { resolveEmailLocale } from '@/lib/email/locale' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' -import { getRouteBoundaryUrl } from '@/i18n/route-boundary' -import { defaultLocale, stripLocaleFromPathname } from '@/i18n/utils' +import { defaultLocale, localizeUrl, stripLocaleFromPathname } from '@/i18n/utils' function getRedirectLocale(req: NextRequest) { const referer = req.headers.get('referer') @@ -43,7 +39,7 @@ export async function GET( const token = req.nextUrl.searchParams.get('token') const isAcceptFlow = !!token // If token is provided, this is an acceptance flow const redirectUrl = (href: string) => - new URL(getRouteBoundaryUrl(getBaseUrl(), getRedirectLocale(req), href)) + new URL(localizeUrl(getBaseUrl(), getRedirectLocale(req), href)) if (!session?.user?.id) { // For token-based acceptance flows, redirect to login @@ -66,9 +62,7 @@ export async function GET( if (!invitation) { if (isAcceptFlow) { - return NextResponse.redirect( - redirectUrl(`/invite/${invitationId}?error=invalid-token`) - ) + return NextResponse.redirect(redirectUrl(`/invite/${invitationId}?error=invalid-token`)) } return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) } @@ -112,17 +106,13 @@ export async function GET( .then((rows) => rows[0]) if (!userData) { - return NextResponse.redirect( - redirectUrl(`/invite/${invitation.id}?error=user-not-found`) - ) + return NextResponse.redirect(redirectUrl(`/invite/${invitation.id}?error=user-not-found`)) } const isValidMatch = userEmail === invitationEmail if (!isValidMatch) { - return NextResponse.redirect( - redirectUrl(`/invite/${invitation.id}?error=email-mismatch`) - ) + return NextResponse.redirect(redirectUrl(`/invite/${invitation.id}?error=email-mismatch`)) } const existingPermission = await db @@ -146,9 +136,7 @@ export async function GET( }) .where(eq(workspaceInvitation.id, invitation.id)) - return NextResponse.redirect( - redirectUrl(`/workspace/${invitation.workspaceId}/dashboard`) - ) + return NextResponse.redirect(redirectUrl(`/workspace/${invitation.workspaceId}/dashboard`)) } await db.transaction(async (tx) => { @@ -171,9 +159,7 @@ export async function GET( .where(eq(workspaceInvitation.id, invitation.id)) }) - return NextResponse.redirect( - redirectUrl(`/workspace/${invitation.workspaceId}/dashboard`) - ) + return NextResponse.redirect(redirectUrl(`/workspace/${invitation.workspaceId}/dashboard`)) } return NextResponse.json({ @@ -285,11 +271,10 @@ export async function POST( .set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() }) .where(eq(workspaceInvitation.id, invitationId)) - const baseUrl = getBaseUrl() - const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}` - const [{ sendEmail }] = await Promise.all([import('@/lib/email/mailer')]) const locale = await resolveEmailLocale({ email: invitation.email }) + const baseUrl = getBaseUrl() + const invitationLink = `${localizeUrl(baseUrl, locale, `/invite/${invitationId}`)}?token=${newToken}` const emailHtml = await renderWorkspaceInvitationEmail({ workspaceName: ws.name, diff --git a/apps/tradinggoose/app/api/workspaces/invitations/route.ts b/apps/tradinggoose/app/api/workspaces/invitations/route.ts index d78ec58f2..ff8a3f749 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/route.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/route.ts @@ -10,15 +10,13 @@ import { } from '@tradinggoose/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - getEmailSubject, - renderWorkspaceInvitationEmail, -} from '@/components/emails/render-email' +import { getEmailSubject, renderWorkspaceInvitationEmail } from '@/components/emails/render-email' import { getSession } from '@/lib/auth' import { resolveEmailLocale } from '@/lib/email/locale' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' export const dynamic = 'force-dynamic' @@ -237,10 +235,10 @@ async function sendInvitationEmail({ fallbackLocale?: string | null }) { try { + const locale = await resolveEmailLocale({ email: to, fallbackLocale }) const baseUrl = getBaseUrl() // Use invitation ID in path, token in query parameter for security - const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}` - const locale = await resolveEmailLocale({ email: to, fallbackLocale }) + const invitationLink = `${localizeUrl(baseUrl, locale, `/invite/${invitationId}`)}?token=${token}` const emailHtml = await renderWorkspaceInvitationEmail({ workspaceName, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts b/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts index 3b45b4ab2..54a799864 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts @@ -79,7 +79,7 @@ export function useGlobalShortcuts() { if (workspaceIndex !== -1 && pathParts[workspaceIndex + 1]) { const workspaceId = pathParts[workspaceIndex + 1] - router.push(`/workspace/${workspaceId}/logs`) + router.push(`/workspace/${workspaceId}/records`) } else { router.push('/workspace') } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap.ts index 1fc0678d5..70af6a70d 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap.ts @@ -1,3 +1,4 @@ +import { formatTemplate } from '@/i18n/utils' import { isUnsupportedMonitorViewDataError } from '../data/api' import { type CreateMonitorViewBody, @@ -44,7 +45,7 @@ const getRowsForMode = (rows: MonitorViewRow[], mode: MonitorPageMode) => rows.filter((row) => row.mode === mode) const DEFAULT_COPY = { - createDefaultView: 'Unable to create default {{name}} view.', + createDefaultView: 'Unable to create default {name} view.', invalidViewResponse: 'Invalid monitor view response', loadViews: 'Unable to load monitor views.', } as const @@ -138,10 +139,9 @@ export const bootstrapMonitorViews = async ({ rowStateByMode[mode] = 'error' errorsByMode[mode] = getErrorMessage( error, - (copy?.createDefaultView ?? DEFAULT_COPY.createDefaultView).replace( - '{{name}}', - resolveDefaultViewName(mode, defaultViewNames) - ) + formatTemplate(copy?.createDefaultView ?? DEFAULT_COPY.createDefaultView, { + name: resolveDefaultViewName(mode, defaultViewNames), + }) ) if (isUnsupportedMonitorViewDataError(error)) { errorsByMode[mode] = getErrorMessage(error, errorsByMode[mode]!) diff --git a/apps/tradinggoose/blocks/types.ts b/apps/tradinggoose/blocks/types.ts index d30d46f55..18d8e8f38 100644 --- a/apps/tradinggoose/blocks/types.ts +++ b/apps/tradinggoose/blocks/types.ts @@ -147,12 +147,6 @@ export interface SubBlockOption { observesDst?: boolean searchLabel?: string rightLabel?: string - i18n?: { - labelKey?: string - groupKey?: string - searchLabelKey?: string - rightLabelKey?: string - } } export interface SubBlockConfig { @@ -180,14 +174,6 @@ export interface SubBlockConfig { max?: number columns?: string[] placeholder?: string - i18n?: { - titleKey?: string - placeholderKey?: string - searchPlaceholderKey?: string - descriptionKey?: string - tooltipKey?: string - columnKeys?: string[] - } format?: TimeFormat timezone?: string clearable?: boolean diff --git a/apps/tradinggoose/components/emails/email-copy.ts b/apps/tradinggoose/components/emails/email-copy.ts index 29d31c095..ee7706aa6 100644 --- a/apps/tradinggoose/components/emails/email-copy.ts +++ b/apps/tradinggoose/components/emails/email-copy.ts @@ -1,18 +1,22 @@ -import { getPublicCopy, formatTemplate } from '@/i18n/public-copy' -import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleInput, normalizeLocaleCode } from '@/i18n/utils' -export type EmailLocale = LocaleCode | string | null | undefined +export type EmailLocale = LocaleInput -export function normalizeEmailTemplateLocale(locale: EmailLocale): LocaleCode { - return locale && isLocaleCode(locale) ? locale : defaultLocale +export function normalizeEmailTemplateLocale(locale: EmailLocale) { + return normalizeLocaleCode(locale) } export function getEmailCopy(locale: EmailLocale) { return getPublicCopy(normalizeEmailTemplateLocale(locale)).emails } -export function emailText(template: string, values: Record) { - return formatTemplate(template, values) +export function emailText( + locale: EmailLocale, + template: string, + values: Record +) { + return formatTemplate(template, values, normalizeEmailTemplateLocale(locale)) } export function formatEmailDate(locale: EmailLocale, date: Date) { diff --git a/apps/tradinggoose/components/emails/footer.tsx b/apps/tradinggoose/components/emails/footer.tsx index f1d1e7102..e28691459 100644 --- a/apps/tradinggoose/components/emails/footer.tsx +++ b/apps/tradinggoose/components/emails/footer.tsx @@ -1,15 +1,15 @@ -import * as React from 'react' import { Container, Img, Link, Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/base-styles' -import { getBrandConfig } from '@/lib/branding/branding' -import { isHosted } from '@/lib/environment' -import { getBaseUrl } from '@/lib/urls/utils' import { type EmailLocale, emailText, getEmailCopy, normalizeEmailTemplateLocale, } from '@/components/emails/email-copy' +import { getBrandConfig } from '@/lib/branding/branding' +import { isHosted } from '@/lib/environment' +import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' interface UnsubscribeOptions { unsubscribeToken?: string @@ -27,6 +27,12 @@ export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe, locale }: Ema const copy = getEmailCopy(resolvedLocale) const brand = getBrandConfig() const year = new Date().getFullYear() + const privacyUrl = localizeUrl(baseUrl, resolvedLocale, '/privacy') + const termsUrl = localizeUrl(baseUrl, resolvedLocale, '/terms') + const unsubscribeUrl = + unsubscribe?.unsubscribeToken && unsubscribe?.email + ? `${localizeUrl(baseUrl, resolvedLocale, '/unsubscribe')}?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}` + : '{{{RESEND_UNSUBSCRIBE_URL}}}' return ( @@ -50,7 +56,10 @@ export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe, locale }: Ema - + - @@ -75,7 +83,10 @@ export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe, locale }: Ema color: '#7c8299', }} > - {emailText(copy.footer.copyright, { year, brandName: brand.name })} + {emailText(resolvedLocale, copy.footer.copyright, { + year, + brandName: brand.name, + })}
{copy.footer.questions}{' '}
)} - +
- - - + + + ) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx index a1fb556d6..0f2c5e0e5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { GoogleCalendarIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -57,21 +57,15 @@ export function GoogleCalendarSelector({ const locale = useLocale() as LocaleCode const selectorCopy = useAppMessages().workspace.widgets.blockEditor.googleCalendarSelector const copy = { - selectGoogleCalendar: translateWorkflowLabel(locale, 'Select Google Calendar'), - searchCalendars: translateWorkflowLabel(locale, 'Search calendars...'), - loadingCalendars: translateWorkflowLabel(locale, 'Loading calendars...'), - noCalendarsFound: translateWorkflowLabel(locale, 'No calendars found'), - noMatchingCalendars: translateWorkflowLabel(locale, 'No matching calendars'), - calendars: translateWorkflowLabel(locale, 'Calendars'), - primary: translateWorkflowLabel(locale, 'Primary'), - googleCalendarAccountRequired: translateWorkflowLabel( - locale, - 'Google Calendar account is required' - ), - failedToFetchGoogleCalendars: translateWorkflowLabel( - locale, - 'Failed to fetch Google Calendar calendars' - ), + selectGoogleCalendar: translateWorkflowLabel(locale, 'selectGoogleCalendar'), + searchCalendars: translateWorkflowLabel(locale, 'searchCalendars'), + loadingCalendars: translateWorkflowLabel(locale, 'loadingCalendars'), + noCalendarsFound: translateWorkflowLabel(locale, 'noCalendarsFound'), + noMatchingCalendars: translateWorkflowLabel(locale, 'noMatchingCalendars'), + calendars: translateWorkflowLabel(locale, 'calendars'), + primary: translateWorkflowLabel(locale, 'primary'), + googleCalendarAccountRequired: translateWorkflowLabel(locale, 'googleCalendarAccountRequired'), + failedToFetchGoogleCalendars: translateWorkflowLabel(locale, 'failedToFetchGoogleCalendars'), } type GoogleCalendarSelectorErrorCode = keyof typeof selectorCopy.errors const labelText = label ?? copy.selectGoogleCalendar @@ -280,17 +274,17 @@ export function GoogleCalendarSelector({ {copy.loadingCalendars} - ) : error ? ( -
-

{selectorCopy.errors[error]}

-
- ) : calendars.length === 0 ? ( -
-

{copy.noCalendarsFound}

-

+ ) : error ? ( +

+

{selectorCopy.errors[error]}

+
+ ) : calendars.length === 0 ? ( +
+

{copy.noCalendarsFound}

+

{selectorCopy.emptyStateDescription} -

-
+

+
) : (

{copy.noMatchingCalendars}

diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index ed3ab1aee..fcc90fa71 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -4,8 +4,9 @@ import { useLocale } from 'next-intl' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { getProviderIdFromServiceId } from '@/lib/oauth' import type { SubBlockConfig } from '@/blocks/types' -import { translateWorkflowLabel } from '@/i18n/block-editor' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' +import { translateWorkflowLabel } from '@/i18n/block-editor' +import type { LocaleCode } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { @@ -21,7 +22,6 @@ import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/w import { useForeignCredential } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import type { LocaleCode } from '@/i18n/utils' interface FileSelectorInputProps { blockId: string @@ -103,7 +103,7 @@ export function FileSelectorInput({ const isWealthbox = provider === 'wealthbox' const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint' const isMicrosoftPlanner = provider === 'microsoft-planner' - const t = (label: string) => translateWorkflowLabel(locale, label) + const t = (key: string) => translateWorkflowLabel(locale, key) // For Confluence and Jira, we need the domain and credentials const domain = @@ -128,7 +128,7 @@ export function FileSelectorInput({ onChange={(val) => { collaborativeSetSubblockValue(blockId, subBlock.id, val) }} - label={subBlock.placeholder || t('Select Google Calendar')} + label={subBlock.placeholder || t('selectGoogleCalendar')} disabled={finalDisabled} showPreview={true} credentialId={credentialId} @@ -159,7 +159,7 @@ export function FileSelectorInput({ provider='confluence' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select Confluence page')} + label={subBlock.placeholder || t('selectConfluencePage')} disabled={finalDisabled} showPreview={true} credentialId={credentialId} @@ -190,7 +190,7 @@ export function FileSelectorInput({ provider='jira' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select Jira issue')} + label={subBlock.placeholder || t('selectJiraIssue')} disabled={finalDisabled} showPreview={true} credentialId={credentialId} @@ -219,7 +219,7 @@ export function FileSelectorInput({ provider='microsoft-excel' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select Microsoft Excel file')} + label={subBlock.placeholder || t('selectMicrosoftExcelFile')} disabled={finalDisabled} showPreview={true} workflowId={workflowIdFromUrl} @@ -247,7 +247,7 @@ export function FileSelectorInput({ provider='microsoft-word' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select Microsoft Word document')} + label={subBlock.placeholder || t('selectMicrosoftWordDocument')} disabled={finalDisabled} showPreview={true} workflowId={workflowIdFromUrl} @@ -274,7 +274,7 @@ export function FileSelectorInput({ provider='microsoft' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select OneDrive folder')} + label={subBlock.placeholder || t('selectOneDriveFolder')} disabled={finalDisabled} showPreview={true} workflowId={workflowIdFromUrl} @@ -303,7 +303,7 @@ export function FileSelectorInput({ provider='microsoft' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select SharePoint site')} + label={subBlock.placeholder || t('selectSharePointSite')} disabled={finalDisabled} showPreview={true} workflowId={workflowIdFromUrl} @@ -315,7 +315,7 @@ export function FileSelectorInput({ {!credentialId && ( -

{t('Please select SharePoint credentials first')}

+

{t('pleaseSelectSharePointCredentialsFirst')}

)} @@ -338,7 +338,7 @@ export function FileSelectorInput({ provider='microsoft-planner' requiredScopes={subBlock.requiredScopes || []} serviceId='microsoft-planner' - label={subBlock.placeholder || t('Select task')} + label={subBlock.placeholder || t('selectTask')} disabled={finalDisabled} showPreview={true} planId={planId} @@ -351,11 +351,11 @@ export function FileSelectorInput({ {!credentialId ? ( -

{t('Please select Microsoft Planner credentials first')}

+

{t('pleaseSelectMicrosoftPlannerCredentialsFirst')}

) : !planId ? ( -

{t('Please enter a Plan ID first')}

+

{t('pleaseEnterAPlanIdFirst')}

) : null} @@ -393,7 +393,7 @@ export function FileSelectorInput({ provider='microsoft-teams' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select Teams message location')} + label={subBlock.placeholder || t('selectTeamsMessageLocation')} disabled={finalDisabled} showPreview={true} credentialId={credentialId} @@ -407,7 +407,7 @@ export function FileSelectorInput({ {!credentialId && ( -

{t('Please select Microsoft Teams credentials first')}

+

{t('pleaseSelectMicrosoftTeamsCredentialsFirst')}

)} @@ -433,7 +433,7 @@ export function FileSelectorInput({ provider='wealthbox' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || t('Select contact')} + label={subBlock.placeholder || t('selectContact')} disabled={finalDisabled} showPreview={true} credentialId={credential} @@ -445,7 +445,7 @@ export function FileSelectorInput({ {!credential && ( -

{t('Please select Wealthbox credentials first')}

+

{t('pleaseSelectWealthboxCredentialsFirst')}

)} @@ -474,7 +474,7 @@ export function FileSelectorInput({ }} provider={provider} requiredScopes={subBlock.requiredScopes || []} - label={subBlock.placeholder || t('Select file')} + label={subBlock.placeholder || t('selectFile')} disabled={finalDisabled} serviceId={subBlock.serviceId} mimeTypeFilter={subBlock.mimeType} @@ -487,7 +487,7 @@ export function FileSelectorInput({ {!credential && ( -

{t('Please select Google Drive credentials first')}

+

{t('pleaseSelectGoogleDriveCredentialsFirst')}

)} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx index 91dce64e5..011ec66ac 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx @@ -1,8 +1,8 @@ 'use client' import { useRef, useState } from 'react' -import { useLocale } from 'next-intl' import { ChevronDown, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Command, CommandEmpty, @@ -17,9 +17,9 @@ import { import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import { createLogger } from '@/lib/logs/console/logger' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { translateWorkflowLabel } from '@/i18n/block-editor' import { formatFileSize as formatLocalizedFileSize } from '@/i18n/formatters' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { LocaleCode } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' @@ -577,7 +577,7 @@ export function FileUpload({ disabled={disabled || loadingWorkspaceFiles} > - {translateWorkflowLabel(locale, 'Add More')} + {translateWorkflowLabel(locale, 'addMore')} @@ -585,7 +585,7 @@ export function FileUpload({ e.stopPropagation()}> @@ -600,16 +600,16 @@ export function FileUpload({ } as React.MouseEvent) }} > - {translateWorkflowLabel(locale, 'Upload New File')} + {translateWorkflowLabel(locale, 'uploadNewFile')} {availableWorkspaceFiles.length === 0 - ? translateWorkflowLabel(locale, 'No files available.') - : translateWorkflowLabel(locale, 'No files found.')} + ? translateWorkflowLabel(locale, 'noFilesAvailable') + : translateWorkflowLabel(locale, 'noFilesFound')} - {availableWorkspaceFiles.length > 0 && ( - + {availableWorkspaceFiles.length > 0 && ( + {availableWorkspaceFiles.map((file) => ( {loadingWorkspaceFiles - ? translateWorkflowLabel(locale, 'Loading files...') - : translateWorkflowLabel(locale, 'Select or upload file')} + ? translateWorkflowLabel(locale, 'loadingFiles') + : translateWorkflowLabel(locale, 'selectOrUploadFile')} - - + e.stopPropagation()}> @@ -678,16 +678,16 @@ export function FileUpload({ } as React.MouseEvent) }} > - {translateWorkflowLabel(locale, 'Upload New File')} + {translateWorkflowLabel(locale, 'uploadNewFile')} {availableWorkspaceFiles.length === 0 - ? translateWorkflowLabel(locale, 'No files available.') - : translateWorkflowLabel(locale, 'No files found.')} + ? translateWorkflowLabel(locale, 'noFilesAvailable') + : translateWorkflowLabel(locale, 'noFilesFound')} {availableWorkspaceFiles.length > 0 && ( - + {availableWorkspaceFiles.map((file) => ( = {} options.forEach((option) => { - const groupName = option.group || translateWorkflowLabel('Other') + const groupName = option.group || translateWorkflowLabel('other') if (!groups[groupName]) { groups[groupName] = [] } @@ -76,16 +76,16 @@ export function GroupedCheckboxList({ if (noneSelected) { return ( - {translateWorkflowLabel('None selected')} + {translateWorkflowLabel('noneSelected')} ) } if (allSelected) { - return {translateWorkflowLabel('All selected')} + return {translateWorkflowLabel('allSelected')} } return ( - {formatWorkflowTemplate(translateWorkflowLabel('{{count}} selected'), { + {formatWorkflowTemplate(translateWorkflowLabel('selectedCount'), { count: selectedValues.length, })} @@ -102,7 +102,7 @@ export function GroupedCheckboxList({ > - {translateWorkflowLabel('Configure PII Types')} + {translateWorkflowLabel('configurePiiTypes')} @@ -112,11 +112,9 @@ export function GroupedCheckboxList({ onWheel={(e) => e.stopPropagation()} > - {translateWorkflowLabel('Select PII Types to Detect')} + {translateWorkflowLabel('selectPiiTypesToDetect')}

- {translateWorkflowLabel( - 'Choose which types of personally identifiable information to detect and block.' - )} + {translateWorkflowLabel('choosePiiTypesToDetect')}

@@ -139,7 +137,7 @@ export function GroupedCheckboxList({ htmlFor='select-all' className='cursor-pointer font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' > - {translateWorkflowLabel('Select all entities')} + {translateWorkflowLabel('selectAllEntities')}
diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/input-format/input-format.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/input-format/input-format.tsx index 011c3bca6..7a13932e2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/input-format/input-format.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/input-format/input-format.tsx @@ -24,6 +24,7 @@ import { LISTING_IDENTITY_VALUE_TYPE, type ListingInputValue } from '@/lib/listi import { cn } from '@/lib/utils' import type { WorkflowFieldType } from '@/lib/workflows/value-types' import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' +import { formatTemplate } from '@/i18n/client-messages' import { ListingSelectorInput } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkflowBlockEditorCopy } from '@/widgets/widgets/editor_workflow/copy' @@ -111,7 +112,7 @@ export function FieldFormat({ const value = isPreview ? previewValue : storeValue const fields: Field[] = Array.isArray(value) ? value : [] - const formatAddTitle = (label: string) => copy.addTitle.replace('{{title}}', label) + const formatAddTitle = (label: string) => formatTemplate(copy.addTitle, { title: label }) const getFieldTypeLabel = (fieldType?: FieldType) => { switch (fieldType) { case 'string': diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index e8c5164f4..a5a15626b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { PackageSearchIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -14,9 +14,9 @@ import { CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { translateWorkflowLabel } from '@/i18n/block-editor' import type { SubBlockConfig } from '@/blocks/types' import { fetchKnowledgeBases as fetchWorkspaceKnowledgeBases } from '@/hooks/queries/knowledge' +import { translateWorkflowLabel } from '@/i18n/block-editor' import { formatTemplate, useAppMessages } from '@/i18n/client-messages' import type { LocaleCode } from '@/i18n/utils' import type { KnowledgeBaseData } from '@/stores/knowledge/store' @@ -40,9 +40,9 @@ export function KnowledgeBaseSelector({ const selectorCopy = useAppMessages().workspace.widgets.blockEditor.knowledgeBaseSelector const workspaceId = useWorkspaceId() const copy = { - searchKnowledgeBases: translateWorkflowLabel(locale, 'Search knowledge bases...'), - loadingKnowledgeBases: translateWorkflowLabel(locale, 'Loading knowledge bases...'), - noKnowledgeBasesFound: translateWorkflowLabel(locale, 'No knowledge bases found'), + searchKnowledgeBases: translateWorkflowLabel(locale, 'searchKnowledgeBases'), + loadingKnowledgeBases: translateWorkflowLabel(locale, 'loadingKnowledgeBases'), + noKnowledgeBasesFound: translateWorkflowLabel(locale, 'noKnowledgeBasesFound'), } type KnowledgeBaseSelectorErrorCode = keyof typeof selectorCopy.errors @@ -178,7 +178,7 @@ export function KnowledgeBaseSelector({ : translateWorkflowLabel(locale, 'documents') return `${docCount} ${documentLabel}` } - return knowledgeBase.description || translateWorkflowLabel(locale, 'No description') + return knowledgeBase.description || translateWorkflowLabel(locale, 'noDescription') } const isKnowledgeBaseSelected = (knowledgeBaseId: string) => { @@ -188,8 +188,8 @@ export function KnowledgeBaseSelector({ const label = subBlock.placeholder || (isMultiSelect - ? translateWorkflowLabel(locale, 'Select knowledge bases') - : translateWorkflowLabel(locale, 'Select knowledge base')) + ? translateWorkflowLabel(locale, 'selectKnowledgeBases') + : translateWorkflowLabel(locale, 'selectKnowledgeBase')) return (
diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index b12999fe3..ffe3ab6dd 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -1,21 +1,21 @@ 'use client' import { useState } from 'react' -import { useLocale } from 'next-intl' import { Plus, Trash2 } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' -import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' import { useTagSelection } from '@/hooks/use-tag-selection' -import { useSubBlockValue } from '../../hooks/use-sub-block-value' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' import { translateWorkflowLabel } from '@/i18n/block-editor' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' import type { LocaleCode } from '@/i18n/utils' +import { useSubBlockValue } from '../../hooks/use-sub-block-value' interface TagFilter { id: string @@ -49,7 +49,7 @@ export function KnowledgeTagFilters({ isConnecting = false, }: KnowledgeTagFiltersProps) { const locale = useLocale() as LocaleCode - const t = (label: string) => translateWorkflowLabel(locale, label) + const t = (key: string) => translateWorkflowLabel(locale, key) const copy = useAppMessages().workspace.widgets.blockEditor.knowledgeTagFilters const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -184,16 +184,15 @@ export function KnowledgeTagFilters({ if (isPreview) { const appliedFilters = filters.filter((f) => f.tagName.trim() && f.tagValue.trim()).length - const appliedCopy = - appliedFilters === 1 ? copy.appliedCountSingular : copy.appliedCountPlural + const appliedCopy = appliedFilters === 1 ? copy.appliedCountSingular : copy.appliedCountPlural return (
- +
{appliedFilters > 0 ? formatTemplate(appliedCopy, { count: appliedFilters }) - : t('No filters')} + : t('noFilters')}
) @@ -202,8 +201,8 @@ export function KnowledgeTagFilters({ const renderHeader = () => (
- - + + ) @@ -251,7 +250,7 @@ export function KnowledgeTagFilters({ />
- {formatDisplayText(cellValue || t('Select tag'), { + {formatDisplayText(cellValue || t('selectTag'), { accessiblePrefixes, highlightAll: !accessiblePrefixes, })} @@ -364,7 +363,7 @@ export function KnowledgeTagFilters({ } if (isLoading) { - return
{t('Loading tag definitions...')}
+ return
{t('loadingTagDefinitions')}
} return ( diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/dropdown.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/dropdown.tsx index 3ec69711a..c9780d2b2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/order-id-selector/dropdown.tsx @@ -52,11 +52,11 @@ export function OrderIdSelectorDropdown({ > {isLoading ? (
- {translateWorkflowLabel(locale, 'Searching...')} + {translateWorkflowLabel(locale, 'searching')}
) : results.length === 0 ? (
- {error || translateWorkflowLabel(locale, 'No orders found.')} + {error || translateWorkflowLabel(locale, 'noOrdersFound')}
) : ( results.map((order, index) => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx index 459e5630f..d3a2f8589 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx @@ -77,7 +77,7 @@ export function JiraProjectSelector({ const locale = useLocale() as LocaleCode const copy = useAppMessages().workspace.widgets.blockEditor.toolInput const selectorCopy = useAppMessages().workspace.widgets.blockEditor.jiraProjectSelector - const labelText = label ?? translateWorkflowLabel(locale, 'Select Jira project') + const labelText = label ?? translateWorkflowLabel(locale, 'selectJiraProject') const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) const [projects, setProjects] = useState([]) @@ -396,7 +396,7 @@ export function JiraProjectSelector({ {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - translateWorkflowLabel(locale, 'Unknown')} + translateWorkflowLabel(locale, 'unknown')}
{credentials.length > 1 && ( @@ -406,7 +406,7 @@ export function JiraProjectSelector({ className='h-6 px-2 text-xs' onClick={() => setOpen(true)} > - {translateWorkflowLabel(locale, 'Switch')} + {translateWorkflowLabel(locale, 'switch')} )}
@@ -414,7 +414,7 @@ export function JiraProjectSelector({ @@ -422,7 +422,7 @@ export function JiraProjectSelector({ {isLoading ? (
- {translateWorkflowLabel(locale, 'Loading...')} + {translateWorkflowLabel(locale, 'loading')}
) : errorMessage ? (
@@ -431,7 +431,7 @@ export function JiraProjectSelector({ ) : credentials.length === 0 ? (

- {translateWorkflowLabel(locale, 'No accounts connected.')} + {translateWorkflowLabel(locale, 'noAccountsConnected')}

{formatTemplate(copy.selectProviderAccount, { provider: 'Jira' })} @@ -440,7 +440,7 @@ export function JiraProjectSelector({ ) : (

- {translateWorkflowLabel(locale, 'No projects found')} + {translateWorkflowLabel(locale, 'noProjectsFound')}

Try a different search or account. @@ -453,7 +453,7 @@ export function JiraProjectSelector({ {credentials.length > 1 && (

- {translateWorkflowLabel(locale, 'Switch Account')} + {translateWorkflowLabel(locale, 'switchAccount')}
{credentials.map((cred) => ( 0 && (
- {translateWorkflowLabel(locale, 'Projects')} + {translateWorkflowLabel(locale, 'projects')}
{projects.map((project) => ( e.stopPropagation()} > - {translateWorkflowLabel(locale, 'Open in Jira')} + {translateWorkflowLabel(locale, 'openInJira')} )} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx index ff08b6939..f9fa2699a 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { LinearIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -43,20 +43,17 @@ export function LinearProjectSelector({ const locale = useLocale() as LocaleCode const selectorCopy = useWorkspaceBlockEditorMessages().linearProjectSelector const copy = { - selectLinearProject: translateWorkflowLabel(locale, 'Select Linear project'), - searchProjects: translateWorkflowLabel(locale, 'Search projects...'), - loading: translateWorkflowLabel(locale, 'Loading...'), - missingCredentialsOrTeam: translateWorkflowLabel(locale, 'Missing credentials or team'), + selectLinearProject: translateWorkflowLabel(locale, 'selectLinearProject'), + searchProjects: translateWorkflowLabel(locale, 'searchProjects'), + loading: translateWorkflowLabel(locale, 'loading'), + missingCredentialsOrTeam: translateWorkflowLabel(locale, 'missingCredentialsOrTeam'), configureLinearCredentialsAndSelectTeam: translateWorkflowLabel( locale, - 'Please configure Linear credentials and select a team.' - ), - noProjectsFound: translateWorkflowLabel(locale, 'No projects found'), - noProjectsAvailable: translateWorkflowLabel( - locale, - 'No projects available for the selected team.' + 'configureLinearCredentialsAndSelectTeam' ), - projects: translateWorkflowLabel(locale, 'Projects'), + noProjectsFound: translateWorkflowLabel(locale, 'noProjectsFound'), + noProjectsAvailable: translateWorkflowLabel(locale, 'noProjectsAvailable'), + projects: translateWorkflowLabel(locale, 'projects'), } const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(false) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx index 7027de173..c677d2e8e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { LinearIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -42,14 +42,14 @@ export function LinearTeamSelector({ const locale = useLocale() as LocaleCode const selectorCopy = useWorkspaceBlockEditorMessages().linearTeamSelector const copy = { - selectLinearTeam: translateWorkflowLabel(locale, 'Select Linear team'), - searchTeams: translateWorkflowLabel(locale, 'Search teams...'), - loading: translateWorkflowLabel(locale, 'Loading...'), - missingCredentials: translateWorkflowLabel(locale, 'Missing credentials'), - configureLinearCredentials: translateWorkflowLabel(locale, 'Please configure Linear credentials.'), - noTeamsFound: translateWorkflowLabel(locale, 'No teams found'), - noTeamsAvailable: translateWorkflowLabel(locale, 'No teams available for this Linear account.'), - teams: translateWorkflowLabel(locale, 'Teams'), + selectLinearTeam: translateWorkflowLabel(locale, 'selectLinearTeam'), + searchTeams: translateWorkflowLabel(locale, 'searchTeams'), + loading: translateWorkflowLabel(locale, 'loading'), + missingCredentials: translateWorkflowLabel(locale, 'missingCredentials'), + configureLinearCredentials: translateWorkflowLabel(locale, 'configureLinearCredentials'), + noTeamsFound: translateWorkflowLabel(locale, 'noTeamsFound'), + noTeamsAvailable: translateWorkflowLabel(locale, 'noTeamsAvailable'), + teams: translateWorkflowLabel(locale, 'teams'), } const labelText = label ?? copy.selectLinearTeam const [teams, setTeams] = useState([]) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index 80b6fb9f3..e0463eeea 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } from 'react' import { useLocale } from 'next-intl' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import type { SubBlockConfig } from '@/blocks/types' +import { translateWorkflowLabel } from '@/i18n/block-editor' +import type { LocaleCode } from '@/i18n/utils' import { type JiraProjectInfo, JiraProjectSelector, @@ -18,9 +21,6 @@ import { import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' -import { translateWorkflowLabel } from '@/i18n/block-editor' -import type { SubBlockConfig } from '@/blocks/types' -import type { LocaleCode } from '@/i18n/utils' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' interface ProjectSelectorInputProps { @@ -93,13 +93,13 @@ export function ProjectSelectorInput({
{subBlock.id === 'teamId' ? ( - { handleProjectChange(teamId, teamInfo) }} credential={(linearCredential as string) || ''} - label={subBlock.placeholder || translateWorkflowLabel(locale, 'Select Linear team')} + label={subBlock.placeholder || translateWorkflowLabel(locale, 'selectLinearTeam')} disabled={finalDisabled} showPreview={true} workflowId={workflowId || ''} @@ -118,7 +118,8 @@ export function ProjectSelectorInput({ credential={credential} teamId={teamId} label={ - subBlock.placeholder || translateWorkflowLabel(locale, 'Select Linear project') + subBlock.placeholder || + translateWorkflowLabel(locale, 'selectLinearProject') } disabled={isDisabled} workflowId={workflowId || ''} @@ -130,7 +131,7 @@ export function ProjectSelectorInput({ {!(linearCredential as string) && ( -

{translateWorkflowLabel(locale, 'Please select a Linear account first')}

+

{translateWorkflowLabel(locale, 'pleaseSelectALinearAccountFirst')}

)} @@ -151,7 +152,7 @@ export function ProjectSelectorInput({ provider='jira' requiredScopes={subBlock.requiredScopes || []} serviceId={subBlock.serviceId} - label={subBlock.placeholder || translateWorkflowLabel(locale, 'Select Jira project')} + label={subBlock.placeholder || translateWorkflowLabel(locale, 'selectJiraProject')} disabled={finalDisabled} showPreview={true} onProjectInfoChange={setProjectInfo} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx index 11c136394..934972925 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -42,7 +42,7 @@ export function ScheduleConfig({ disabled = false, }: ScheduleConfigProps) { const locale = useLocale() as LocaleCode - const t = (label: string) => translateWorkflowLabel(locale, label) + const t = (key: string) => translateWorkflowLabel(locale, key) const copy = useWorkflowBlockEditorCopy().scheduleConfig const [error, setError] = useState(null) const [scheduleData, setScheduleData] = useState<{ @@ -169,7 +169,7 @@ export function ScheduleConfig({ const getScheduleInfo = () => { if (!scheduleData.id || !scheduleData.nextRunAt) return null - let scheduleTiming = t('Unknown schedule') + let scheduleTiming = t('unknownSchedule') if (scheduleData.cronExpression) { scheduleTiming = parseCronToHumanReadable(scheduleData.cronExpression, scheduleData.timezone) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx index 28acda68a..1db8efc0b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx @@ -1,13 +1,13 @@ 'use client' import { useMemo, useState } from 'react' -import { useLocale } from 'next-intl' import { ToolCase, XIcon } from 'lucide-react' +import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' +import { useSkills } from '@/hooks/queries/skills' import { translateWorkflowLabel } from '@/i18n/block-editor' -import { formatTemplate } from '@/i18n/utils' import type { LocaleCode } from '@/i18n/utils' -import { useSkills } from '@/hooks/queries/skills' +import { formatTemplate } from '@/i18n/utils' import { Dropdown } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -49,7 +49,7 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput label: skill.name, id: skill.id, icon: ToolCase, - group: translateWorkflowLabel(locale, 'Skills'), + group: translateWorkflowLabel(locale, 'skills'), })) }, [locale, selectedSkillIds, workspaceSkills]) @@ -75,14 +75,14 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput blockId={blockId} subBlockId={`${subBlockId}-skill-selector`} options={dropdownOptions} - placeholder={translateWorkflowLabel(locale, 'Add Skill')} + placeholder={translateWorkflowLabel(locale, 'addSkill')} useStore={false} valueOverride={selectorValue} onChange={handleSkillSelection} disabled={disabled || !workspaceId} className='w-full' enableSearch - searchPlaceholder={translateWorkflowLabel(locale, 'Search skills...')} + searchPlaceholder={translateWorkflowLabel(locale, 'searchSkills')} /> ) : (
@@ -115,7 +115,7 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput type='button' className='ml-2 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground' onClick={() => handleRemoveSkill(storedSkill.skillId)} - aria-label={formatTemplate(translateWorkflowLabel(locale, 'Remove Skill'), { + aria-label={formatTemplate(translateWorkflowLabel(locale, 'removeSkill'), { name: resolvedName, })} > @@ -131,14 +131,14 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput blockId={blockId} subBlockId={`${subBlockId}-skill-selector-inline`} options={dropdownOptions} - placeholder={translateWorkflowLabel(locale, 'Add Skill')} + placeholder={translateWorkflowLabel(locale, 'addSkill')} useStore={false} valueOverride={selectorValue} onChange={handleSkillSelection} disabled={disabled || !workspaceId} className='w-full' enableSearch - searchPlaceholder={translateWorkflowLabel(locale, 'Search skills...')} + searchPlaceholder={translateWorkflowLabel(locale, 'searchSkills')} />
)} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 99ba55c19..e550b4db3 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { @@ -23,7 +24,6 @@ import { formatTemplate } from '@/i18n/client-messages' import type { LocaleCode } from '@/i18n/utils' import { useWorkspaceBlockEditorMessages } from '@/i18n/workspace-widget-hooks' import { useWorkflowId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { useLocale } from 'next-intl' const logger = createLogger('ToolCredentialSelector') @@ -81,7 +81,7 @@ export function ToolCredentialSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [selectedId, setSelectedId] = useState('') const activeWorkflowId = useWorkflowId() - const labelText = label ?? translateWorkflowLabel(locale, 'Select credential') + const labelText = label ?? translateWorkflowLabel(locale, 'selectCredential') // Update selected ID when value changes useEffect(() => { @@ -155,7 +155,7 @@ export function ToolCredentialSelector({ const selectedCredential = credentials.find((cred) => cred.id === selectedId) const selectedLabel = selectedCredential?.isOwner === false - ? translateWorkflowLabel(locale, 'Saved by collaborator') + ? translateWorkflowLabel(locale, 'savedByCollaborator') : selectedCredential?.name return ( @@ -171,7 +171,11 @@ export function ToolCredentialSelector({ >
{getProviderIcon(provider)} - + {selectedLabel || labelText}
@@ -185,12 +189,12 @@ export function ToolCredentialSelector({ {isLoading ? (
- {translateWorkflowLabel(locale, 'Loading...')} + {translateWorkflowLabel(locale, 'loading')}
) : credentials.length === 0 ? (

- {translateWorkflowLabel(locale, 'No accounts connected.')} + {translateWorkflowLabel(locale, 'noAccountsConnected')}

{formatTemplate(copy.selectProviderAccount, { @@ -201,7 +205,7 @@ export function ToolCredentialSelector({ ) : (

- {translateWorkflowLabel(locale, 'No accounts found.')} + {translateWorkflowLabel(locale, 'noAccountsFound')}

)} @@ -215,14 +219,14 @@ export function ToolCredentialSelector({ value={credential.id} onSelect={() => handleSelect(credential.id)} > -
- {getProviderIcon(credential.provider)} - - {credential.isOwner === false - ? translateWorkflowLabel(locale, 'Saved by collaborator') - : credential.name} - -
+
+ {getProviderIcon(credential.provider)} + + {credential.isOwner === false + ? translateWorkflowLabel(locale, 'savedByCollaborator') + : credential.name} + +
{credential.id === selectedId && } ))} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/trigger-save/trigger-save.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/trigger-save/trigger-save.tsx index 88ea517c6..5e011498f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/trigger-save/trigger-save.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/trigger-save/trigger-save.tsx @@ -77,7 +77,7 @@ export function TriggerSave({ blockId, subBlockId, disabled = false }: TriggerSa .forEach((subBlock) => { if (subBlock.id === 'triggerCredentials') { if (!triggerCredentials) { - missingFields.push(subBlock.title || translateWorkflowLabel(locale, 'Credentials')) + missingFields.push(subBlock.title || translateWorkflowLabel(locale, 'credentials')) } } else { const value = configToCheck?.[subBlock.id] @@ -151,7 +151,7 @@ export function TriggerSave({ blockId, subBlockId, disabled = false }: TriggerSa }) } else { setErrorMessage( - `${translateWorkflowLabel(locale, 'Missing required fields')}: ${validation.missingFields.join(', ')}` + `${translateWorkflowLabel(locale, 'missingRequiredFields')}: ${validation.missingFields.join(', ')}` ) logger.debug('Error message updated', { blockId, @@ -190,7 +190,7 @@ export function TriggerSave({ blockId, subBlockId, disabled = false }: TriggerSa const validation = validateRequiredFields(aggregatedConfig) if (!validation.valid) { setErrorMessage( - `${translateWorkflowLabel(locale, 'Missing required fields')}: ${validation.missingFields.join(', ')}` + `${translateWorkflowLabel(locale, 'missingRequiredFields')}: ${validation.missingFields.join(', ')}` ) setSaveStatus('error') return @@ -198,7 +198,7 @@ export function TriggerSave({ blockId, subBlockId, disabled = false }: TriggerSa const success = await saveConfig(aggregatedConfig ?? {}) if (!success) { - throw new Error(translateWorkflowLabel(locale, 'Save config returned false')) + throw new Error(translateWorkflowLabel(locale, 'saveConfigReturnedFalse')) } setSaveStatus('saved') @@ -216,7 +216,7 @@ export function TriggerSave({ blockId, subBlockId, disabled = false }: TriggerSa } catch (error: any) { setSaveStatus('error') setErrorMessage( - error?.message || translateWorkflowLabel(locale, 'An error occurred while saving.') + error?.message || translateWorkflowLabel(locale, 'anErrorOccurredWhileSaving') ) logger.error('Error saving trigger configuration', { error }) } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx index 6d50b2cf5..05c85f49b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx @@ -6,7 +6,6 @@ import { MarketProviderSelector } from '@/components/market-selector/provider-se import { TradingAccountSelector } from '@/components/trading-selector/account-selector' import { TradingProviderSelector } from '@/components/trading-selector/provider-selector' import { Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui' -import { formatTemplate } from '@/i18n/utils' import { DateTimePicker } from '@/components/ui/datetime-picker' import { SimpleTimePicker } from '@/components/ui/simple-time-picker' import { Slider } from '@/components/ui/slider' @@ -20,6 +19,7 @@ import { import { cn } from '@/lib/utils' import type { SubBlockConfig } from '@/blocks/types' import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import { formatTemplate } from '@/i18n/utils' import { getMarketProviderOptions, getMarketProviderOptionsByKind, @@ -66,11 +66,11 @@ import { TriggerSave, VariablesInput, } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components' +import { useWorkflowInspectorCopy } from '@/widgets/widgets/editor_workflow/copy' import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry' import { KnowledgeTagFilters } from './components/knowledge-tag-filters/knowledge-tag-filters' import { useDependsOnGate } from './hooks/use-depends-on-gate' import { useSubBlockValue } from './hooks/use-sub-block-value' -import { useWorkflowI18n, useWorkflowInspectorCopy } from '@/widgets/widgets/editor_workflow/copy' interface SubBlockProps { blockId: string @@ -439,7 +439,6 @@ export const SubBlock = memo( }: SubBlockProps) { const [isValidJson, setIsValidJson] = useState(true) const editorCopy = useWorkflowInspectorCopy().workflowEditor - const { translateWorkflowLabel } = useWorkflowI18n() const handleMouseDown = (e: React.MouseEvent) => { e.stopPropagation() @@ -546,7 +545,7 @@ export const SubBlock = memo(
@@ -111,7 +118,7 @@ export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe, locale }: Ema }} > {' '} |{' '} {' '} |{' '} { expect(html).toContain('Estás en la lista de espera') expect(html).toContain('ada@example.com') + expect(html).toContain('https://test.tradinggoose.ai/es/privacy') + expect(html).toContain('https://test.tradinggoose.ai/es/terms') + }) + + it('localizes generated email app links by locale', async () => { + const html = await renderPlanWelcomeEmail({ planName: 'Pro', locale: 'es' }) + + expect(html).toContain('https://test.tradinggoose.ai/es/login') + expect(html).toContain('https://test.tradinggoose.ai/es/privacy') + expect(html).toContain('https://test.tradinggoose.ai/es/terms') }) it('renders invite links exactly as supplied', async () => { diff --git a/apps/tradinggoose/components/emails/render-email.ts b/apps/tradinggoose/components/emails/render-email.ts index d78953baf..3a0b4c2d5 100644 --- a/apps/tradinggoose/components/emails/render-email.ts +++ b/apps/tradinggoose/components/emails/render-email.ts @@ -1,5 +1,4 @@ import { render } from '@react-email/components' -import { LocalizedEmail } from '@/components/emails/localized-email' import { type EmailLocale, emailText, @@ -9,8 +8,10 @@ import { getEmailCopy, normalizeEmailTemplateLocale, } from '@/components/emails/email-copy' +import { LocalizedEmail } from '@/components/emails/localized-email' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' export type EmailSubjectType = | 'sign-in' @@ -32,7 +33,13 @@ export type EmailSubjectType = | 'waitlist-approved' | 'careers-confirmation' -const otpTypes = ['sign-in', 'email-verification', 'forget-password', 'change-email', 'chat-access'] as const +const otpTypes = [ + 'sign-in', + 'email-verification', + 'forget-password', + 'change-email', + 'chat-access', +] as const type OtpType = (typeof otpTypes)[number] function commonValues(values: Record = {}) { @@ -48,7 +55,7 @@ function commonValues(values: Record = {}) { } function text(locale: EmailLocale, template: string, values: Record = {}) { - return emailText(template, commonValues(values)) + return emailText(locale, template, commonValues(values)) } export function getEmailSubject( @@ -287,6 +294,7 @@ export async function renderEnterpriseSubscriptionEmail( ): Promise { const copy = getEmailCopy(locale) const baseUrl = getBaseUrl() + const loginUrl = localizeUrl(baseUrl, locale, '/login') return await render( LocalizedEmail({ @@ -297,7 +305,7 @@ export async function renderEnterpriseSubscriptionEmail( text(locale, copy.billing.enterprise.welcome, { userName }), text(locale, copy.billing.enterprise.body), ], - cta: { href: `${baseUrl}/login`, label: copy.billing.enterprise.cta }, + cta: { href: loginUrl, label: copy.billing.enterprise.cta }, detailsTitle: copy.billing.enterprise.nextStepsTitle, details: copy.billing.enterprise.nextSteps, muted: [copy.billing.enterprise.help], @@ -369,19 +377,25 @@ export async function renderFreeTierUpgradeEmail(params: { const details: string[] = [] if (params.recommendedTierName) { - details.push(text(params.locale, copy.billing.freeTier.recommendedTier, { - tierName: params.recommendedTierName, - })) + details.push( + text(params.locale, copy.billing.freeTier.recommendedTier, { + tierName: params.recommendedTierName, + }) + ) } if (params.recommendedTierPriceUsd) { - details.push(text(params.locale, copy.billing.freeTier.recommendedPrice, { - price: formatEmailCurrency(params.locale, params.recommendedTierPriceUsd), - })) + details.push( + text(params.locale, copy.billing.freeTier.recommendedPrice, { + price: formatEmailCurrency(params.locale, params.recommendedTierPriceUsd), + }) + ) } if (params.recommendedTierIncludedUsageLimitUsd) { - details.push(text(params.locale, copy.billing.freeTier.recommendedUsage, { - usage: formatEmailCurrency(params.locale, params.recommendedTierIncludedUsageLimitUsd), - })) + details.push( + text(params.locale, copy.billing.freeTier.recommendedUsage, { + usage: formatEmailCurrency(params.locale, params.recommendedTierIncludedUsageLimitUsd), + }) + ) } details.push(...(params.recommendedTierFeatures ?? []).slice(0, 3)) @@ -429,14 +443,18 @@ export async function renderPaymentFailedEmail(params: { ] if (params.lastFourDigits) { - details.push(text(params.locale, copy.billing.paymentFailed.paymentMethod, { - lastFourDigits: params.lastFourDigits, - })) + details.push( + text(params.locale, copy.billing.paymentFailed.paymentMethod, { + lastFourDigits: params.lastFourDigits, + }) + ) } if (params.failureReason) { - details.push(text(params.locale, copy.billing.paymentFailed.reason, { - reason: params.failureReason, - })) + details.push( + text(params.locale, copy.billing.paymentFailed.reason, { + reason: params.failureReason, + }) + ) } return await render( @@ -519,6 +537,7 @@ export async function renderPlanWelcomeEmail(params: { }): Promise { const copy = getEmailCopy(params.locale) const baseUrl = getBaseUrl() + const loginUrl = localizeUrl(baseUrl, params.locale, '/login') return await render( LocalizedEmail({ @@ -529,14 +548,16 @@ export async function renderPlanWelcomeEmail(params: { title: text(params.locale, copy.billing.planWelcome.title, { planName: params.planName }), paragraphs: [ params.userName - ? text(params.locale, copy.billing.planWelcome.namedWelcome, { userName: params.userName }) + ? text(params.locale, copy.billing.planWelcome.namedWelcome, { + userName: params.userName, + }) : copy.billing.planWelcome.welcome, text(params.locale, copy.billing.planWelcome.body, { planName: params.planName }), copy.billing.planWelcome.help, copy.billing.planWelcome.settings, ], cta: { - href: params.loginLink || `${baseUrl}/login`, + href: params.loginLink || loginUrl, label: text(params.locale, copy.shared.openBrand, { brandName: getBrandConfig().name }), }, footerLine: text(params.locale, copy.shared.sentOn, { @@ -563,7 +584,10 @@ export async function renderCareersConfirmationEmail(params: { text(params.locale, copy.careers.greeting, { name: params.name }), text(params.locale, copy.careers.body, { position: params.position }), copy.careers.review, - text(params.locale, copy.careers.explore, { docsUrl: 'https://docs.tradinggoose.ai', blogUrl: `${baseUrl}/blog` }), + text(params.locale, copy.careers.explore, { + docsUrl: 'https://docs.tradinggoose.ai', + blogUrl: `${baseUrl}/blog`, + }), ], footerLine: text(params.locale, copy.careers.sentLine, { dateTime: formatEmailDateTime(params.locale, new Date()), diff --git a/apps/tradinggoose/components/market-selector/provider-selector.tsx b/apps/tradinggoose/components/market-selector/provider-selector.tsx index c99826afb..55657f791 100644 --- a/apps/tradinggoose/components/market-selector/provider-selector.tsx +++ b/apps/tradinggoose/components/market-selector/provider-selector.tsx @@ -1,8 +1,8 @@ 'use client' +import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' -import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' import type { MarketProviderOption } from '@/providers/market/providers' interface MarketProviderSelectorProps { @@ -46,7 +46,7 @@ export function MarketProviderSelector({ currentVariant === 'form' ? option.name : formatTemplate(copy.selectedLabel, { - providerName: option.name || copy.fallbackProviderName, + providerName: option.name || copy.defaultProviderName, }) } /> diff --git a/apps/tradinggoose/components/trading-selector/account-selector.tsx b/apps/tradinggoose/components/trading-selector/account-selector.tsx index d67eeb020..bb9595aac 100644 --- a/apps/tradinggoose/components/trading-selector/account-selector.tsx +++ b/apps/tradinggoose/components/trading-selector/account-selector.tsx @@ -85,7 +85,7 @@ export function TradingAccountSelector({ const providerDefinition = trimmedProviderId ? getTradingProviderDefinition(trimmedProviderId) : undefined - const providerName = providerDefinition?.name ?? copy.fallbackProviderName + const providerName = providerDefinition?.name ?? copy.defaultProviderName const resolvedPlaceholder = placeholder ?? copy.placeholder const resolvedTooltipText = tooltipText ?? copy.tooltip const oauthProvider = providerDefinition?.oauth?.provider @@ -223,9 +223,7 @@ export function TradingAccountSelector({ ) : portfolioIdentities.length === 0 ? (
- {accountsQuery.error - ? copy.unableToLoadBrokerAccounts - : copy.noBrokerAccountsFound} + {accountsQuery.error ? copy.unableToLoadBrokerAccounts : copy.noBrokerAccountsFound}
) : ( portfolioIdentities.map((account) => { @@ -281,7 +279,7 @@ export function TradingAccountSelector({ { providerName: getTradingServiceName(trimmedProviderId, serviceId) || - copy.fallbackProviderName, + copy.defaultProviderName, } )} diff --git a/apps/tradinggoose/components/trading-selector/provider-selector.tsx b/apps/tradinggoose/components/trading-selector/provider-selector.tsx index 552083fde..d7f68de0b 100644 --- a/apps/tradinggoose/components/trading-selector/provider-selector.tsx +++ b/apps/tradinggoose/components/trading-selector/provider-selector.tsx @@ -1,9 +1,9 @@ 'use client' -import { formatTemplate } from '@/i18n/utils' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' import { OAUTH_PROVIDERS, parseProvider } from '@/lib/oauth' +import { formatTemplate } from '@/i18n/utils' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { getTradingProviderDefinition } from '@/providers/trading/providers' export type TradingProviderOption = { @@ -68,7 +68,7 @@ export function TradingProviderSelector({ currentVariant === 'form' ? option.name : formatTemplate(copy.selectedLabel, { - providerName: option.name || copy.fallbackProviderName, + providerName: option.name || copy.defaultProviderName, }) } /> diff --git a/apps/tradinggoose/global-navbar/components/user-menu.tsx b/apps/tradinggoose/global-navbar/components/user-menu.tsx index da424e011..74aca1c70 100644 --- a/apps/tradinggoose/global-navbar/components/user-menu.tsx +++ b/apps/tradinggoose/global-navbar/components/user-menu.tsx @@ -42,12 +42,7 @@ import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organi import { useSubscriptionData } from '@/hooks/queries/subscription' import { formatTemplate } from '@/i18n/client-messages' import { usePathname, useRouter } from '@/i18n/navigation' -import { - getLocaleDisplayName, - isLocaleCode, - type LocaleCode, - locales, -} from '@/i18n/utils' +import { getLocaleDisplayName, isLocaleCode, type LocaleCode, locales } from '@/i18n/utils' import { clearUserData } from '@/stores' import { useGeneralStore } from '@/stores/settings/general/store' import { getInitials } from '../utils' @@ -151,9 +146,7 @@ export function UserMenu({ const currentThemeOption = THEME_OPTIONS.find((option) => option.value === theme) ?? THEME_OPTIONS[0] const currentThemeLabel = themeOptionLabels[currentThemeOption.value] - // This label intentionally bypasses next-intl interpolation because workspace copy uses - // {{token}} templates instead of ICU placeholders. - const themeLabelTemplate = messages.workspace?.userMenu?.themeLabel ?? 'Theme: {{theme}}' + const themeLabelTemplate = messages.workspace?.userMenu?.themeLabel ?? 'Theme: {theme}' const currentThemeAriaLabel = formatTemplate(themeLabelTemplate, { theme: currentThemeLabel }) const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const activeOrganization = organizationsData?.activeOrganization @@ -577,7 +570,7 @@ export function UserMenu({ { event.preventDefault() - router.push(systemNavigation.href) + router.push(systemNavigation.href) }} > diff --git a/apps/tradinggoose/i18n/block-editor.test.ts b/apps/tradinggoose/i18n/block-editor.test.ts index ae9431b00..ebdf8afee 100644 --- a/apps/tradinggoose/i18n/block-editor.test.ts +++ b/apps/tradinggoose/i18n/block-editor.test.ts @@ -22,7 +22,7 @@ import esCopy from './messages/es.json' import zhCopy from './messages/zh.json' import { getPublicCopy } from './public-copy' -function collectObjectOptionOverridePaths( +function collectNonArrayOptionOverridePaths( localeCopy: Record, locale: string ): string[] { @@ -43,12 +43,11 @@ function collectObjectOptionOverridePaths( } for (const [subBlockId, subBlockValue] of Object.entries(entryValue as Record)) { - if ( - subBlockValue && - typeof subBlockValue === 'object' && - 'options' in (subBlockValue as Record) && - !Array.isArray((subBlockValue as Record).options) - ) { + if (!subBlockValue || typeof subBlockValue !== 'object') { + continue + } + const options = (subBlockValue as Record)?.options + if ('options' in (subBlockValue as Record) && !Array.isArray(options)) { invalidPaths.push(`${locale}:${basePath}.${entryId}.${subBlockId}.options`) } } @@ -73,12 +72,11 @@ function collectObjectOptionOverridePaths( for (const [paramId, paramValue] of Object.entries( parameterCollection as Record )) { - if ( - paramValue && - typeof paramValue === 'object' && - 'options' in (paramValue as Record) && - !Array.isArray((paramValue as Record).options) - ) { + if (!paramValue || typeof paramValue !== 'object') { + continue + } + const options = (paramValue as Record)?.options + if ('options' in (paramValue as Record) && !Array.isArray(options)) { invalidPaths.push( `${locale}:workspace.widgets.blockEditor.toolParameters.${blockType}.${toolId}.${paramId}.options` ) @@ -96,12 +94,11 @@ function collectObjectOptionOverridePaths( } for (const [subBlockId, subBlockValue] of Object.entries(subBlocks as Record)) { - if ( - subBlockValue && - typeof subBlockValue === 'object' && - 'options' in (subBlockValue as Record) && - !Array.isArray((subBlockValue as Record).options) - ) { + if (!subBlockValue || typeof subBlockValue !== 'object') { + continue + } + const options = (subBlockValue as Record)?.options + if ('options' in (subBlockValue as Record) && !Array.isArray(options)) { invalidPaths.push( `${locale}:workspace.widgets.blockEditor.triggers.${triggerId}.subBlocks.${subBlockId}.options` ) @@ -114,80 +111,75 @@ function collectObjectOptionOverridePaths( } describe('block-editor i18n helpers', () => { - it('translates tools labels and strips trailing colons before lookup', () => { - expect(translateWorkflowLabel('zh', 'Tools')).toBe('工具') - expect(translateWorkflowLabel('zh', 'Response Format:')).toBe('响应格式') + it('translates workflow labels by canonical key', () => { + expect(translateWorkflowLabel('zh', 'tools')).toBe('工具') + expect(translateWorkflowLabel('zh', 'responseFormat')).toBe('响应格式') }) it('translates webhook labels from the shared workflow label namespace', () => { const esLabels = getPublicCopy('es').workspace.widgets.workflowLabels const zhLabels = getPublicCopy('zh').workspace.widgets.workflowLabels - expect(translateWorkflowLabel('es', 'Webhook URL:')).toBe(esLabels.webhookUrl) - expect(translateWorkflowLabel('es', 'Payload')).toBe(esLabels.payload) + expect(translateWorkflowLabel('es', 'webhookUrl')).toBe(esLabels.webhookUrl) + expect(translateWorkflowLabel('es', 'payload')).toBe(esLabels.payload) expect(translateWorkflowLabel('zh', 'signingSecret')).toBe(zhLabels.signingSecret) - expect(translateWorkflowLabel('zh', 'Additional Headers')).toBe(zhLabels.additionalHeaders) + expect(translateWorkflowLabel('zh', 'additionalHeaders')).toBe(zhLabels.additionalHeaders) }) - it('resolves shared workflow labels through the stable resolver', () => { - expect(translateWorkflowLabel('es', 'System Prompt')).toBe('Prompt del sistema') - expect(translateWorkflowLabel('zh', 'System Prompt')).toBe('系统提示词') - expect(translateWorkflowLabel('es', 'Task')).toBe('Tarea') - expect(translateWorkflowLabel('zh', 'Variables')).toBe('变量') + it('resolves shared workflow labels through canonical keys', () => { + expect(translateWorkflowLabel('es', 'systemPrompt')).toBe('Prompt del sistema') + expect(translateWorkflowLabel('zh', 'systemPrompt')).toBe('系统提示词') }) - it('translates shared API block labels and stable aliases', () => { + it('translates shared API block labels by canonical key', () => { const esLabels = getPublicCopy('es').workspace.widgets.workflowLabels const zhLabels = getPublicCopy('zh').workspace.widgets.workflowLabels - expect(translateWorkflowLabel('es', 'URL:')).toBe(esLabels.url) - expect(translateWorkflowLabel('es', 'Method')).toBe(esLabels.method) - expect(translateWorkflowLabel('es', 'Query Params')).toBe(esLabels.queryParams) + expect(translateWorkflowLabel('es', 'method')).toBe(esLabels.method) + expect(translateWorkflowLabel('es', 'queryParams')).toBe(esLabels.queryParams) expect(translateWorkflowLabel('es', 'headers')).toBe(esLabels.headers) - expect(translateWorkflowLabel('zh', 'Body')).toBe(zhLabels.body) - expect(translateWorkflowLabel('zh', 'params')).toBe('params') + expect(translateWorkflowLabel('zh', 'body')).toBe(zhLabels.body) }) - it('translates guardrails workflow labels from the shared namespace', () => { - const esLabels = getPublicCopy('es').workspace.widgets.workflowLabels + it('keeps guardrails block copy in the block editor catalog', () => { + const esGuardrails = getPublicCopy('es').workspace.widgets.blockEditor.subBlocks.guardrails + const zhGuardrails = getPublicCopy('zh').workspace.widgets.blockEditor.subBlocks.guardrails const zhLabels = getPublicCopy('zh').workspace.widgets.workflowLabels - expect(translateWorkflowLabel('es', 'Content to Validate')).toBe(esLabels.contentToValidate) - expect(translateWorkflowLabel('es', 'Validation Type')).toBe(esLabels.validationType) - expect(translateWorkflowLabel('zh', 'PII Types to Detect')).toBe(zhLabels.piiTypesToDetect) - expect(translateWorkflowLabel('zh', 'Configure PII Types')).toBe(zhLabels.configurePiiTypes) + expect(esGuardrails.input.title).toBe('Contenido a validar') + expect(esGuardrails.validationType.title).toBe('Tipo de validación') + expect(zhGuardrails.piiEntityTypes.title).toBe('要检测的 PII 类型') + expect(translateWorkflowLabel('zh', 'configurePiiTypes')).toBe(zhLabels.configurePiiTypes) }) it('translates human in the loop workflow labels from the shared namespace', () => { const esLabels = getPublicCopy('es').workspace.widgets.workflowLabels const zhLabels = getPublicCopy('zh').workspace.widgets.workflowLabels - expect(translateWorkflowLabel('es', 'Display Data')).toBe(esLabels.displayData) - expect(translateWorkflowLabel('es', 'Notification (Send URL)')).toBe( - esLabels.notificationSendUrl - ) - expect(translateWorkflowLabel('zh', 'Resume Form')).toBe(zhLabels.resumeForm) + expect(translateWorkflowLabel('es', 'displayData')).toBe(esLabels.displayData) + expect(translateWorkflowLabel('es', 'notificationSendUrl')).toBe(esLabels.notificationSendUrl) + expect(translateWorkflowLabel('zh', 'resumeForm')).toBe(zhLabels.resumeForm) }) it('translates shared knowledge workflow labels from the shared namespace', () => { - expect(translateWorkflowLabel('es', 'Operation')).toBe('Operación') - expect(translateWorkflowLabel('es', 'Search Query')).toBe('Consulta de búsqueda') - expect(translateWorkflowLabel('zh', 'Number of Results')).toBe('结果数量') + expect(translateWorkflowLabel('es', 'operation')).toBe('Operación') + expect(translateWorkflowLabel('es', 'searchQuery')).toBe('Consulta de búsqueda') + expect(translateWorkflowLabel('zh', 'numberOfResults')).toBe('结果数量') }) it('translates landing workflow preview labels through the shared resolver', () => { - expect(translateWorkflowLabel('es', 'Signal Briefing')).toBe('Resumen de señales') - expect(translateWorkflowLabel('zh', 'Risk Committee')).toBe('风险委员会') + expect(translateWorkflowLabel('es', 'signalBriefing')).toBe('Resumen de señales') + expect(translateWorkflowLabel('zh', 'riskCommittee')).toBe('风险委员会') }) it('translates memory workflow labels from the shared namespace', () => { const esLabels = getPublicCopy('es').workspace.widgets.workflowLabels const zhLabels = getPublicCopy('zh').workspace.widgets.workflowLabels - expect(translateWorkflowLabel('es', 'Role')).toBe(esLabels.role) - expect(translateWorkflowLabel('es', 'Content')).toBe(esLabels.content) - expect(translateWorkflowLabel('zh', 'ID')).toBe(zhLabels.id) - expect(translateWorkflowLabel('zh', 'Add Memory')).toBe(zhLabels.addMemory) + expect(translateWorkflowLabel('es', 'role')).toBe(esLabels.role) + expect(translateWorkflowLabel('es', 'content')).toBe(esLabels.content) + expect(translateWorkflowLabel('zh', 'id')).toBe(zhLabels.id) + expect(translateWorkflowLabel('zh', 'addMemory')).toBe(zhLabels.addMemory) }) it('resolves workflow inspector key paths directly', () => { @@ -199,11 +191,13 @@ describe('block-editor i18n helpers', () => { ) }) - it('falls back to the source label when no shared workflow mapping exists', () => { - expect(translateWorkflowLabel('es', 'Unmapped Workflow Label')).toBe('Unmapped Workflow Label') + it('throws when a workflow label key is missing', () => { + expect(() => translateWorkflowLabel('es', 'Unmapped Workflow Label')).toThrow( + 'Missing workflow label translation' + ) }) - it('localizes placeholders, titles, and static options through the shared config helper', () => { + it('localizes titles and static options through block editor overrides', () => { const localizedConfig = localizeWorkflowSubBlockConfig( 'es', { @@ -213,43 +207,51 @@ describe('block-editor i18n helpers', () => { placeholder: 'Type or select a model...', options: [ { id: 'json', label: 'Valid JSON' }, - { id: 'pii', label: 'PII Detection', group: 'Common' }, + { + id: 'pii', + label: 'PII Detection', + group: 'Common', + }, ], }, 'guardrails' ) - expect(localizedConfig.title).toBe( - getPublicCopy('es').workspace.widgets.workflowLabels.validationType - ) + const guardrailsCopy = getPublicCopy('es').workspace.widgets.blockEditor.subBlocks.guardrails + expect(localizedConfig.title).toBe(guardrailsCopy.validationType.title) expect(localizedConfig.placeholder).toBe('Type or select a model...') - expect(localizedConfig.options).toEqual([ - { id: 'json', label: translateWorkflowLabel('es', 'Valid JSON') }, + const localizedOptions = + typeof localizedConfig.options === 'function' + ? localizedConfig.options() + : localizedConfig.options + expect( + localizedOptions?.map((option) => ({ + id: option.id, + label: option.label, + group: option.group, + })) + ).toEqual([ + { id: 'json', label: 'JSON válido', group: undefined }, { id: 'pii', - label: translateWorkflowLabel('es', 'PII Detection'), - group: translateWorkflowLabel('es', 'Common'), + label: 'Detección de PII', + group: 'Common', }, ]) }) it('resolves localized display values for guardrails option ids', () => { const config = { - id: 'piiTypes', + id: 'piiEntityTypes', options: [ - { id: 'json', label: 'Valid JSON' }, - { id: 'PERSON', label: 'Person name' }, - { id: 'EMAIL_ADDRESS', label: 'Email address' }, + { id: 'PERSON', label: 'Person name', group: 'Common' }, + { id: 'EMAIL_ADDRESS', label: 'Email address', group: 'Common' }, ], } - expect(resolveWorkflowDisplayValue('es', config, 'json')).toBe( - translateWorkflowLabel('es', 'Valid JSON') - ) - expect(resolveWorkflowDisplayValue('zh', config, ['PERSON', 'EMAIL_ADDRESS'])).toEqual([ - translateWorkflowLabel('zh', 'Person name'), - translateWorkflowLabel('zh', 'Email address'), - ]) + expect( + resolveWorkflowDisplayValue('zh', config, ['PERSON', 'EMAIL_ADDRESS'], 'guardrails') + ).toEqual(['姓名', '电子邮箱']) }) it('localizes trigger and subflow names through stable block-type keys', () => { @@ -452,23 +454,21 @@ describe('block-editor i18n helpers', () => { 'stagehand_agent' ) - expect(localizedSchema.title).toBe(translateWorkflowLabel('zh', 'Output Schema')) - expect(localizedSchema.placeholder).toBe( - translateWorkflowLabel('zh', 'Enter JSON schema...') - ) + expect(localizedSchema.title).toBe('输出架构') + expect(localizedSchema.placeholder).toBe('输入 JSON 模式...') }) it('localizes trigger-capable tool metadata through the shared block catalog', () => { expect(getLocalizedBlockMetadata('es', GitHubBlock)).toEqual({ name: 'GitHub', description: 'Interactuar con GitHub o activar flujos desde eventos de GitHub', - longDescription: GitHubBlock.longDescription, + longDescription: undefined, }) expect(getLocalizedBlockMetadata('zh', GmailBlock)).toEqual({ name: 'Gmail', description: getPublicCopy('zh').workspace.widgets.blockEditor.blockDescriptions.gmail, - longDescription: GmailBlock.longDescription, + longDescription: undefined, }) }) @@ -534,15 +534,15 @@ describe('block-editor i18n helpers', () => { expect(localizedApifyBuild.uiComponent?.placeholder).toContain('latest') }) - it('stores block editor option overrides as arrays so external ids never become locale keys', () => { + it('stores block editor option overrides as arrays so arbitrary ids remain message values', () => { expect([ - ...collectObjectOptionOverridePaths(enCopy as Record, 'en'), - ...collectObjectOptionOverridePaths(esCopy as Record, 'es'), - ...collectObjectOptionOverridePaths(zhCopy as Record, 'zh'), + ...collectNonArrayOptionOverridePaths(enCopy as Record, 'en'), + ...collectNonArrayOptionOverridePaths(esCopy as Record, 'es'), + ...collectNonArrayOptionOverridePaths(zhCopy as Record, 'zh'), ]).toEqual([]) }) - it('localizes dotted option ids through array-based option overrides', () => { + it('localizes dotted option ids through array option overrides', () => { const localizedBrowserModel = localizeWorkflowSubBlockConfig( 'en', { @@ -612,14 +612,14 @@ describe('block-editor i18n helpers', () => { }) it('localizes monitor trigger instructions through centralized trigger override entries', () => { - const fallbackInstruction = 'inline fallback instructions' + const inlineInstruction = 'inline instructions' const localizedIndicatorInstructions = localizeWorkflowSubBlockConfig( 'es', { id: 'triggerInstructions', title: 'Setup Instructions', type: 'text', - defaultValue: fallbackInstruction, + defaultValue: inlineInstruction, }, undefined, 'indicator_trigger' @@ -630,7 +630,7 @@ describe('block-editor i18n helpers', () => { id: 'triggerInstructions', title: 'Setup Instructions', type: 'text', - defaultValue: fallbackInstruction, + defaultValue: inlineInstruction, }, undefined, 'portfolio_state_trigger' @@ -639,9 +639,9 @@ describe('block-editor i18n helpers', () => { expect(localizedIndicatorInstructions.defaultValue).toContain( 'gestionar los monitores de indicadores' ) - expect(localizedIndicatorInstructions.defaultValue).not.toContain(fallbackInstruction) + expect(localizedIndicatorInstructions.defaultValue).not.toContain(inlineInstruction) expect(localizedPortfolioInstructions.defaultValue).toContain('投资组合监控') - expect(localizedPortfolioInstructions.defaultValue).not.toContain(fallbackInstruction) + expect(localizedPortfolioInstructions.defaultValue).not.toContain(inlineInstruction) }) it('prefers trigger metadata names over inline selectedTriggerId labels', () => { diff --git a/apps/tradinggoose/i18n/block-editor.ts b/apps/tradinggoose/i18n/block-editor.ts index 3d4956aeb..7a0a76f21 100644 --- a/apps/tradinggoose/i18n/block-editor.ts +++ b/apps/tradinggoose/i18n/block-editor.ts @@ -12,14 +12,14 @@ import { getLocalizedBlockMetadataWithCopy, getLocalizedBlockNameWithCopy, getLocalizedDefaultBlockNameWithCopy, - getLocalizedTriggerMetadataWithCopy, getLocalizedToolParameterLabelWithCopy, getLocalizedToolParametersConfigWithCopy, + getLocalizedTriggerMetadataWithCopy, getMcpToolSelectorCopyFromInspector, getReadOnlyPreviewCopyFromInspector, - getTriggerSubBlockCopyFromInspector, getToolbarDisabledReasonFromInspector, getToolInputCopyFromInspector, + getTriggerSubBlockCopyFromInspector, getTriggerWarningCopyFromInspector, getWorkflowEditorCopyFromInspector, getWorkflowLabelCopyFromInspector, @@ -30,7 +30,6 @@ import { translateWorkflowLabelWithCopy, translateWorkflowToolbarLabelWithCopy, type WorkflowInspectorCopy, - type WorkflowOption, } from './workflow-inspector-core' export type { LocaleCode } from './utils' @@ -44,12 +43,12 @@ export function getBlockEditorCopy(locale: LocaleCode) { return getBlockEditorCopyFromInspector(getWorkflowInspectorCopy(locale)) } -export function getTriggerSubBlockCopy( - locale: LocaleCode, - triggerId: string, - subBlockId: string -) { - return getTriggerSubBlockCopyFromInspector(getWorkflowInspectorCopy(locale), triggerId, subBlockId) +export function getTriggerSubBlockCopy(locale: LocaleCode, triggerId: string, subBlockId: string) { + return getTriggerSubBlockCopyFromInspector( + getWorkflowInspectorCopy(locale), + triggerId, + subBlockId + ) } export function localizeWorkflowOptions( @@ -107,20 +106,20 @@ export function getTriggerWarningCopy(locale: LocaleCode, triggerName: string) { export function getLocalizedBlockName( locale: LocaleCode, blockOrType: Pick | string, - fallbackName?: string + providedName?: string ): string { - return getLocalizedBlockNameWithCopy(getWorkflowInspectorCopy(locale), blockOrType, fallbackName) + return getLocalizedBlockNameWithCopy(getWorkflowInspectorCopy(locale), blockOrType, providedName) } export function getLocalizedBlockDescription( locale: LocaleCode, blockOrType: Pick | string, - fallbackDescription?: string + providedDescription?: string ): string { return getLocalizedBlockDescriptionWithCopy( getWorkflowInspectorCopy(locale), blockOrType, - fallbackDescription + providedDescription ) } @@ -141,12 +140,12 @@ export function getLocalizedTriggerMetadata( export function getLocalizedBlockLongDescription( locale: LocaleCode, block: Pick | string, - fallbackLongDescription?: string + providedLongDescription?: string ) { return getLocalizedBlockLongDescriptionWithCopy( getWorkflowInspectorCopy(locale), block, - fallbackLongDescription + providedLongDescription ) } @@ -155,7 +154,11 @@ export function getLocalizedDefaultBlockName( blockType: string, blockName?: string ): string { - return getLocalizedDefaultBlockNameWithCopy(getWorkflowInspectorCopy(locale), blockType, blockName) + return getLocalizedDefaultBlockNameWithCopy( + getWorkflowInspectorCopy(locale), + blockType, + blockName + ) } export function getLocalizedToolParameterLabel( @@ -189,12 +192,12 @@ export function getLocalizedToolParametersConfig( ) } -export function translateWorkflowToolbarLabel(locale: LocaleCode, label: string): string { - return translateWorkflowToolbarLabelWithCopy(getWorkflowToolbarCopy(locale), label) +export function translateWorkflowToolbarLabel(locale: LocaleCode, key: string): string { + return translateWorkflowToolbarLabelWithCopy(getWorkflowToolbarCopy(locale), key) } -export function translateWorkflowLabel(locale: LocaleCode, label: string): string { - return translateWorkflowLabelWithCopy(getWorkflowInspectorCopy(locale), label) +export function translateWorkflowLabel(locale: LocaleCode, key: string): string { + return translateWorkflowLabelWithCopy(getWorkflowInspectorCopy(locale), key) } export function localizeWorkflowSubBlockConfig( diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 4029c99b8..91957cde3 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -47,8 +47,8 @@ "homeLabel": "Home", "languageLabel": "Language", "primaryNavigation": "Primary navigation", - "githubRepositoryAriaLabel": "GitHub repository - {{stars}} stars", - "homeAriaLabel": "{{brand}} home" + "githubRepositoryAriaLabel": "GitHub repository - {stars} stars", + "homeAriaLabel": "{brand} home" }, "registration": { "open": { @@ -178,7 +178,7 @@ "github": "GitHub", "google": "Google", "connecting": "Connecting...", - "cancelled": "{{provider}} sign in was cancelled. Please try again." + "cancelled": "{provider} sign in was cancelled. Please try again." }, "verify": { "eyebrow": "Verification", @@ -186,7 +186,7 @@ "verifiedTitle": "Email Verified!", "verifiedDescription": "Your email has been verified. Redirecting to dashboard...", "disabledDescription": "Email verification is disabled. Redirecting to dashboard...", - "codeSent": "A verification code has been sent to {{email}}", + "codeSent": "A verification code has been sent to {email}", "developmentDescription": "Development mode: Check your console logs for the verification code", "missingServiceDescription": "Error: Email verification is enabled but no email service is configured", "instructionsWithService": "Enter the 6-digit code to verify your account. If you don't see it in your inbox, check your spam folder.", @@ -194,7 +194,7 @@ "verifyButton": "Verify Email", "verifyingButton": "Verifying...", "resendPrompt": "Didn't receive a code?", - "resendIn": "Resend in {{countdown}}s", + "resendIn": "Resend in {countdown}s", "resendButton": "Resend", "yourEmail": "your email", "errors": { @@ -370,16 +370,8 @@ "waitlist": "Honk! Introducing TradingGoose-Studio", "open": "Honk! TradingGoose-Studio is here!" }, - "leadWords": [ - "Build", - "Test", - "Run" - ], - "highlightWords": [ - "Trading Analysis", - "Signal Detection", - "Risk Assessment" - ], + "leadWords": ["Build", "Test", "Run"], + "highlightWords": ["Trading Analysis", "Signal Detection", "Risk Assessment"], "titleConnector": "your", "suffix": "with TradingGoose", "description": "Connect your own data providers, write custom indicators to monitor market prices, and wire them into workflows that trigger trade, sell, buy, or any action you define.", @@ -412,7 +404,7 @@ }, "footer": { "description": "AI workflow platform for technical LLM trading", - "copyright": "© {{year}} {{brand}}. Built for visual trading workflows.", + "copyright": "© {year} {brand}. Built for visual trading workflows.", "links": { "docs": "Docs", "blog": "Blog", @@ -654,11 +646,11 @@ }, "blog": { "pageTitle": "Blog", - "pageDescription": "Insights on trading automation, workflow design, and building smarter strategies. {{count}} articles and counting.", + "pageDescription": "Insights on trading automation, workflow design, and building smarter strategies. {count} articles and counting.", "searchPlaceholder": "Search articles", "emptyTitle": "No posts yet", "emptyDescription": "Check back soon - new articles are on the way.", - "noMatches": "No posts matching \"{{query}}\"", + "noMatches": "No posts matching \"{query}\"", "noMatchesDescription": "Try a different search term.", "readTimeSuffix": "min read", "viewArticle": "View Article", @@ -666,11 +658,11 @@ "breadcrumbBlog": "Blog", "tableOfContents": "On This Page", "shareTitle": "Share This Article", - "shareOn": "Share on {{platform}}", + "shareOn": "Share on {platform}", "copyLink": "Copy link", "copied": "Copied!", "summarizeTitle": "Summarize with AI", - "summarizeWithPlatform": "Summarize with {{platform}}", + "summarizeWithPlatform": "Summarize with {platform}", "articleSingular": "article", "articlePlural": "articles" }, @@ -713,13 +705,7 @@ "experience": { "label": "Years of Experience *", "placeholder": "Select experience level", - "options": [ - "0-1 years", - "1-3 years", - "3-5 years", - "5-10 years", - "10+ years" - ] + "options": ["0-1 years", "1-3 years", "3-5 years", "5-10 years", "10+ years"] }, "location": { "label": "Location *", @@ -772,8 +758,8 @@ "rssFeed": "RSS Feed", "loadingMore": "Loading...", "showMore": "Show more", - "viewContributorAriaLabel": "View @{{contributor}} on GitHub", - "contributorAvatarAlt": "@{{contributor}}", + "viewContributorAriaLabel": "View @{contributor} on GitHub", + "contributorAvatarAlt": "@{contributor}", "breadcrumb": "Changelog" }, "legal": { @@ -783,12 +769,12 @@ "terms": { "title": "Terms of Service", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "These Terms of Service govern your access to and use of the {{projectName}} website, app, APIs, and any project-operated hosted services (collectively, the Service).\n\nIf you use a self-hosted deployment or a deployment operated by someone other than the TradingGoose project owner, that operator is responsible for its own service terms, privacy disclosures, security practices, billing, and compliance.\n\nBy accessing or using the Service, you agree to these Terms. If you do not agree, do not use the Service.\n\n## 1. Open-Source License\n\n{{projectName}} source code is made available separately under the project's AGPL-3.0-only license and applicable third-party licenses. These Terms govern the project-operated website and hosted Service and do not replace or reduce rights granted to you under the source-code license.\n\nLicense and attribution details are available in the repository and on the [Licenses & Notices](/licenses) page.\n\n## 2. Eligibility, Accounts, and Access\n\nYou may need an account to use some parts of the Service. You must provide accurate information, keep your credentials secure, and are responsible for activity that occurs through your account.\n\nYou are responsible for maintaining the confidentiality of login credentials, API keys, OAuth connections, broker credentials, and any other access methods linked to your account or workflows.\n\nWe may suspend or restrict access if we reasonably believe an account is being used in a way that violates these Terms, threatens security, or creates legal or operational risk.\n\n## 3. Acceptable Use\n\nYou may not use the Service to:\n\n- Break the law or violate the rights of others.\n- Upload, transmit, or automate unlawful, infringing, abusive, or malicious content.\n- Attempt unauthorized access, interfere with the Service, or disrupt other users.\n- Use the Service to distribute malware, spam, or fraudulent activity.\n- Misuse connected accounts, OAuth credentials, broker accounts, or third-party APIs that you do not control or are not authorized to use.\n- Use the Service in a way that would require regulatory registrations, disclosures, or permissions that you do not have.\n\n## 4. Your Content, Workflows, and Integrations\n\nYou retain ownership of content, files, prompts, workflow definitions, indicator scripts, credentials, and other data you submit or connect through the Service (Your Content).\n\nYou grant us a limited license to host, process, store, transmit, and display Your Content only as needed to operate, secure, support, and improve the Service.\n\nYou are responsible for making sure you have the rights and permissions needed to use Your Content and any third-party services, market data sources, broker accounts, or APIs you connect.\n\n## 5. Third-Party Services and Connected Accounts\n\nThe Service may interoperate with third-party services, including model providers, storage providers, communication services, identity providers, analytics services, payment processors, market-data services, and broker platforms.\n\nYour use of those third-party services remains subject to their own terms, privacy notices, fees, technical limits, and availability.\n\nWe are not responsible for outages, errors, pricing changes, API changes, account restrictions, execution failures, or other actions taken by third-party services.\n\n## 6. Paid Features and Billing\n\nSome deployments may offer paid plans, usage-based billing, or subscription features. If you purchase paid access, you agree to pay applicable fees, taxes, and charges described at the time of purchase.\n\nBilling may be handled by third-party payment processors such as Stripe. We do not store full payment card details ourselves.\n\nFailure to pay may result in suspension or downgrade of paid features. Pricing and plan terms may change prospectively.\n\n## 7. Analytics, Research, and Automation Only\n\n{{projectName}} is provided as software for analytics, research, charting, monitoring, workflow automation, and related technical operations. It is not a broker-dealer, exchange, investment adviser, fiduciary, or execution venue.\n\nThe Service does not provide financial, investment, legal, accounting, or tax advice. Any outputs, charts, alerts, scripts, model responses, workflow results, or automations are informational tools only.\n\nYou are solely responsible for evaluating information, reviewing outputs, managing risk, determining suitability, and deciding whether to place trades, submit orders, or take any other action based on the Service.\n\n## 8. Trading Actions, Broker Integrations, and Market Data\n\nSome deployments may enable workflows or tools that interact with third-party brokers, exchanges, prediction markets, or market-data providers. Any such trading action is initiated at your direction and carried out, if at all, by the relevant third-party provider.\n\nWe are not responsible for trades, orders, cancellations, fills, partial fills, rejected orders, delayed execution, stale prices, incorrect symbols, model mistakes, workflow logic errors, unavailable APIs, market-data inaccuracies, or losses of any kind arising from actions taken through or based on the Service.\n\nYou are responsible for configuring safeguards, testing workflows, supervising automated behavior, and confirming that any connected trading activity complies with applicable law and the third-party provider's rules.\n\n## 9. Availability, Experimental Features, and Changes\n\nThe Service may change over time. We may add, modify, suspend, or remove features at any time, including integrations, hosted capabilities, and experimental features.\n\nSome capabilities may be marked experimental, beta, preview, or otherwise incomplete. You use those features at your own risk.\n\nWe may suspend or terminate access to the project-operated Service if you violate these Terms, create security or legal risk, or misuse the Service. You may stop using the Service at any time.\n\n## 10. Intellectual Property and Branding\n\nThe Service includes open-source code, third-party components, and project-owned branding and content. Open-source and third-party materials remain subject to their respective licenses.\n\nUnless a license expressly allows otherwise, the {{projectName}} name, logos, and brand assets may not be used in a way that implies endorsement, affiliation, or source without permission.\n\n## 11. Disclaimers\n\nThe Service is provided on an AS IS and AS AVAILABLE basis without warranties of any kind to the fullest extent permitted by law. We do not warrant uninterrupted operation, accuracy, profitability, availability of integrations, suitability for a particular strategy, or that the Service will prevent losses or errors.\n\n## 12. Limitation of Liability\n\nTo the fullest extent permitted by law, we are not liable for indirect, incidental, special, consequential, exemplary, or punitive damages, or for loss of profits, revenue, trading losses, brokerage losses, goodwill, data, or business interruption arising from or related to the Service.\n\nIf liability cannot be excluded, it is limited to the amount you paid us for the project-operated Service during the 12 months before the claim arose.\n\n## 13. Indemnity\n\nTo the extent permitted by law, you will be responsible for claims, losses, and costs arising from your misuse of the Service, your connected accounts, your content, your workflows, or your violation of these Terms or applicable law.\n\n## 14. Changes to These Terms\n\nWe may update these Terms from time to time. When we do, we will update the Last updated date on this page. Your continued use of the project-operated Service after an update becomes effective means you accept the revised Terms.\n\n## 15. Contact and Copyright Notices\n\nIf you have questions about these Terms, or if you believe material available through a project-operated Service infringes your rights, contact us at [{{supportEmail}}](mailto:{{supportEmail}}).\n\nPlease include enough detail for us to understand and review your request. We do not currently publish a separate mailing address for legal notices on this page." + "bodyMarkdown": "These Terms of Service govern your access to and use of the {projectName} website, app, APIs, and any project-operated hosted services (collectively, the Service).\n\nIf you use a self-hosted deployment or a deployment operated by someone other than the TradingGoose project owner, that operator is responsible for its own service terms, privacy disclosures, security practices, billing, and compliance.\n\nBy accessing or using the Service, you agree to these Terms. If you do not agree, do not use the Service.\n\n## 1. Open-Source License\n\n{projectName} source code is made available separately under the project's AGPL-3.0-only license and applicable third-party licenses. These Terms govern the project-operated website and hosted Service and do not replace or reduce rights granted to you under the source-code license.\n\nLicense and attribution details are available in the repository and on the [Licenses & Notices](/licenses) page.\n\n## 2. Eligibility, Accounts, and Access\n\nYou may need an account to use some parts of the Service. You must provide accurate information, keep your credentials secure, and are responsible for activity that occurs through your account.\n\nYou are responsible for maintaining the confidentiality of login credentials, API keys, OAuth connections, broker credentials, and any other access methods linked to your account or workflows.\n\nWe may suspend or restrict access if we reasonably believe an account is being used in a way that violates these Terms, threatens security, or creates legal or operational risk.\n\n## 3. Acceptable Use\n\nYou may not use the Service to:\n\n- Break the law or violate the rights of others.\n- Upload, transmit, or automate unlawful, infringing, abusive, or malicious content.\n- Attempt unauthorized access, interfere with the Service, or disrupt other users.\n- Use the Service to distribute malware, spam, or fraudulent activity.\n- Misuse connected accounts, OAuth credentials, broker accounts, or third-party APIs that you do not control or are not authorized to use.\n- Use the Service in a way that would require regulatory registrations, disclosures, or permissions that you do not have.\n\n## 4. Your Content, Workflows, and Integrations\n\nYou retain ownership of content, files, prompts, workflow definitions, indicator scripts, credentials, and other data you submit or connect through the Service (Your Content).\n\nYou grant us a limited license to host, process, store, transmit, and display Your Content only as needed to operate, secure, support, and improve the Service.\n\nYou are responsible for making sure you have the rights and permissions needed to use Your Content and any third-party services, market data sources, broker accounts, or APIs you connect.\n\n## 5. Third-Party Services and Connected Accounts\n\nThe Service may interoperate with third-party services, including model providers, storage providers, communication services, identity providers, analytics services, payment processors, market-data services, and broker platforms.\n\nYour use of those third-party services remains subject to their own terms, privacy notices, fees, technical limits, and availability.\n\nWe are not responsible for outages, errors, pricing changes, API changes, account restrictions, execution failures, or other actions taken by third-party services.\n\n## 6. Paid Features and Billing\n\nSome deployments may offer paid plans, usage-based billing, or subscription features. If you purchase paid access, you agree to pay applicable fees, taxes, and charges described at the time of purchase.\n\nBilling may be handled by third-party payment processors such as Stripe. We do not store full payment card details ourselves.\n\nFailure to pay may result in suspension or downgrade of paid features. Pricing and plan terms may change prospectively.\n\n## 7. Analytics, Research, and Automation Only\n\n{projectName} is provided as software for analytics, research, charting, monitoring, workflow automation, and related technical operations. It is not a broker-dealer, exchange, investment adviser, fiduciary, or execution venue.\n\nThe Service does not provide financial, investment, legal, accounting, or tax advice. Any outputs, charts, alerts, scripts, model responses, workflow results, or automations are informational tools only.\n\nYou are solely responsible for evaluating information, reviewing outputs, managing risk, determining suitability, and deciding whether to place trades, submit orders, or take any other action based on the Service.\n\n## 8. Trading Actions, Broker Integrations, and Market Data\n\nSome deployments may enable workflows or tools that interact with third-party brokers, exchanges, prediction markets, or market-data providers. Any such trading action is initiated at your direction and carried out, if at all, by the relevant third-party provider.\n\nWe are not responsible for trades, orders, cancellations, fills, partial fills, rejected orders, delayed execution, stale prices, incorrect symbols, model mistakes, workflow logic errors, unavailable APIs, market-data inaccuracies, or losses of any kind arising from actions taken through or based on the Service.\n\nYou are responsible for configuring safeguards, testing workflows, supervising automated behavior, and confirming that any connected trading activity complies with applicable law and the third-party provider's rules.\n\n## 9. Availability, Experimental Features, and Changes\n\nThe Service may change over time. We may add, modify, suspend, or remove features at any time, including integrations, hosted capabilities, and experimental features.\n\nSome capabilities may be marked experimental, beta, preview, or otherwise incomplete. You use those features at your own risk.\n\nWe may suspend or terminate access to the project-operated Service if you violate these Terms, create security or legal risk, or misuse the Service. You may stop using the Service at any time.\n\n## 10. Intellectual Property and Branding\n\nThe Service includes open-source code, third-party components, and project-owned branding and content. Open-source and third-party materials remain subject to their respective licenses.\n\nUnless a license expressly allows otherwise, the {projectName} name, logos, and brand assets may not be used in a way that implies endorsement, affiliation, or source without permission.\n\n## 11. Disclaimers\n\nThe Service is provided on an AS IS and AS AVAILABLE basis without warranties of any kind to the fullest extent permitted by law. We do not warrant uninterrupted operation, accuracy, profitability, availability of integrations, suitability for a particular strategy, or that the Service will prevent losses or errors.\n\n## 12. Limitation of Liability\n\nTo the fullest extent permitted by law, we are not liable for indirect, incidental, special, consequential, exemplary, or punitive damages, or for loss of profits, revenue, trading losses, brokerage losses, goodwill, data, or business interruption arising from or related to the Service.\n\nIf liability cannot be excluded, it is limited to the amount you paid us for the project-operated Service during the 12 months before the claim arose.\n\n## 13. Indemnity\n\nTo the extent permitted by law, you will be responsible for claims, losses, and costs arising from your misuse of the Service, your connected accounts, your content, your workflows, or your violation of these Terms or applicable law.\n\n## 14. Changes to These Terms\n\nWe may update these Terms from time to time. When we do, we will update the Last updated date on this page. Your continued use of the project-operated Service after an update becomes effective means you accept the revised Terms.\n\n## 15. Contact and Copyright Notices\n\nIf you have questions about these Terms, or if you believe material available through a project-operated Service infringes your rights, contact us at [{supportEmail}](mailto:{supportEmail}).\n\nPlease include enough detail for us to understand and review your request. We do not currently publish a separate mailing address for legal notices on this page." }, "privacy": { "title": "Privacy Policy", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "This Privacy Policy describes how {{projectName}} handles personal data when the TradingGoose project owner operates the website, app, APIs, or hosted services (collectively, the Service).\n\nIf you use a self-hosted deployment or a deployment operated by someone else, that operator is responsible for its own privacy notice, data handling, cookies, analytics, integrations, and security practices.\n\n## 1. Scope and Roles\n\nThis Privacy Policy applies to project-operated deployments of {{projectName}}. For self-hosted deployments or deployments operated by someone else, that operator controls its own configuration, storage, integrations, analytics, retention, and compliance.\n\nIn those cases, the third-party operator, not the TradingGoose project owner, is the primary controller or operator for that deployment's user data.\n\n## 2. Information We Collect\n\n### Account and authentication data\n\nWe may collect information you provide when creating or using an account, such as your name, email address, login method, profile details, organization membership, and account settings.\n\n### Content and workflow data\n\nWe may process prompts, chats, files, documents, workflow definitions, logs, indicator scripts, watchlists, templates, and other content you submit or generate through the Service.\n\n### Connected account and integration data\n\nIf you connect third-party services, we may receive account identifiers, OAuth tokens, metadata, and the third-party data you authorize us to access. Depending on what you enable, this may include services such as Google, GitHub, Microsoft, Slack, Stripe, and broker or market-data providers.\n\n### Payment and subscription data\n\nIf paid plans or usage billing are enabled, billing is handled through payment providers such as Stripe. We may receive billing metadata such as customer IDs, subscription status, invoices, and payment outcomes, but we do not store full payment card details ourselves.\n\n### Technical and usage data\n\nWe may automatically collect information such as IP address, browser type, device information, timestamps, error data, request logs, feature usage, page visits, and performance metrics.\n\n### Cookies, local storage, and analytics\n\nThe Service uses cookies, local storage, and similar technologies for authentication, session management, preferences, security, and product analytics.\n\nIf analytics features are enabled by the deployment, they may include OpenTelemetry event collection and PostHog analytics. PostHog may collect page views, clicks, form interactions, and session replay data. Password fields are masked in replay, but other form inputs may not be.\n\n### Optional training dataset exports\n\nSome deployments may enable optional copilot-training features. If you explicitly use those features, the recorded workflow-edit dataset you choose to submit may be sent to a configured indexing or training service.\n\n## 3. Sources of Information\n\nWe may collect information directly from:\n\n- You, when you create an account, upload content, connect services, or contact us.\n- Your browser, device, and use of the Service.\n- Third-party accounts and APIs that you choose to connect.\n- Payment providers, analytics providers, hosting vendors, and operational vendors.\n\nWe may also derive operational or diagnostic information from logs, execution results, billing events, and system telemetry.\n\n## 4. How We Use Information\n\nWe may use information we collect to:\n\n- Provide, maintain, and secure the Service.\n- Authenticate users and manage accounts, organizations, and permissions.\n- Store, execute, and troubleshoot workflows, chats, files, and integrations.\n- Process payments, subscriptions, usage limits, and billing communications.\n- Respond to support requests, bug reports, and product feedback.\n- Monitor performance, reliability, fraud, abuse, and security incidents.\n- Improve the Service and develop new features.\n- Comply with legal obligations and enforce our terms and policies.\n\nIf you use AI or automation features, your content may be processed by model providers or integration providers selected by you or configured by the deployment in order to deliver the requested feature.\n\n## 5. How We Share Information\n\nWe may share information with:\n\n- Service providers that help us operate the Service, such as hosting, storage, email, analytics, logging, and payment vendors.\n- Third-party platforms, model providers, and APIs when needed to run the workflows, integrations, or features you enable.\n- Law enforcement, regulators, courts, or other parties when legally required.\n- A successor or buyer in connection with a merger, acquisition, financing, or transfer of the Service.\n\nWe do not sell personal information for money. Third-party service transfers may still occur when you enable connected features or when a deployment uses analytics or other operational vendors.\n\n## 6. Google, AI Providers, Broker Integrations, and Other Connected-Service Data\n\nIf you connect Google or other third-party services, we access and use that data only as needed to provide the features you enable, subject to your permissions and the connected provider's terms.\n\nWe do not use Google user data obtained through Google APIs to train generalized AI or ML models.\n\nOutside the optional training-dataset feature described above, we do not use your workflow content to train generalized models for the Service.\n\n## 7. Cookies, Local Storage, Telemetry, and Analytics\n\nThe Service uses cookies, local storage, and related technologies for login sessions, authentication state, UI preferences, account settings, security, product analytics, and similar operational functions.\n\nFor project-operated deployments, anonymous telemetry may be enabled by default and may be controllable through product settings depending on the deployment and your account state.\n\nSome deployments may also enable PostHog or similar analytics tooling for page analytics, interaction analytics, and session replay. If you run a self-hosted deployment, your operator controls whether such tooling is enabled and where data is sent.\n\n## 8. International Processing\n\nInformation may be processed in the United States and other countries where our vendors, infrastructure, or connected service providers operate. Data-protection laws may differ between jurisdictions.\n\n## 9. Retention\n\nWe keep information for as long as reasonably necessary to operate the Service, maintain your account, process billing, investigate abuse, comply with legal obligations, and resolve disputes.\n\nRetention periods may vary by data type and deployment configuration. Self-hosted or third-party operators control their own retention settings for their deployments.\n\n## 10. Security\n\nWe use reasonable administrative, technical, and organizational safeguards to protect information, but no system is completely secure. You are also responsible for protecting your account credentials, API keys, and connected third-party accounts.\n\n## 11. Your Choices and Rights\n\nDepending on where you live, you may have rights to access, correct, delete, export, or object to certain uses of your personal data.\n\nYou may also be able to control some collection directly through product settings, such as anonymous telemetry preferences, or by disconnecting third-party accounts.\n\nIf you are in the EEA, UK, or another jurisdiction with similar rights, you may also have rights related to restriction, objection, withdrawal of consent where consent is used, and complaint to a supervisory authority.\n\nIf you are a California resident, you may have rights to know, access, correct, delete, and receive information about categories of personal information we collect, use, and disclose for project-operated deployments, subject to legal exceptions.\n\nTo make a privacy-related request for a project-operated deployment, contact [{{supportEmail}}](mailto:{{supportEmail}}). If you use a self-hosted or third-party deployment, contact that operator instead.\n\n## 12. Children's Privacy\n\nThe Service is not intended for children under 18, and we do not knowingly collect personal data from children through the project-operated Service.\n\n## 13. External Services\n\nThis Privacy Policy does not cover third-party services, brokerages, model providers, market-data providers, or sites that you connect to or visit through the Service. Review those providers' own terms and privacy notices.\n\n## 14. Changes to This Policy\n\nWe may update this Privacy Policy from time to time. When we do, we will update the Last updated date on this page. Material changes will apply when posted unless a longer notice period is required by law.\n\n## 15. Contact\n\nIf you have questions, requests, or complaints regarding this Privacy Policy for a project-operated deployment, contact us at [{{supportEmail}}](mailto:{{supportEmail}}).\n\nWe do not currently publish a separate mailing address on this page. If that changes, we will update this policy." + "bodyMarkdown": "This Privacy Policy describes how {projectName} handles personal data when the TradingGoose project owner operates the website, app, APIs, or hosted services (collectively, the Service).\n\nIf you use a self-hosted deployment or a deployment operated by someone else, that operator is responsible for its own privacy notice, data handling, cookies, analytics, integrations, and security practices.\n\n## 1. Scope and Roles\n\nThis Privacy Policy applies to project-operated deployments of {projectName}. For self-hosted deployments or deployments operated by someone else, that operator controls its own configuration, storage, integrations, analytics, retention, and compliance.\n\nIn those cases, the third-party operator, not the TradingGoose project owner, is the primary controller or operator for that deployment's user data.\n\n## 2. Information We Collect\n\n### Account and authentication data\n\nWe may collect information you provide when creating or using an account, such as your name, email address, login method, profile details, organization membership, and account settings.\n\n### Content and workflow data\n\nWe may process prompts, chats, files, documents, workflow definitions, logs, indicator scripts, watchlists, templates, and other content you submit or generate through the Service.\n\n### Connected account and integration data\n\nIf you connect third-party services, we may receive account identifiers, OAuth tokens, metadata, and the third-party data you authorize us to access. Depending on what you enable, this may include services such as Google, GitHub, Microsoft, Slack, Stripe, and broker or market-data providers.\n\n### Payment and subscription data\n\nIf paid plans or usage billing are enabled, billing is handled through payment providers such as Stripe. We may receive billing metadata such as customer IDs, subscription status, invoices, and payment outcomes, but we do not store full payment card details ourselves.\n\n### Technical and usage data\n\nWe may automatically collect information such as IP address, browser type, device information, timestamps, error data, request logs, feature usage, page visits, and performance metrics.\n\n### Cookies, local storage, and analytics\n\nThe Service uses cookies, local storage, and similar technologies for authentication, session management, preferences, security, and product analytics.\n\nIf analytics features are enabled by the deployment, they may include OpenTelemetry event collection and PostHog analytics. PostHog may collect page views, clicks, form interactions, and session replay data. Password fields are masked in replay, but other form inputs may not be.\n\n### Optional training dataset exports\n\nSome deployments may enable optional copilot-training features. If you explicitly use those features, the recorded workflow-edit dataset you choose to submit may be sent to a configured indexing or training service.\n\n## 3. Sources of Information\n\nWe may collect information directly from:\n\n- You, when you create an account, upload content, connect services, or contact us.\n- Your browser, device, and use of the Service.\n- Third-party accounts and APIs that you choose to connect.\n- Payment providers, analytics providers, hosting vendors, and operational vendors.\n\nWe may also derive operational or diagnostic information from logs, execution results, billing events, and system telemetry.\n\n## 4. How We Use Information\n\nWe may use information we collect to:\n\n- Provide, maintain, and secure the Service.\n- Authenticate users and manage accounts, organizations, and permissions.\n- Store, execute, and troubleshoot workflows, chats, files, and integrations.\n- Process payments, subscriptions, usage limits, and billing communications.\n- Respond to support requests, bug reports, and product feedback.\n- Monitor performance, reliability, fraud, abuse, and security incidents.\n- Improve the Service and develop new features.\n- Comply with legal obligations and enforce our terms and policies.\n\nIf you use AI or automation features, your content may be processed by model providers or integration providers selected by you or configured by the deployment in order to deliver the requested feature.\n\n## 5. How We Share Information\n\nWe may share information with:\n\n- Service providers that help us operate the Service, such as hosting, storage, email, analytics, logging, and payment vendors.\n- Third-party platforms, model providers, and APIs when needed to run the workflows, integrations, or features you enable.\n- Law enforcement, regulators, courts, or other parties when legally required.\n- A successor or buyer in connection with a merger, acquisition, financing, or transfer of the Service.\n\nWe do not sell personal information for money. Third-party service transfers may still occur when you enable connected features or when a deployment uses analytics or other operational vendors.\n\n## 6. Google, AI Providers, Broker Integrations, and Other Connected-Service Data\n\nIf you connect Google or other third-party services, we access and use that data only as needed to provide the features you enable, subject to your permissions and the connected provider's terms.\n\nWe do not use Google user data obtained through Google APIs to train generalized AI or ML models.\n\nOutside the optional training-dataset feature described above, we do not use your workflow content to train generalized models for the Service.\n\n## 7. Cookies, Local Storage, Telemetry, and Analytics\n\nThe Service uses cookies, local storage, and related technologies for login sessions, authentication state, UI preferences, account settings, security, product analytics, and similar operational functions.\n\nFor project-operated deployments, anonymous telemetry may be enabled by default and may be controllable through product settings depending on the deployment and your account state.\n\nSome deployments may also enable PostHog or similar analytics tooling for page analytics, interaction analytics, and session replay. If you run a self-hosted deployment, your operator controls whether such tooling is enabled and where data is sent.\n\n## 8. International Processing\n\nInformation may be processed in the United States and other countries where our vendors, infrastructure, or connected service providers operate. Data-protection laws may differ between jurisdictions.\n\n## 9. Retention\n\nWe keep information for as long as reasonably necessary to operate the Service, maintain your account, process billing, investigate abuse, comply with legal obligations, and resolve disputes.\n\nRetention periods may vary by data type and deployment configuration. Self-hosted or third-party operators control their own retention settings for their deployments.\n\n## 10. Security\n\nWe use reasonable administrative, technical, and organizational safeguards to protect information, but no system is completely secure. You are also responsible for protecting your account credentials, API keys, and connected third-party accounts.\n\n## 11. Your Choices and Rights\n\nDepending on where you live, you may have rights to access, correct, delete, export, or object to certain uses of your personal data.\n\nYou may also be able to control some collection directly through product settings, such as anonymous telemetry preferences, or by disconnecting third-party accounts.\n\nIf you are in the EEA, UK, or another jurisdiction with similar rights, you may also have rights related to restriction, objection, withdrawal of consent where consent is used, and complaint to a supervisory authority.\n\nIf you are a California resident, you may have rights to know, access, correct, delete, and receive information about categories of personal information we collect, use, and disclose for project-operated deployments, subject to legal exceptions.\n\nTo make a privacy-related request for a project-operated deployment, contact [{supportEmail}](mailto:{supportEmail}). If you use a self-hosted or third-party deployment, contact that operator instead.\n\n## 12. Children's Privacy\n\nThe Service is not intended for children under 18, and we do not knowingly collect personal data from children through the project-operated Service.\n\n## 13. External Services\n\nThis Privacy Policy does not cover third-party services, brokerages, model providers, market-data providers, or sites that you connect to or visit through the Service. Review those providers' own terms and privacy notices.\n\n## 14. Changes to This Policy\n\nWe may update this Privacy Policy from time to time. When we do, we will update the Last updated date on this page. Material changes will apply when posted unless a longer notice period is required by law.\n\n## 15. Contact\n\nIf you have questions, requests, or complaints regarding this Privacy Policy for a project-operated deployment, contact us at [{supportEmail}](mailto:{supportEmail}).\n\nWe do not currently publish a separate mailing address on this page. If that changes, we will update this policy." }, "licenses": { "title": "Licenses & Notices", @@ -820,7 +806,7 @@ }, "warning": { "title": "Already Part of a Team", - "currentOrgWithName": "You are currently a member of \"{{name}}\". You must leave your current organization before accepting a new invitation.", + "currentOrgWithName": "You are currently a member of \"{name}\". You must leave your current organization before accepting a new invitation.", "currentOrg": "You are already a member of an organization. Leave your current organization before accepting a new invitation.", "manageTeamSettings": "Manage Team Settings" }, @@ -829,12 +815,12 @@ }, "success": { "title": "Welcome!", - "description": "You have successfully joined {{name}}. Redirecting to your workspace..." + "description": "You have successfully joined {name}. Redirecting to your workspace..." }, "invitation": { "organizationTitle": "Organization Invitation", "workspaceTitle": "Workspace Invitation", - "description": "You've been invited to join {{name}}. Click accept below to join.", + "description": "You've been invited to join {name}. Click accept below to join.", "accept": "Accept Invitation" }, "errors": { @@ -1042,7 +1028,7 @@ "createTooltip": "Write permission required to create knowledge bases" }, "errors": { - "load": "Error loading knowledge bases: {{error}}", + "load": "Error loading knowledge bases: {error}", "retry": "Try again" }, "emptyState": { @@ -1078,7 +1064,7 @@ "copyId": "Copy knowledge base ID", "deleteButtonLabel": "Delete knowledge base", "deleteTitle": "Delete Knowledge Base", - "deleteDescription": "Are you sure you want to delete \"{{title}}\"? This will remove the knowledge base and its {{count}} document{{plural}} permanently.", + "deleteDescription": "Are you sure you want to delete \"{title}\"? This will remove the knowledge base and its {count} document{plural} permanently.", "cancel": "Cancel", "deleteConfirm": "Delete", "deleting": "Deleting..." @@ -1112,7 +1098,7 @@ "nextChunk": "Next chunk", "previousPage": "previous page", "nextPage": "next page", - "editingChunk": "Editing chunk #{{index}} • Page {{currentPage}} of {{totalPages}}", + "editingChunk": "Editing chunk #{index} • Page {currentPage} of {totalPages}", "cancel": "Cancel", "errors": { "failedToCreateChunk": "Failed to create chunk", @@ -1150,8 +1136,8 @@ "creating": "Creating...", "failedToCreateKnowledgeBase": "Failed to create knowledge base", "unknownError": "An unknown error occurred", - "fileTooLarge": "File {{name}} is too large. Maximum size is 100MB per file.", - "unsupportedFileType": "File {{name}} has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML, or YML.", + "fileTooLarge": "File {name} is too large. Maximum size is 100MB per file.", + "unsupportedFileType": "File {name} has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML, or YML.", "processingError": "An error occurred while processing files. Please try again.", "validation": { "nameRequired": "Name is required", @@ -1180,8 +1166,8 @@ "uploadDocuments": "Upload Documents", "uploading": "Uploading...", "processing": "Processing...", - "fileTooLarge": "File \"{{name}}\" is too large. Maximum size is 100MB.", - "unsupportedFileType": "File \"{{name}}\" has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML, or YML files." + "fileTooLarge": "File \"{name}\" is too large. Maximum size is 100MB.", + "unsupportedFileType": "File \"{name}\" has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML, or YML files." }, "document": { "searchChunksPlaceholder": "Search chunks...", @@ -1189,13 +1175,13 @@ "deleteChunkTitle": "Delete chunk?", "deleteChunksTitle": "Delete chunks?", "deleteChunkDescription": "Deleting this chunk will permanently remove it from this document.", - "deleteChunksDescription": "Deleting {{count}} chunks will permanently remove them from this document.", + "deleteChunksDescription": "Deleting {count} chunks will permanently remove them from this document.", "thisActionCannotBeUndone": "This action cannot be undone.", "cancel": "Cancel", "deleteFileTitle": "Delete file?", "deleteFilesTitle": "Delete files?", - "deleteFileDescription": "Deleting \"{{name}}\" will permanently remove its source file, chunks, and embeddings from this knowledge base.", - "deleteFilesDescription": "Deleting {{count}} files will permanently remove their source files, chunks, and embeddings from this knowledge base.", + "deleteFileDescription": "Deleting \"{name}\" will permanently remove its source file, chunks, and embeddings from this knowledge base.", + "deleteFilesDescription": "Deleting {count} files will permanently remove their source files, chunks, and embeddings from this knowledge base.", "delete": "Delete", "deleting": "Deleting..." }, @@ -1210,8 +1196,8 @@ "addTag": "Add Tag", "emptyState": "No tags added yet. Click \"Add Tag\" to get started.", "advancedSettings": "Advanced Settings", - "tagCount": "{{count}} tag{{count, plural, one {} other {s}}}", - "slotsUsed": "{{used}} of {{total}} tag slots used", + "tagCount": "{count} tag{count, plural, one {} other {s}}", + "slotsUsed": "{used} of {total} tag slots used", "editTag": "Edit Tag", "addNewTag": "Add New Tag", "tagName": "Tag Name", @@ -1234,15 +1220,15 @@ "moreTags": "More tags", "showFewerTags": "Show fewer tags", "activeTags": "Active tags:", - "tagLabel": "Tag {{index}}", + "tagLabel": "Tag {index}", "deleteTagTitle": "Delete Tag", - "deleteTagDescription": "Are you sure you want to delete the \"{{name}}\" tag? This will remove this tag from {{count}} document{{plural}}.", + "deleteTagDescription": "Are you sure you want to delete the \"{name}\" tag? This will remove this tag from {count} document{plural}.", "thisActionCannotBeUndone": "This action cannot be undone.", "affectedDocuments": "Affected documents:", "deleting": "Deleting...", - "documentsUsingTagTitle": "Documents using \"{{name}}\"", - "documentsUsingTagDescriptionSingular": "{{count}} document is currently using this tag definition.", - "documentsUsingTagDescriptionPlural": "{{count}} documents are currently using this tag definition.", + "documentsUsingTagTitle": "Documents using \"{name}\"", + "documentsUsingTagDescriptionSingular": "{count} document is currently using this tag definition.", + "documentsUsingTagDescriptionPlural": "{count} documents are currently using this tag definition.", "singularIs": "is", "pluralAre": "are", "tagUnusedHelp": "This tag definition is not being used by any documents. You can safely delete it to free up the tag slot." @@ -1286,7 +1272,7 @@ "refreshing": "Refreshing...", "chart": { "noData": "No data available.", - "toggleSeries": "Toggle series {{label}}" + "toggleSeries": "Toggle series {label}" }, "filters": { "title": "Filters", @@ -1295,7 +1281,7 @@ "suggestedFilters": "Suggested filters", "textSearch": "Text search", "searchPlaceholder": "Search logs...", - "filterOptionsPlaceholder": "No options found for {{title}}.", + "filterOptionsPlaceholder": "No options found for {title}.", "searchWorkflows": "Search workflows...", "searchFolders": "Search folders...", "searchOptions": "Search options...", @@ -1305,11 +1291,11 @@ "noFolders": "No folders found.", "noOptions": "No options found.", "allWorkflows": "All workflows", - "selectedWorkflows": "{{count}} selected workflow{{plural}}", + "selectedWorkflows": "{count} selected workflow{plural}", "allFolders": "All folders", - "selectedFolders": "{{count}} selected folder{{plural}}", + "selectedFolders": "{count} selected folder{plural}", "allTriggers": "All triggers", - "selectedTriggers": "{{count}} selected trigger{{plural}}", + "selectedTriggers": "{count} selected trigger{plural}", "allTime": "All time", "past30Minutes": "Past 30 minutes", "pastHour": "Past hour", @@ -1334,7 +1320,7 @@ "trigger": "Trigger", "timeline": "Timeline", "retentionPolicy": "Log Retention Policy", - "retentionDescription": "Logs are automatically deleted after {{days}} days on this tier.", + "retentionDescription": "Logs are automatically deleted after {days} days on this tier.", "upgradePlan": "Upgrade Plan" }, "metrics": { @@ -1345,15 +1331,15 @@ }, "workflows": { "title": "Workflows", - "legend": "Each cell is approximately {{duration}} of the selected range. Click a cell to filter details.", - "count": "{{count}} workflow", - "countPlural": "{{count}} workflows", - "filteredFrom": " (filtered from {{count}})", - "noMatches": "No workflows found matching \"{{query}}\".", + "legend": "Each cell is approximately {duration} of the selected range. Click a cell to filter details.", + "count": "{count} workflow", + "countPlural": "{count} workflows", + "filteredFrom": " (filtered from {count})", + "noMatches": "No workflows found matching \"{query}\".", "selectedSegment": "Selected segment", - "filteredTo": "Filtered to {{timestamp}}", - "selectedRangeMore": " (+{{count}} more segment{{plural}})", - "selectedRangeExecutions": "— {{count}} execution{{plural}}", + "filteredTo": "Filtered to {timestamp}", + "selectedRangeMore": " (+{count} more segment{plural})", + "selectedRangeExecutions": "— {count} execution{plural}", "clearFilter": "Clear filter", "executions": "Executions", "success": "Success", @@ -1372,13 +1358,13 @@ "noExecutions": "No executions", "loadingMore": "Loading more...", "scrollToLoadMore": "Scroll to load more", - "succeeded": "{{success}}/{{total}} succeeded", - "segment": "Segment {{index}}", + "succeeded": "{success}/{total} succeeded", + "segment": "Segment {index}", "allWorkflows": "All workflows", - "multipleSelected": "{{count}} workflows selected", - "durationDay": "{{count}} day{{plural}}", - "durationHour": "{{count}} hour{{plural}}", - "durationMinute": "{{count}} minute{{plural}}" + "multipleSelected": "{count} workflows selected", + "durationDay": "{count} day{plural}", + "durationHour": "{count} hour{plural}", + "durationMinute": "{count} minute{plural}" } }, "list": { @@ -1396,7 +1382,7 @@ "noLogs": "No logs found", "unknownWorkflow": "Unknown Workflow", "errorPrefix": "Error: ", - "runningMs": "{{value}} ms" + "runningMs": "{value} ms" }, "details": { "title": "Log Details", @@ -1422,17 +1408,17 @@ "modelOutput": "Model Output:", "total": "Total:", "tokens": "Tokens:", - "modelBreakdown": "Model Breakdown ({{count}})", + "modelBreakdown": "Model Breakdown ({count})", "input": "Input:", "output": "Output:", - "totalCostNote": "Total cost includes a base execution charge of {{amount}} plus any model usage costs.", + "totalCostNote": "Total cost includes a base execution charge of {amount} plus any model usage costs.", "notAvailable": "Not available", "unknownSize": "Unknown size", "unknownType": "Unknown type", "unknownWorkflow": "Unknown", "unknownLevel": "unknown", "unknownValue": "Unknown", - "runningMs": "{{value}} ms", + "runningMs": "{value} ms", "traceSpans": { "workflowExecution": "Workflow execution", "collapseAll": "Collapse all", @@ -1445,21 +1431,21 @@ "initialResponse": "Initial response", "modelResponse": "Model response", "modelGeneration": "Model generation", - "tokens": "{{count}} token{{plural}}", + "tokens": "{count} token{plural}", "tokensUnavailable": "Tokens unavailable", - "tokensInOut": "{{input}} in / {{output}} out", - "tokensTotal": "{{count}} total token{{plural}}", - "tokensTotalSuffix": " ({{count}} total)", + "tokensInOut": "{input} in / {output} out", + "tokensTotal": "{count} total token{plural}", + "tokensTotalSuffix": " ({count} total)", "input": "Input", "output": "Output", "total": "Total", "start": "Start", - "plusMs": "+{{ms}} ms", - "betweenBlocks": "Gap of {{ms}} ms", + "plusMs": "+{ms} ms", + "betweenBlocks": "Gap of {ms} ms", "inputSection": "Input", "outputSection": "Output", "errorSection": "Error", - "segmentTimingTooltip": "{{type}}{{nameSuffix}} took {{duration}} ms" + "segmentTimingTooltip": "{type}{nameSuffix} took {duration} ms" }, "download": { "downloading": "Downloading...", @@ -1559,8 +1545,8 @@ } }, "searchEmpty": { - "workspace": "No workspace environment variables found matching \"{{query}}\".", - "personal": "No personal environment variables found matching \"{{query}}\"." + "workspace": "No workspace environment variables found matching \"{query}\".", + "personal": "No personal environment variables found matching \"{query}\"." }, "headers": { "createdAt": "Created At", @@ -1583,7 +1569,7 @@ }, "apiKeys": { "title": "API Keys", - "cardTitle": "{{scope}} API Keys", + "cardTitle": "{scope} API Keys", "searchPlaceholder": "Search keys...", "scope": { "workspace": "Workspace", @@ -1605,7 +1591,7 @@ "button": "Create Key" } }, - "searchEmpty": "No {{scope}} API keys found matching \"{{query}}\".", + "searchEmpty": "No {scope} API keys found matching \"{query}\".", "headers": { "createdAt": "Created At", "name": "Name", @@ -1616,20 +1602,20 @@ "labels": { "never": "Never", "billingTier": "Billing tier", - "lastUsed": "Last used: {{date}}", + "lastUsed": "Last used: {date}", "saveName": "Save API key name", - "rename": "Rename {{scope}} API key", - "reveal": "Reveal {{scope}} API key", - "hide": "Hide {{scope}} API key", - "copy": "Copy {{scope}} API key", - "save": "Save {{scope}} API key", + "rename": "Rename {scope} API key", + "reveal": "Reveal {scope} API key", + "hide": "Hide {scope} API key", + "copy": "Copy {scope} API key", + "save": "Save {scope} API key", "cancelRename": "Cancel rename", - "delete": "Delete {{scope}} API key", + "delete": "Delete {scope} API key", "nameRequired": "Name is required", - "duplicateName": "A {{scope}} API key named \"{{name}}\" already exists.", - "failedRename": "Failed to rename {{scope}} API key.", - "unableRename": "Unable to rename {{scope}} API key. Please try again.", - "failedCreate": "Failed to create {{scope}} API key. Please try again.", + "duplicateName": "A {scope} API key named \"{name}\" already exists.", + "failedRename": "Failed to rename {scope} API key.", + "unableRename": "Unable to rename {scope} API key. Please try again.", + "failedCreate": "Failed to create {scope} API key. Please try again.", "workspaceAccess": "This key grants access to all workflows and files within this workspace. Copy it immediately after creation as you will not be able to see it again.", "personalAccess": "This key grants access to your personal workflows and files. Copy it immediately after creation as you will not be able to see it again.", "onlyTimeYouWillSee": "This is the only time you will see the full key. Copy and store it securely.", @@ -1637,15 +1623,15 @@ "workspacePermissions": "You need edit or admin access to manage workspace API keys." }, "dialogs": { - "createTitle": "Create {{scope}} API key", + "createTitle": "Create {scope} API key", "createNameLabel": "Name", "createNamePlaceholder": "e.g., Production MCP Server", "createButton": "Create Key", - "newKeyTitle": "Your {{scope}} API key", + "newKeyTitle": "Your {scope} API key", "newKeyDescription": "This is the only time you will see the full key. Copy and store it securely.", - "deleteTitle": "Delete {{scope}} API key?", + "deleteTitle": "Delete {scope} API key?", "deleteDescription": "This will immediately revoke access for any integrations using this key.", - "deletePrompt": "Type {{name}} to confirm.", + "deletePrompt": "Type {name} to confirm.", "deletePlaceholder": "API key name", "cancel": "Cancel", "deleteButton": "Delete Key", @@ -1684,7 +1670,7 @@ "searchPlaceholder": "Search orders...", "filters": "Filters", "orderFilters": "Order filters", - "showingOf": "Showing {{loadedCount}} of {{totalCount}}", + "showingOf": "Showing {loadedCount} of {totalCount}", "clear": "Clear", "allProviders": "All providers", "allEnvironments": "All environments", @@ -1761,7 +1747,7 @@ "disconnect": "Disconnect", "emptyState": { "noConnectible": "No connectible integrations are configured.", - "noSearchMatches": "No services found matching \"{{query}}\"" + "noSearchMatches": "No services found matching \"{query}\"" }, "errors": { "loadAvailability": "Failed to load provider availability", @@ -1774,7 +1760,7 @@ "upload": { "idle": "Upload File", "uploading": "Uploading...", - "uploadingWithCount": "Uploading {{completed}}/{{total}}...", + "uploadingWithCount": "Uploading {completed}/{total}...", "button": "Upload File" }, "headers": { @@ -1798,7 +1784,7 @@ }, "deleteDialog": { "title": "Delete file?", - "descriptionWithName": "Deleting \"{{name}}\" will permanently remove it from this workspace.", + "descriptionWithName": "Deleting \"{name}\" will permanently remove it from this workspace.", "description": "Deleting this file will permanently remove it from this workspace.", "warning": "This action cannot be undone.", "cancel": "Cancel", @@ -1808,7 +1794,7 @@ "errors": { "billingTier": "Billing tier", "uploadFailed": "Upload failed", - "unsupportedFileType": "Unsupported file type: {{files}}" + "unsupportedFileType": "Unsupported file type: {files}" } }, "userMenu": { @@ -1824,7 +1810,7 @@ "loggingOut": "Logging out…", "billingPortalSelectOrganization": "Select an organization to manage billing.", "billingPortalFailed": "Failed to open billing portal", - "themeLabel": "Theme: {{theme}}", + "themeLabel": "Theme: {theme}", "languageLabel": "Language", "themeOptions": { "light": "Light", @@ -1875,8 +1861,8 @@ "nameRequired": "Please provide a name.", "saveError": "Unable to save profile settings.", "nameRequiredValidation": "Name is required", - "profilePictureFileTooLarge": "File {{name}} is too large. Maximum size is 5MB.", - "profilePictureUnsupportedFormat": "File {{name}} is not a supported image format. Please use PNG or JPEG.", + "profilePictureFileTooLarge": "File {name} is too large. Maximum size is 5MB.", + "profilePictureUnsupportedFormat": "File {name} is not a supported image format. Please use PNG or JPEG.", "profilePictureUpdateError": "Failed to update profile picture", "profilePictureRemoveError": "Failed to remove profile picture", "unableToUpdateProfilePicture": "Unable to update profile picture.", @@ -1905,7 +1891,7 @@ "dropImagesBrowse": "Drop images here or click to browse", "imageHint": "JPEG, PNG, WebP, GIF (max 20MB each)", "uploadedImages": "Uploaded Images", - "previewAlt": "Preview {{index}}", + "previewAlt": "Preview {index}", "cancel": "Cancel", "submit": "Submit", "submitting": "Submitting...", @@ -1916,8 +1902,8 @@ "subjectRequired": "Subject is required", "messageRequired": "Message is required", "requestTypeRequired": "Please select a request type", - "fileTooLarge": "File {{name}} is too large. Maximum size is 20MB.", - "unsupportedFormat": "File {{name}} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.", + "fileTooLarge": "File {name} is too large. Maximum size is 20MB.", + "unsupportedFormat": "File {name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.", "processing": "An error occurred while processing images. Please try again.", "submitFailed": "Failed to submit help request", "unknown": "An unknown error occurred" @@ -1955,7 +1941,7 @@ "custom": "Custom", "seats": "seats" }, - "seatsText": "{{count}} seats", + "seatsText": "{count} seats", "descriptions": { "manage": "Open Stripe Billing Portal to cancel, restore, or update your subscription.", "usageNotifications": "Email me when usage reaches the billing warning threshold", @@ -1981,7 +1967,7 @@ "actions": { "manage": "Manage", "contact": "Contact", - "upgradeTo": "Upgrade to {{name}}" + "upgradeTo": "Upgrade to {name}" }, "badges": { "resolvePayment": "Resolve Payment", @@ -2000,8 +1986,8 @@ }, "team": { "error": "Error", - "defaultTeamName": "{{name}}'s Team", - "billingHowWorksSeatCost": "{{seats}} seat{{plural}} cost ${{amount}} per month.", + "defaultTeamName": "{name}'s Team", + "billingHowWorksSeatCost": "{seats} seat{plural} cost ${amount} per month.", "billingHowWorksUsageTracked": "Usage is tracked across all workspaces in the organization.", "billingHowWorksIncreaseLimit": "Increase the limit when the organization needs more capacity.", "billingHowWorksOverage": "Overages are billed according to the active plan.", @@ -2025,16 +2011,16 @@ "subscriptionMayNeedTransfer": "Your subscription may need to be transferred to this organization.", "setUpTeamSubscription": "Set Up Team Subscription", "seats": "Seats", - "pricePerSeat": "({{price}}/month each)", - "used": "{{count}} used", - "total": "{{count}} total", + "pricePerSeat": "({price}/month each)", + "used": "{count} used", + "total": "{count} total", "removeSeat": "Remove Seat", "addSeat": "Add Seat", "seat": "seat", "numberOfSeats": "Number of seats", - "yourTeamWillHave": "Your team will have {{count}} {{seatWord}} with a total of ${{cost}} inference credits per month.", - "minimumSeatsNoMax": "Minimum {{minimum}} seats. No maximum seat cap applies to this tier.", - "chooseBetweenSeats": "Choose between {{minimum}} and {{maximum}} seats for this tier.", + "yourTeamWillHave": "Your team will have {count} {seatWord} with a total of ${cost} inference credits per month.", + "minimumSeatsNoMax": "Minimum {minimum} seats. No maximum seat cap applies to this tier.", + "chooseBetweenSeats": "Choose between {minimum} and {maximum} seats for this tier.", "currentSeats": "Current seats:", "newSeats": "New seats:", "monthlyCostChange": "Monthly cost change:", @@ -2043,7 +2029,7 @@ "leaveOrganization": "Leave Organization", "removeTeamMember": "Remove Team Member", "leaveOrganizationDescription": "Are you sure you want to leave this organization? You will lose access to all team resources.", - "removeMemberDescription": "Are you sure you want to remove {{name}} from the team?", + "removeMemberDescription": "Are you sure you want to remove {name} from the team?", "alsoReduceSeatCount": "Also reduce seat count in my subscription", "reduceSeatCountDescription": "If selected, your team seat count will be reduced by 1, lowering your monthly billing.", "thisActionCannotBeUndone": "This action cannot be undone.", @@ -2055,7 +2041,7 @@ "noPublicAdjustableTier": "No public adjustable organization tier is configured", "addSeats": { "title": "Add Team Seats", - "description": "Each seat costs ${{price}}/month and provides ${{price}} in monthly inference credits. Adjust the number of licensed seats for your team.", + "description": "Each seat costs ${price}/month and provides ${price} in monthly inference credits. Adjust the number of licensed seats for your team.", "confirm": "Update Seats" }, "billing": { @@ -2075,7 +2061,7 @@ "members": { "title": "Team members", "empty": "No team members yet.", - "sharedUsage": "Shared usage: ${{amount}}", + "sharedUsage": "Shared usage: ${amount}", "pending": "Pending", "billing": "Billing", "usage": "Usage", @@ -2097,13 +2083,13 @@ "unavailable": "Invite unavailable", "workspaceAccess": "Workspace access", "optional": "Optional", - "selected": "{{count}} selected workspace{{plural}}", + "selected": "{count} selected workspace{plural}", "grantAccess": "Grant access to specific workspaces and choose a permission level.", "noWorkspacesAvailable": "No workspaces available.", "needAdminAccess": "You need admin access to assign workspaces.", "owner": "Owner", "sentSuccess": "Invitation sent.", - "sentSuccessWithAccess": "Invitation sent with access to {{count}} workspace{{plural}}.", + "sentSuccessWithAccess": "Invitation sent with access to {count} workspace{plural}.", "invalidEmail": "Please enter a valid email address.", "permissions": { "read": { @@ -2180,7 +2166,7 @@ "providerError": "Failed to configure SSO provider", "reloadError": "Failed to reload SSO providers", "validation": { - "fieldRequired": "{{field}} is required.", + "fieldRequired": "{field} is required.", "providerIdRequired": "Provider ID is required.", "providerIdPattern": "Use letters, numbers, and dashes only.", "issuerUrlRequired": "Issuer URL is required.", @@ -2235,11 +2221,11 @@ "webhook": { "common": { "configureButton": "Configure Webhook", - "connectedLabel": "{{provider}} connected", - "configureTitle": "Configure {{provider}} Webhook", - "editTitle": "Edit {{provider}} Webhook", + "connectedLabel": "{provider} connected", + "configureTitle": "Configure {provider} Webhook", + "editTitle": "Edit {provider} Webhook", "close": "Close", - "learnMoreAbout": "Learn more about {{topic}}", + "learnMoreAbout": "Learn more about {topic}", "showSecret": "Show secret", "hideSecret": "Hide secret", "copyValue": "Copy value", @@ -2254,14 +2240,14 @@ "validConfiguration": "Webhook configuration is valid", "testFailure": "Webhook test failed", "telegramSslError": "Telegram requires a publicly accessible HTTPS URL with a valid SSL certificate.", - "telegramTestFailure": "Telegram webhook test failed: {{error}}", + "telegramTestFailure": "Telegram webhook test failed: {error}", "testError": "An unexpected error occurred while testing the webhook", "testUrlLabel": "Test webhook URL", "testUrlHint": "Use this temporary URL to send a sample request to your workflow.", "generate": "Generate", "generating": "Generating...", "regenerate": "Regenerate", - "expiresAt": "Expires at {{timestamp}}", + "expiresAt": "Expires at {timestamp}", "delete": "Delete", "deleting": "Deleting...", "testWebhook": "Test webhook", @@ -2315,7 +2301,7 @@ "copyWebhookUrl": "Copy the webhook URL.", "configureService": "Paste it into the service that will send the webhook.", "includeBearerHeader": "Include an Authorization header with the generated token.", - "includeNamedHeader": "Include a {{header}} header with the generated token." + "includeNamedHeader": "Include a {header} header with the generated token." } }, "github": { @@ -2347,7 +2333,7 @@ "addWebhook": "Go to Webhooks and click Add webhook.", "pastePayloadUrlPrefix": "Paste the", "pastePayloadUrlSuffix": "into the Payload URL field.", - "selectContentType": "Set Content type to {{contentType}}.", + "selectContentType": "Set Content type to {contentType}.", "enterWebhookSecretPrefix": "Copy the generated secret into", "enterWebhookSecretSuffix": ".", "setSslVerification": "Enable SSL verification unless you are testing in a controlled environment.", @@ -2386,7 +2372,7 @@ "notice": { "payloadTitle": "Gmail Event Payload Example" }, - "fallbackLabels": { + "defaultLabels": { "inbox": "Inbox", "sent": "Sent", "important": "Important", @@ -2425,7 +2411,7 @@ "notice": { "payloadTitle": "Outlook Event Payload Example" }, - "fallbackFolders": { + "defaultFolders": { "inbox": "Inbox", "sentItems": "Sent Items", "drafts": "Drafts", @@ -2702,7 +2688,7 @@ "downloadConsoleCsv": "Download console CSV", "downloadCsv": "Download CSV", "clearConsole": "Clear console", - "noResults": "No results found for \"{{query}}\"", + "noResults": "No results found for \"{query}\"", "showLess": "Show less", "showMore": "Show more", "copyValue": "Copy value", @@ -2727,10 +2713,10 @@ "noConsoleEntries": "No console entries", "generatedImageAlt": "Generated image", "downloadImageFailed": "Failed to download image. Please try again later.", - "summaryItemsSingular": "{{count}} item", - "summaryItemsPlural": "{{count}} items", - "summaryKeysSingular": "{{count}} key", - "summaryKeysPlural": "{{count}} keys" + "summaryItemsSingular": "{count} item", + "summaryItemsPlural": "{count} items", + "summaryKeysSingular": "{count} key", + "summaryKeysPlural": "{count} keys" }, "workflowChat": { "selectWorkspace": "Select a workspace to load workflows.", @@ -2744,22 +2730,22 @@ "fileUploadError": "File upload error", "attachFiles": "Attach files", "attach": "Attach", - "maximumFilesAllowed": "Maximum {{maxFiles}} files allowed", - "selectedFiles": "{{count}}/{{maxFiles}} files", + "maximumFilesAllowed": "Maximum {maxFiles} files allowed", + "selectedFiles": "{count}/{maxFiles} files", "removeFile": "Remove file", "dropFilesHere": "Drop files here to attach", "typeMessage": "Type a message...", - "uploadedFiles": "Uploaded {{count}} file{{plural}}", - "fileTooLarge": "{{name}} is too large (max {{maxSize}}MB)", - "fileTypeNotSupported": "{{name}} type not supported", - "fileAlreadyAdded": "{{name}} already added", + "uploadedFiles": "Uploaded {count} file{plural}", + "fileTooLarge": "{name} is too large (max {maxSize}MB)", + "fileTypeNotSupported": "{name} type not supported", + "fileAlreadyAdded": "{name} already added", "workflowExecutionFailed": "Workflow execution failed.", "errorPrefix": "Error: " }, "workflowOutputSelect": { "defaultPlaceholder": "Select output sources", - "fallbackBlockName": "Block {{id}}", - "selectedCount": "{{count}} selected", + "defaultBlockName": "Block {id}", + "selectedCount": "{count} selected", "searchPlaceholder": "Search outputs...", "noMatchingOutputs": "No matching outputs.", "noOutputsAvailable": "No outputs available." @@ -2823,16 +2809,16 @@ "serverNameRequired": "Server name is required.", "failedToRefreshMcpServer": "Failed to refresh MCP server tools.", "failedToSaveMcpServer": "Failed to save MCP server.", - "toolCount": "{{count}} tools", + "toolCount": "{count} tools", "loading": "Loading...", "connected": "Connected", "error": "Error", "draft": "Draft", "disconnected": "Disconnected", "unnamedServer": "Unnamed server", - "updated": "Updated {{time}}", - "toolsRefreshed": "Tools refreshed {{time}}", - "lastConnected": "Last connected {{time}}", + "updated": "Updated {time}", + "toolsRefreshed": "Tools refreshed {time}", + "lastConnected": "Last connected {time}", "lastError": "Last error", "noSharedMcpServerSelected": "This color has no shared MCP server selected yet.", "mcpServerNotFound": "MCP server not found.", @@ -2840,12 +2826,12 @@ "serverNameIsRequired": "Server name is required.", "relativeTime": { "justNow": "just now", - "minutesAgo": "{{count}}m ago", - "hoursAgo": "{{count}}h ago", - "daysAgo": "{{count}}d ago", - "weeksAgo": "{{count}}w ago", - "monthsAgo": "{{count}}mo ago", - "yearsAgo": "{{count}}y ago" + "minutesAgo": "{count}m ago", + "hoursAgo": "{count}h ago", + "daysAgo": "{count}d ago", + "weeksAgo": "{count}w ago", + "monthsAgo": "{count}mo ago", + "yearsAgo": "{count}y ago" } }, "triggerList": { @@ -2854,7 +2840,7 @@ "searchPlaceholder": "Search triggers", "openTriggerList": "Click to Add Trigger", "close": "Close", - "noResults": "No results found for \"{{query}}\"" + "noResults": "No results found for \"{query}\"" }, "workflowToolbar": { "selectWorkspace": "Select a workspace to browse blocks", @@ -2862,14 +2848,13 @@ "tools": "Tools", "triggers": "Triggers", "special": "Special", - "browseLabel": "Browse {{label}}", - "searchPlaceholder": "Search {{label}}...", - "noResults": "No {{label}} found." + "browseLabel": "Browse {label}", + "searchPlaceholder": "Search {label}...", + "noResults": "No {label} found." }, "workflowLabels": { "systemPrompt": "System Prompt", "userPrompt": "User Prompt", - "model": "Model", "temperature": "Temperature", "Signal Briefing": "Signal Briefing", "Indicator Monitor": "Indicator Monitor", @@ -2909,10 +2894,8 @@ "decisionRouter": "Decision Router", "increasePosition": "Increase Position", "reduceExposure": "Reduce Exposure", - "apiKey": "API Key", "skills": "Skills", "tools": "Tools", - "url": "URL", "method": "Method", "queryParams": "Query Params", "headers": "Headers", @@ -2928,7 +2911,6 @@ "reasoningEffort": "Reasoning Effort", "verbosity": "Verbosity", "configured": "Configured", - "value": "Value", "items": "Items", "fields": "Fields", "object": "Object", @@ -2949,41 +2931,22 @@ "nextStep": "Next Step", "locked": "Locked", "deployed": "Deployed", - "deployedWithVersion": "Deployed (v{{version}})", + "deployedWithVersion": "Deployed (v{version})", "notDeployed": "Not Deployed", "disabled": "Disabled", - "key": "Key", "start": "Start", "end": "End", - "removeSkill": "Remove {{name}}", + "removeSkill": "Remove {name}", "currentWorkflow": "Current Workflow", "currentSkill": "Current Skill", "currentTool": "Current Tool", "currentIndicator": "Current Indicator", "currentMcpServer": "Current MCP Server", - "task": "Task", - "Task": "Task", - "variables": "Variables", - "Variables": "Variables", - "startingUrl": "Starting URL", - "Starting URL": "Starting URL", - "outputSchema": "Output Schema", - "Output Schema": "Output Schema", - "anthropicApiKey": "Anthropic API Key", - "Anthropic API Key": "Anthropic API Key", "workflows": "Workflows", "customTools": "Custom Tools", "indicators": "Indicators", "mcpServers": "MCP Servers", "allWorkflows": "All workflows", - "contentToValidate": "Content to Validate", - "enterContentToValidate": "Enter content to validate", - "validationType": "Validation Type", - "validJson": "Valid JSON", - "regexMatch": "Regex Match", - "hallucinationCheck": "Hallucination Check", - "piiDetection": "PII Detection", - "regexPattern": "Regex Pattern", "operation": "Operation", "id": "ID", "role": "Role", @@ -3001,12 +2964,6 @@ "enterMemoryIdentifierToDelete": "Enter memory identifier to delete", "selectAgentRole": "Select agent role", "enterMessageContent": "Enter message content", - "Enter the starting URL for the agent": "Enter the starting URL for the agent", - "enterTheStartingUrlForTheAgent": "Enter the starting URL for the agent", - "enterTheTaskOrGoalForTheAgentToAchieveReferenceVariablesUsingKeySyntax": "Enter the task or goal for the agent to achieve. Reference variables using %key% syntax.", - "Enter your Anthropic API key": "Enter your Anthropic API key", - "enterYourAnthropicApiKey": "Enter your Anthropic API key", - "knowledgeBase": "Knowledge Base", "selectKnowledgeBase": "Select knowledge base", "selectKnowledgeBases": "Select knowledge bases", "searchQuery": "Search Query", @@ -3022,13 +2979,13 @@ "searchTeams": "Search teams...", "searchKnowledgeBases": "Search knowledge bases...", "searchFiles": "Search files...", - "searchItems": "Search {{itemName}}...", - "loadingItems": "Loading {{itemName}}...", - "noItemsFound": "No {{itemName}} found.", - "noItemsFoundInService": "No {{itemName}} were found in your {{serviceName}}.", - "connectProviderAccountToContinue": "Connect a {{providerName}} account to continue.", - "connectProviderAccount": "Connect {{providerName}} account", - "openInProvider": "Open in {{providerName}}", + "searchItems": "Search {itemName}...", + "loadingItems": "Loading {itemName}...", + "noItemsFound": "No {itemName} found.", + "noItemsFoundInService": "No {itemName} were found in your {serviceName}.", + "connectProviderAccountToContinue": "Connect a {providerName} account to continue.", + "connectProviderAccount": "Connect {providerName} account", + "openInProvider": "Open in {providerName}", "openInDrive": "Open in Drive", "openInConfluence": "Open in Confluence", "openInJira": "Open in Jira", @@ -3059,25 +3016,11 @@ "selectContact": "Select contact", "selectItem": "Select item", "selectATeamFirst": "Select a team first", - "typeOrSelectModel": "Type or select a model...", - "confidence": "Confidence", "numberOfResults": "Number of Results", - "numberOfChunksToRetrieve": "Number of Chunks to Retrieve", - "enterYourApiKey": "Enter your API key", - "piiTypesToDetect": "PII Types to Detect", - "action": "Action", - "blockRequest": "Block Request", - "maskPii": "Mask PII", - "language": "Language", - "english": "English", - "spanish": "Spanish", - "italian": "Italian", - "polish": "Polish", - "finnish": "Finnish", "configurePiiTypes": "Configure PII Types", "noneSelected": "None selected", "allSelected": "All selected", - "selectedCount": "{{count}} selected", + "selectedCount": "{count} selected", "selectPiiTypesToDetect": "Select PII Types to Detect", "choosePiiTypesToDetect": "Choose which types of personally identifiable information to detect and block.", "selectAllEntities": "Select all entities", @@ -3147,7 +3090,7 @@ "contacts": "Contacts", "tagFilters": "Tag Filters", "noFilters": "No filters", - "filtersApplied": "{{count}} filter(s) applied", + "filtersApplied": "{count} filter(s) applied", "tagName": "Tag Name", "selectTag": "Select tag", "tryDifferentSearchOrAccount": "Try a different search or account.", @@ -3174,51 +3117,7 @@ "missingRequiredFields": "Missing required fields", "saveConfigReturnedFalse": "Save config returned false", "anErrorOccurredWhileSaving": "An error occurred while saving.", - "common": "Common", - "usa": "USA", - "uk": "UK", - "spain": "Spain", - "italy": "Italy", - "poland": "Poland", - "singapore": "Singapore", - "australia": "Australia", - "india": "India", "other": "Other", - "personName": "Person name", - "emailAddress": "Email address", - "phoneNumber": "Phone number", - "location": "Location", - "dateOrTime": "Date or time", - "ipAddress": "IP address", - "creditCardNumber": "Credit card number", - "internationalBankAccountNumber": "International bank account number (IBAN)", - "cryptocurrencyWalletAddress": "Cryptocurrency wallet address", - "medicalLicenseNumber": "Medical license number", - "nationalityReligionPoliticalGroup": "Nationality / religion / political group", - "usBankAccountNumber": "US bank account number", - "usDriverLicenseNumber": "US driver license number", - "usIndividualTaxpayerIdentificationNumber": "US individual taxpayer identification number (ITIN)", - "usPassportNumber": "US passport number", - "usSocialSecurityNumber": "US Social Security number", - "ukNationalInsuranceNumber": "UK National Insurance number", - "ukNhsNumber": "UK NHS number", - "spanishNifNumber": "Spanish NIF number", - "spanishNieNumber": "Spanish NIE number", - "italianFiscalCode": "Italian fiscal code", - "italianDriverLicense": "Italian driver license", - "italianIdentityCard": "Italian identity card", - "italianPassport": "Italian passport", - "polishPesel": "Polish PESEL", - "singaporeNricFin": "Singapore NRIC/FIN", - "australianBusinessNumber": "Australian business number (ABN)", - "australianCompanyNumber": "Australian company number (ACN)", - "australianTaxFileNumber": "Australian tax file number (TFN)", - "australianMedicareNumber": "Australian Medicare number", - "indianAadhaar": "Indian Aadhaar", - "indianPan": "Indian PAN", - "indianVehicleRegistration": "Indian vehicle registration", - "indianVoterNumber": "Indian voter number", - "indianPassport": "Indian passport", "Provider": "Provider", "Text": "Text", "Prompt": "Prompt", @@ -3268,7 +3167,6 @@ "Variable Assignments": "Variable Assignments", "Wait Amount": "Wait Amount", "Unit": "Unit", - "enterJsonSchema": "Enter JSON schema...", "describeTheAIAgentYouWantToCreate": "Describe the AI agent you want to create...", "enterSystemPrompt": "Enter system prompt...", "enterContextOrUserMessage": "Enter context or user message...", @@ -3309,7 +3207,15 @@ "eventPayloadExample": "Event Payload Example", "webhookUrlWillBeGenerated": "Webhook URL will be generated", "setupInstructions": "Setup Instructions", - "enabled": "Enabled" + "enabled": "Enabled", + "pleaseSelectSharePointCredentialsFirst": "Please select SharePoint credentials first", + "pleaseSelectMicrosoftPlannerCredentialsFirst": "Please select Microsoft Planner credentials first", + "pleaseEnterAPlanIdFirst": "Please enter a Plan ID first", + "pleaseSelectMicrosoftTeamsCredentialsFirst": "Please select Microsoft Teams credentials first", + "pleaseSelectWealthboxCredentialsFirst": "Please select Wealthbox credentials first", + "pleaseSelectGoogleDriveCredentialsFirst": "Please select Google Drive credentials first", + "url": "URL", + "value": "Value" }, "blockEditor": { "blockNames": { @@ -3465,7 +3371,9 @@ "youtube": "YouTube", "zendesk": "Zendesk", "zep": "Zep", - "zoom": "Zoom" + "zoom": "Zoom", + "portfolio_detail": "Portfolio Detail", + "watchlist": "Watchlist" }, "blockDescriptions": { "response": "Send structured API response", @@ -3618,7 +3526,9 @@ "youtube": "Interact with YouTube videos, channels, and playlists", "zendesk": "Manage support tickets, users, and organizations in Zendesk", "zep": "Long-term memory for AI agents", - "zoom": "Create and manage Zoom meetings and recordings" + "zoom": "Create and manage Zoom meetings and recordings", + "portfolio_detail": "Fetch full portfolio detail from a selected broker account.", + "watchlist": "Read watchlists and add or remove listing items." }, "blockLongDescriptions": { "response": "Integrate Response into the workflow. Can send build or edit structured responses into a final workflow response.", @@ -3755,10 +3665,7 @@ }, "headers": { "title": "Response Headers", - "columns": [ - "Key", - "Value" - ], + "columns": ["Key", "Value"], "description": "Additional HTTP headers to include in the response" } }, @@ -15031,11 +14938,6 @@ "title": "URL" } }, - "stagehand_agent": { - "variables": { - "title": "Variables" - } - }, "stripe": { "active": { "options": [ @@ -17997,6 +17899,299 @@ "waitingRoom": { "title": "Waiting Room" } + }, + "stagehand_agent": { + "startUrl": { + "title": "Starting URL", + "placeholder": "Enter the starting URL for the agent" + }, + "task": { + "title": "Task", + "placeholder": "Enter the task or goal for the agent to achieve. Reference variables using %key% syntax." + }, + "variables": { + "title": "Variables", + "columns": ["Key", "Value"] + }, + "apiKey": { + "title": "Anthropic API Key", + "placeholder": "Enter your Anthropic API key" + }, + "outputSchema": { + "title": "Output Schema", + "placeholder": "Enter JSON schema..." + } + }, + "guardrails": { + "input": { + "title": "Content to Validate", + "placeholder": "Enter content to validate" + }, + "validationType": { + "title": "Validation Type", + "options": [ + { + "id": "json", + "label": "Valid JSON" + }, + { + "id": "regex", + "label": "Regex Match" + }, + { + "id": "hallucination", + "label": "Hallucination Check" + }, + { + "id": "pii", + "label": "PII Detection" + } + ] + }, + "regex": { + "title": "Regex Pattern" + }, + "knowledgeBaseId": { + "title": "Knowledge Base", + "placeholder": "Select knowledge base" + }, + "model": { + "title": "Model", + "placeholder": "Type or select a model..." + }, + "threshold": { + "title": "Confidence" + }, + "topK": { + "title": "Number of Chunks to Retrieve" + }, + "apiKey": { + "title": "API Key", + "placeholder": "Enter your API key" + }, + "piiEntityTypes": { + "title": "PII Types to Detect", + "options": [ + { + "id": "PERSON", + "label": "Person name", + "group": "Common" + }, + { + "id": "EMAIL_ADDRESS", + "label": "Email address", + "group": "Common" + }, + { + "id": "PHONE_NUMBER", + "label": "Phone number", + "group": "Common" + }, + { + "id": "LOCATION", + "label": "Location", + "group": "Common" + }, + { + "id": "DATE_TIME", + "label": "Date or time", + "group": "Common" + }, + { + "id": "IP_ADDRESS", + "label": "IP address", + "group": "Common" + }, + { + "id": "URL", + "label": "URL", + "group": "Common" + }, + { + "id": "CREDIT_CARD", + "label": "Credit card number", + "group": "Common" + }, + { + "id": "IBAN_CODE", + "label": "International bank account number (IBAN)", + "group": "Common" + }, + { + "id": "CRYPTO", + "label": "Cryptocurrency wallet address", + "group": "Common" + }, + { + "id": "MEDICAL_LICENSE", + "label": "Medical license number", + "group": "Common" + }, + { + "id": "NRP", + "label": "Nationality / religion / political group", + "group": "Common" + }, + { + "id": "US_BANK_NUMBER", + "label": "US bank account number", + "group": "USA" + }, + { + "id": "US_DRIVER_LICENSE", + "label": "US driver license number", + "group": "USA" + }, + { + "id": "US_ITIN", + "label": "US individual taxpayer identification number (ITIN)", + "group": "USA" + }, + { + "id": "US_PASSPORT", + "label": "US passport number", + "group": "USA" + }, + { + "id": "US_SSN", + "label": "US Social Security number", + "group": "USA" + }, + { + "id": "UK_NINO", + "label": "UK National Insurance number", + "group": "UK" + }, + { + "id": "UK_NHS", + "label": "UK NHS number", + "group": "UK" + }, + { + "id": "ES_NIF", + "label": "Spanish NIF number", + "group": "Spain" + }, + { + "id": "ES_NIE", + "label": "Spanish NIE number", + "group": "Spain" + }, + { + "id": "IT_FISCAL_CODE", + "label": "Italian fiscal code", + "group": "Italy" + }, + { + "id": "IT_DRIVER_LICENSE", + "label": "Italian driver license", + "group": "Italy" + }, + { + "id": "IT_IDENTITY_CARD", + "label": "Italian identity card", + "group": "Italy" + }, + { + "id": "IT_PASSPORT", + "label": "Italian passport", + "group": "Italy" + }, + { + "id": "PL_PESEL", + "label": "Polish PESEL", + "group": "Poland" + }, + { + "id": "SG_NRIC_FIN", + "label": "Singapore NRIC/FIN", + "group": "Singapore" + }, + { + "id": "AU_ABN", + "label": "Australian business number (ABN)", + "group": "Australia" + }, + { + "id": "AU_ACN", + "label": "Australian company number (ACN)", + "group": "Australia" + }, + { + "id": "AU_TFN", + "label": "Australian tax file number (TFN)", + "group": "Australia" + }, + { + "id": "AU_MEDICARE", + "label": "Australian Medicare number", + "group": "Australia" + }, + { + "id": "IN_AADHAAR", + "label": "Indian Aadhaar", + "group": "India" + }, + { + "id": "IN_PAN", + "label": "Indian PAN", + "group": "India" + }, + { + "id": "IN_VEHICLE_REGISTRATION", + "label": "Indian vehicle registration", + "group": "India" + }, + { + "id": "IN_VOTER", + "label": "Indian voter number", + "group": "India" + }, + { + "id": "IN_PASSPORT", + "label": "Indian passport", + "group": "India" + } + ] + }, + "piiMode": { + "title": "Action", + "options": [ + { + "id": "block", + "label": "Block Request" + }, + { + "id": "mask", + "label": "Mask PII" + } + ] + }, + "piiLanguage": { + "title": "Language", + "options": [ + { + "id": "en", + "label": "English" + }, + { + "id": "es", + "label": "Spanish" + }, + { + "id": "it", + "label": "Italian" + }, + { + "id": "pl", + "label": "Polish" + }, + { + "id": "fi", + "label": "Finnish" + } + ] + } } }, "variablesInput": { @@ -18008,7 +18203,7 @@ "noVariablesDefinedInWorkflow": "No variables defined in this workflow.", "addVariablesInPanel": "Add them in the Variables panel.", "valueLabel": "Value", - "typedValuePlaceholder": "{{type}} value", + "typedValuePlaceholder": "{type} value", "allVariablesAssignedButton": "All Variables Assigned", "addVariableAssignment": "Add Variable Assignment", "objectValuePlaceholder": "{\n \"key\": \"value\"\n}", @@ -18021,7 +18216,7 @@ "selectedWorkflowNeedsInputTrigger": "The selected workflow needs an Input Trigger with defined fields" }, "evalInput": { - "metricLabel": "Metric {{index}}", + "metricLabel": "Metric {index}", "addMetric": "Add Metric", "deleteMetric": "Delete Metric", "name": "Name", @@ -18032,7 +18227,7 @@ "maxValue": "Max Value" }, "inputFormat": { - "addTitle": "Add {{title}}", + "addTitle": "Add {title}", "deleteField": "Delete Field", "name": "Name", "type": "Type", @@ -18112,12 +18307,12 @@ }, "oauthRequiredModal": { "additionalAccessRequired": "Additional Access Required", - "toolRequiresAccess": "The \"{{toolName}}\" tool requires access to your {{providerName}} account to function properly.", - "connectProvider": "Connect {{providerName}}", - "connectProviderDescription": "You need to connect your {{providerName}} account to continue", + "toolRequiresAccess": "The \"{toolName}\" tool requires access to your {providerName} account to function properly.", + "connectProvider": "Connect {providerName}", + "connectProviderDescription": "You need to connect your {providerName} account to continue", "permissionsRequested": "Permissions requested", "cancel": "Cancel", - "connectService": "Connect {{serviceName}}", + "connectService": "Connect {serviceName}", "connectNow": "Connect Now" }, "dropdown": { @@ -18179,19 +18374,19 @@ "errors": { "failedToFetchDocuments": "Failed to fetch documents" }, - "chunkCountSingular": "{{count}} chunk", - "chunkCountPlural": "{{count}} chunks" + "chunkCountSingular": "{count} chunk", + "chunkCountPlural": "{count} chunks" }, "knowledgeTagFilters": { "addFilter": "Add Filter", - "appliedCountSingular": "{{count}} filter applied", - "appliedCountPlural": "{{count}} filters applied" + "appliedCountSingular": "{count} filter applied", + "appliedCountPlural": "{count} filters applied" }, "documentTagEntry": { "typeText": "Text", "prefillExistingTags": "Prefill Existing Tags", "addTag": "Add Tag", - "tagSlotsUsed": "{{used}} of {{total}} tag slots used" + "tagSlotsUsed": "{used} of {total} tag slots used" }, "confluenceFileSelector": { "errors": { @@ -18274,9 +18469,9 @@ "addTool": "Add Tool", "searchTools": "Search tools...", "account": "Account", - "selectProviderAccount": "Select {{provider}} account", - "selectParameter": "Select {{label}}", - "enterParameter": "Enter {{label}}", + "selectProviderAccount": "Select {provider} account", + "selectParameter": "Select {label}", + "enterParameter": "Enter {label}", "enterJsonArrayOrCommaSeparatedValues": "Enter JSON array, e.g. [\"item1\", \"item2\"] or comma-separated values", "selectToolToConfigureParameters": "Select a tool to configure its parameters", "loadingToolSchema": "Loading tool schema...", @@ -18308,8 +18503,8 @@ "unsupportedValue": "Unsupported value" }, "triggerWarning": { - "duplicateTitle": "Only one {{triggerName}} trigger allowed", - "duplicateDescription": "A workflow can only have one {{triggerName}} trigger block. Please remove the existing one before adding a new one.", + "duplicateTitle": "Only one {triggerName} trigger allowed", + "duplicateDescription": "A workflow can only have one {triggerName} trigger block. Please remove the existing one before adding a new one.", "dismiss": "Got it" }, "templateModal": { @@ -18421,8 +18616,8 @@ "title": "Webhook Notifications", "searchPlaceholder": "Search webhooks...", "emptyState": "Click \"Add Webhook\" below to get started", - "webhookLabel": "Webhook {{index}}", - "noSearchMatches": "No webhooks found matching \"{{query}}\"", + "webhookLabel": "Webhook {index}", + "noSearchMatches": "No webhooks found matching \"{query}\"", "actions": { "copyUrl": "Copy webhook URL", "test": "Test webhook", @@ -18527,7 +18722,7 @@ "validationFailed": "Validation failed. Review the webhook settings and try again." }, "testStatus": { - "success": "Test webhook sent successfully ({{status}})", + "success": "Test webhook sent successfully ({status})", "failure": "Test webhook failed.", "sendFailed": "Failed to send test webhook" }, @@ -20147,7 +20342,7 @@ "selectBlockToViewPreviewDetails": "Select a block to view its preview details.", "nodeNotFound": "Node not found", "selectedNodeUnavailable": "The selected node is no longer available.", - "missingBlockConfiguration": "Missing block configuration for `{{type}}`.", + "missingBlockConfiguration": "Missing block configuration for `{type}`.", "controlsUnavailable": "Controls unavailable", "saveName": "Save name", "renameNode": "Rename node", @@ -20169,7 +20364,7 @@ "collectionItems": "Collection Items", "collectionItemsPlaceholder": "['item1', 'item2', 'item3']", "parallelItems": "Parallel Items", - "enterValueBetween": "Enter a value between 1 and {{max}}", + "enterValueBetween": "Enter a value between 1 and {max}", "hideAdditionalFields": "Hide additional fields", "showAdditionalFields": "Show additional fields", "additionalFields": "Additional fields", @@ -20177,7 +20372,7 @@ "blockNoEditableFields": "No editable fields for this block.", "requiredField": "This field is required", "invalidJson": "Invalid JSON", - "unknownInputType": "Unknown input type: {{type}}", + "unknownInputType": "Unknown input type: {type}", "loop": "Loop", "parallel": "Parallel", "start": "Start", @@ -20194,7 +20389,7 @@ "checkingWorkflowPermissions": "Checking workflow permissions", "writePermissionRequiredToRunWorkflows": "Write permission required to run workflows", "usageLimitExceeded": "Usage Limit Exceeded", - "usageLimitExceededDescription": "You've used {{currentUsage}}$ of {{limit}}$. Upgrade your plan to continue.", + "usageLimitExceededDescription": "You've used {currentUsage}$ of {limit}$. Upgrade your plan to continue.", "run": "Run" }, "floatingControls": { @@ -20212,7 +20407,7 @@ }, "summary": { "objectItem": "[Object]", - "additionalCount": "+{{count}} more" + "additionalCount": "+{count} more" }, "selectWorkspaceToLoadWorkflows": "Select a workspace to load workflows.", "noWorkflowsAvailable": "No workflows available in this workspace.", @@ -20232,7 +20427,7 @@ "close": "Close", "coreTriggers": "Core Triggers", "integrationTriggers": "Integration Triggers", - "noResultsFound": "No results found for \"{{query}}\"" + "noResultsFound": "No results found for \"{query}\"" }, "connectionStatus": { "reconnected": "Reconnected", @@ -20241,8 +20436,8 @@ "refreshPageToContinueEditing": "Refresh page to continue editing" }, "triggerWarning": { - "duplicateTitle": "Only one {{triggerName}} trigger allowed", - "duplicateDescription": "A workflow can only have one {{triggerName}} trigger block. Please remove the existing one before adding a new one.", + "duplicateTitle": "Only one {triggerName} trigger allowed", + "duplicateDescription": "A workflow can only have one {triggerName} trigger block. Please remove the existing one before adding a new one.", "dismiss": "Got it" } }, @@ -20299,7 +20494,7 @@ "deployApi": "Deploy API", "needsRedeployment": "Needs Redeployment", "deployWorkflowTitle": "Deploy Workflow", - "deployVersion": "Deploy {{versionName}}", + "deployVersion": "Deploy {versionName}", "active": "active", "inactive": "Inactive", "close": "Close", @@ -20337,7 +20532,7 @@ "reviewTriggerBeforeDeployment": "Review this trigger before deployment. No additional configuration is required.", "triggerConfigurationUnavailable": "Trigger configuration is unavailable.", "noAdditionalTriggerConfigurationRequired": "This trigger deploys with the workflow. No additional configuration is required.", - "completeRequiredFieldsBeforeDeploying": "Complete required fields before deploying: {{fields}}.", + "completeRequiredFieldsBeforeDeploying": "Complete required fields before deploying: {fields}.", "saveTriggerConfigurationBeforeDeploying": "Save this trigger configuration to provision its webhook before deploying.", "triggerSettingsChangedSinceLastSave": "Trigger settings changed since the last save. Save the trigger before deploying.", "triggerConfigurationReady": "Trigger configuration looks ready. Review the values below before deploying.", @@ -20391,7 +20586,7 @@ "failedToSavePassword": "Failed to save chat password", "auth": { "accessControl": "Access Control", - "selectAccessAriaLabel": "Select {{type}} access", + "selectAccessAriaLabel": "Select {type} access", "publicAccess": "Public Access", "publicAccessDescription": "Anyone can access your chat", "passwordProtected": "Password Protected", @@ -20440,8 +20635,8 @@ "tooltip": "Select market data provider", "selectionUnavailable": "Provider selection unavailable", "noProviders": "No providers", - "selectedLabel": "Market: {{providerName}}", - "fallbackProviderName": "Market" + "selectedLabel": "Market: {providerName}", + "defaultProviderName": "Market" }, "tradingSelector": { "placeholder": "Select Trading Provider", @@ -20449,8 +20644,8 @@ "tooltip": "Select broker", "selectionUnavailable": "Provider selection unavailable", "noProviders": "No providers", - "selectedLabel": "Broker: {{providerName}}", - "fallbackProviderName": "Broker" + "selectedLabel": "Broker: {providerName}", + "defaultProviderName": "Broker" }, "accountSelector": { "placeholder": "Select account", @@ -20459,24 +20654,24 @@ "loadingAccount": "Loading account...", "loadingProviderConnection": "Loading provider connection...", "unableToLoadProviderConnection": "Unable to load provider connection.", - "selectConnection": "Select a {{providerName}} connection.", - "noAccountConnected": "No {{providerName}} account connected.", + "selectConnection": "Select a {providerName} connection.", + "noAccountConnected": "No {providerName} account connected.", "loadingBrokerAccounts": "Loading broker accounts...", "unableToLoadBrokerAccounts": "Unable to load broker accounts.", "noBrokerAccountsFound": "No broker accounts found.", - "reconnectAccount": "Reconnect {{providerName}} account", - "connectAccount": "Connect {{providerName}} account", - "fallbackProviderName": "broker" + "reconnectAccount": "Reconnect {providerName} account", + "connectAccount": "Connect {providerName} account", + "defaultProviderName": "broker" }, "settingsButton": { - "triggerLabel": "Configure {{providerName}} provider", + "triggerLabel": "Configure {providerName} provider", "triggerTooltip": "Provider settings", "title": "Provider settings", "description": "Save credentials for this widget.", "select": "Select", "save": "Save", "cancel": "Cancel", - "fallbackProviderName": "Market" + "defaultProviderName": "Market" } }, "watchlist": { @@ -20502,10 +20697,10 @@ "selectLabel": "Select watchlist", "searchPlaceholder": "Search watchlists...", "noWatchlistsFound": "No watchlists found.", - "renameAriaLabel": "Rename {{name}}", - "deleteAriaLabel": "Delete {{name}}", + "renameAriaLabel": "Rename {name}", + "deleteAriaLabel": "Delete {name}", "deleteDialogTitle": "Delete watchlist?", - "deleteDialogDescription": "This action will permanently delete \"{{name}}\".", + "deleteDialogDescription": "This action will permanently delete \"{name}\".", "deleteDialogDescriptionFallback": "This action will permanently delete this watchlist.", "cancel": "Cancel", "delete": "Delete" @@ -20529,7 +20724,7 @@ "collapseSection": "Collapse section", "expandSection": "Expand section", "deleteSymbolDialogTitle": "Delete symbol?", - "deleteSymbolDialogDescription": "Removing {{name}} will delete it from the watchlist.", + "deleteSymbolDialogDescription": "Removing {name} will delete it from the watchlist.", "deleteSymbolDialogDescriptionFallback": "Removing this symbol will delete it from the watchlist.", "deleteSymbolDialogDescriptionHighlight": "This action cannot be undone.", "deleteSectionDialogTitle": "Delete section?", @@ -20583,10 +20778,10 @@ }, "validation": { "nameRequired": "Skill name is required.", - "nameTooLong": "Skill name must be {{max}} characters or fewer.", + "nameTooLong": "Skill name must be {max} characters or fewer.", "descriptionRequired": "Skill description is required.", "contentRequired": "Skill content is required.", - "duplicateName": "A skill named \"{{name}}\" already exists.", + "duplicateName": "A skill named \"{name}\" already exists.", "saveFailed": "Failed to save skill." }, "form": { @@ -20746,7 +20941,7 @@ "schemaMustBeFunctionType": "Schema must have a \"type\" field set to \"function\"", "schemaMustHaveFunctionName": "Schema must have a \"function\" object with a \"name\" field", "failedToValidateSchema": "Failed to validate custom tool schema. Please check your inputs and try again.", - "duplicateName": "A tool with the name \"{{name}}\" already exists", + "duplicateName": "A tool with the name \"{name}\" already exists", "failedToSave": "Failed to save custom tool. Please check your inputs and try again." }, "form": { @@ -20822,10 +21017,10 @@ "refreshingQuotes": "Refreshing quotes", "noHoldingsWithMarketListings": "No holdings with market listings", "noMarketProvider": "No market provider", - "quoteMetricsUseFirst": "Quote metrics use first {{cap}} of {{total}} holdings", - "quotedPositionsSummary": "{{quoted}}/{{total}} quoted", + "quoteMetricsUseFirst": "Quote metrics use first {cap} of {total} holdings", + "quotedPositionsSummary": "{quoted}/{total} quoted", "performanceHistoryUnavailable": "Performance history is unavailable for the selected account.", - "asOf": "As of {{date}}", + "asOf": "As of {date}", "return": "Return", "start": "Start", "current": "Current", @@ -20847,7 +21042,7 @@ "orderSubmitted": "Order submitted", "submitting": "Submitting...", "orderPrefix": "Order", - "submitOrder": "Submit {{side}} Order", + "submitOrder": "Submit {side} Order", "unknown": "unknown" } }, @@ -20897,15 +21092,15 @@ "selectTimeInForce": "Select a time in force.", "selectOrderSize": "Select order size.", "notionalSizingIsNotSupportedForThisOrderType": "Notional sizing is not supported for this order type.", - "notionalSizingRequires": "Notional sizing requires {{values}}.", - "fieldNotSupportedForThisOrderType": "{{field}} is not supported for this order type.", - "enterValid": "Enter a valid {{field}}.", - "oneOfFieldsRequired": "{{fields}} is required.", + "notionalSizingRequires": "Notional sizing requires {values}.", + "fieldNotSupportedForThisOrderType": "{field} is not supported for this order type.", + "enterValid": "Enter a valid {field}.", + "oneOfFieldsRequired": "{fields} is required.", "marketPrice": "Market Price", "notional": "Notional", "quantity": "Quantity", "orderType": "Order Type", - "chooseHowTo": "Choose how to {{side}}", + "chooseHowTo": "Choose how to {side}", "timeInForce": "Time in Force", "limitPrice": "Limit Price", "stopPrice": "Stop Price", @@ -20923,8 +21118,8 @@ "orderSubmitted": "Order submitted", "submitting": "Submitting...", "orderPrefix": "Order", - "sideLabel": "{{side}}", - "submitOrder": "Submit {{side}} Order" + "sideLabel": "{side}", + "submitOrder": "Submit {side} Order" } }, "dataChart": { @@ -20964,7 +21159,7 @@ "timezone": { "exchange": "Exchange", "utc": "UTC", - "tooltip": "Timezone: {{timezone}}", + "tooltip": "Timezone: {timezone}", "tooltipFallback": "Exchange timezone", "searchPlaceholder": "Search timezones...", "loading": "Loading timezones...", @@ -20972,7 +21167,7 @@ }, "range": { "allAvailableData": "All available data", - "rangeIntervalTooltip": "{{range}} in {{interval}} interval", + "rangeIntervalTooltip": "{range} in {interval} interval", "presets": { "1d": "1D", "5d": "5D", @@ -21012,7 +21207,7 @@ }, "normalization": { "ariaLabel": "Normalization", - "tooltip": "Normalization: {{mode}}", + "tooltip": "Normalization: {mode}", "unavailable": "Normalization unavailable", "noOptions": "No normalization options.", "modes": { @@ -21030,7 +21225,7 @@ "freehand": "Freehand tools", "shapes": "Shapes tools" }, - "unavailable": "{{tool}} is unavailable in this session", + "unavailable": "{tool} is unavailable in this session", "tools": { "TrendLine": "Trend line", "Ray": "Ray", @@ -21075,14 +21270,14 @@ "removeIndicator": "Remove indicator", "remove": "Remove", "errorTitle": "Indicator error", - "compileFailed": "{{name}} failed to compile.", + "compileFailed": "{name} failed to compile.", "errorGuidance": "Check the indicator inputs or script, then try again.", "settingsSubtitle": "Indicator settings", "close": "Close", "noConfigurableInputs": "No configurable inputs.", "cancel": "Cancel", "save": "Save", - "plotFallback": "Plot {{index}}", + "plotFallback": "Plot {index}", "executionErrorFallback": "Failed to execute indicators", "metadataLabels": { "Length": "Length", @@ -21105,7 +21300,7 @@ "close": "C:" }, "listingOverlay": { - "flagAlt": "{{countryCode}} flag" + "flagAlt": "{countryCode} flag" }, "errors": { "failedToLoadSeriesData": "Failed to load series data", @@ -21140,7 +21335,7 @@ "label": "Listing", "listingFallback": "Listing", "selectListing": "Select listing", - "flagAlt": "{{countryCode}} flag", + "flagAlt": "{countryCode} flag", "searching": "Searching...", "noListingsFound": "No listings found.", "searchPlaceholder": "Search listings..." @@ -21148,8 +21343,8 @@ }, "layoutTabs": { "createNewLayout": "Create new layout", - "renameAriaLabel": "Rename {{name}}", - "deleteAriaLabel": "Delete {{name}}" + "renameAriaLabel": "Rename {name}", + "deleteAriaLabel": "Delete {name}" }, "monitor": { "title": "Monitor", @@ -21180,7 +21375,7 @@ "errors": { "activateView": "Failed to activate view", "configViewsUnavailable": "Config views are unavailable right now.", - "createDefaultView": "Unable to create default {{name}} view.", + "createDefaultView": "Unable to create default {name} view.", "createMonitor": "Failed to create monitor", "createView": "Failed to create view", "deleteMonitor": "Failed to delete monitor", @@ -21282,13 +21477,13 @@ "today": "Today", "boundaries": "Boundaries", "todayAndBoundaries": "Today + Boundaries", - "shownCount": "{{count}} shown", - "itemsCount": "{{count}} items", - "executionsCount": "{{count}} executions", - "setCount": "{{count}} set", + "shownCount": "{count} shown", + "itemsCount": "{count} items", + "executionsCount": "{count} executions", + "setCount": "{count} set", "all": "All", "allExecutions": "All executions", - "monitorsCount": "{{count}} monitors", + "monitorsCount": "{count} monitors", "monitorControls": "Monitor controls" }, "execution": { @@ -21302,7 +21497,7 @@ "closeInspector": "Close inspector", "kanban": "Kanban", "timeline": "Timeline", - "limitLabel": "Limit {{count}}", + "limitLabel": "Limit {count}", "running": "Running", "unknown": "Unknown", "unknownListing": "Unknown listing", @@ -21322,7 +21517,7 @@ "loadingRecords": "Loading monitor records...", "noExecutions": "No executions", "noOutcome": "No outcome", - "addMonitorIn": "Add monitor in {{title}}" + "addMonitorIn": "Add monitor in {title}" }, "configSearch": { "activeMonitors": "Active monitors", @@ -21331,7 +21526,7 @@ "hasLastExecutionLog": "Has last execution log", "hasLastOutcome": "Has last outcome", "invalidTokensPrefix": "Invalid config query tokens", - "lastOutcome": "Last outcome: {{outcome}}", + "lastOutcome": "Last outcome: {outcome}", "noLastExecution": "No last execution", "noLastExecutionLog": "No last execution log", "noLastOutcome": "No last outcome", @@ -21346,7 +21541,7 @@ "loading": "Loading timezones...", "placeholder": "UTC", "searchPlaceholder": "Search timezones...", - "triggerLabel": "Timezone: {{label}}" + "triggerLabel": "Timezone: {label}" }, "editor": { "createTitle": "Create Monitor", @@ -21394,7 +21589,7 @@ "scaleLabel": "Scale", "scaleAriaLabel": "Timeline scale", "searchZoomLevels": "Search zoom levels...", - "zoomLabel": "Zoom: {{zoom}}", + "zoomLabel": "Zoom: {zoom}", "scrollPrevious": "Scroll to previous date range", "scrollNext": "Scroll to next date range", "groupsTitle": "Groups", @@ -21413,9 +21608,9 @@ "navigationAriaLabel": "Chat header", "titleFallback": "TradingGoose Chat", "brandName": "TradingGoose", - "logoAlt": "Logo for {{title}}", - "githubRepositoryAriaLabel": "View the TradingGoose repository on GitHub with {{stars}} stars", - "homeAriaLabel": "Go to {{brand}} home" + "logoAlt": "Logo for {title}", + "githubRepositoryAriaLabel": "View the TradingGoose repository on GitHub with {stars} stars", + "homeAriaLabel": "Go to {brand} home" }, "error": { "title": "Chat unavailable", @@ -21453,8 +21648,8 @@ "startVoiceConversation": "Start voice conversation", "send": "Send", "stop": "Stop", - "fileTooLarge": "{{name}} is too large.", - "fileAlreadyAdded": "{{name}} is already attached." + "fileTooLarge": "{name} is too large.", + "fileAlreadyAdded": "{name} is already attached." }, "auth": { "password": { @@ -21480,7 +21675,7 @@ "title": "Enter your email", "verifyTitle": "Verify your email", "description": "This chat uses email verification.", - "verifiedDescription": "We sent a verification code to {{email}}.", + "verifiedDescription": "We sent a verification code to {email}.", "label": "Email", "placeholder": "Enter your email", "submit": "Send code", @@ -21489,7 +21684,7 @@ "verifying": "Verifying...", "instructions": "Enter the 6-digit code we sent you.", "resendPrompt": "Didn't receive a code?", - "resendIn": "Resend in {{countdown}}s", + "resendIn": "Resend in {countdown}s", "resend": "Resend code", "changeEmail": "Change email", "validation": { @@ -21621,7 +21816,7 @@ "searchPlaceholder": "Search waitlist entries...", "mode": "Mode", "loading": "Loading registration settings...", - "selectedCount": "{{count}} selected", + "selectedCount": "{count} selected", "submitted": "Submitted", "timeRanges": { "all": "All time", @@ -21651,7 +21846,7 @@ }, "emptyState": "No waitlist entries match the current search.", "selectVisible": "Select visible waitlist entries", - "selectEntry": "Select {{email}}", + "selectEntry": "Select {email}", "never": "Never", "error": "Something went wrong", "modes": { @@ -21683,8 +21878,8 @@ "title": "Settings", "description": "Configure runtime behavior and endpoint defaults for this service.", "none": "This service does not expose stored settings.", - "storedValue": "Stored value: {{value}}", - "defaultValue": "Default: {{value}}", + "storedValue": "Stored value: {value}", + "defaultValue": "Default: {value}", "notConfigured": "Not configured", "notSet": "Not set", "enabled": "Enabled", @@ -21696,21 +21891,21 @@ "optional": "Optional" }, "placeholders": { - "enterValue": "Enter {{label}}", - "replaceValue": "Enter a new {{label}} to replace the stored value" + "enterValue": "Enter {label}", + "replaceValue": "Enter a new {label} to replace the stored value" }, "actions": { - "saveField": "Save {{label}}", - "cancelEditingField": "Cancel editing {{label}}", - "editField": "Edit {{label}}", - "clearField": "Clear {{label}}" + "saveField": "Save {label}", + "cancelEditingField": "Cancel editing {label}", + "editField": "Edit {label}", + "clearField": "Clear {label}" }, "summary": { - "requiredCredentialsSet": "{{configured}}/{{total}} required credentials set", + "requiredCredentialsSet": "{configured}/{total} required credentials set", "noRequiredCredentials": "No required credentials", - "requiredSettingsResolved": "{{configured}}/{{total}} required settings resolved", + "requiredSettingsResolved": "{configured}/{total} required settings resolved", "noRequiredSettings": "No required settings", - "missing": "Missing {{labels}}." + "missing": "Missing {labels}." }, "footer": { "saving": "Saving changes...", @@ -21740,29 +21935,29 @@ "description": "Set the secrets for this system-managed OAuth provider.", "none": "This provider does not require stored credentials.", "noMatches": "No credentials match the current search.", - "fallbackDescription": "Provider credential" + "defaultDescription": "Provider credential" }, "services": { "title": "OAuth services", "description": "Enable or disable the services that inherit this provider's credentials.", "none": "This provider does not expose any OAuth services.", "noMatches": "No services match the current search.", - "inheritsFrom": "Inherits credentials from {{name}}." + "inheritsFrom": "Inherits credentials from {name}." }, "placeholders": { - "enterValue": "Enter {{label}}", - "replaceValue": "Enter a new {{label}} to replace the stored value" + "enterValue": "Enter {label}", + "replaceValue": "Enter a new {label} to replace the stored value" }, "summary": { - "serviceCount": "{{count}} service{{plural}}", + "serviceCount": "{count} service{plural}", "servicePlural": "s", - "requiredCredentialsSet": "{{configured}}/{{total}} required credentials set", - "credentialsSet": "{{count}} credential{{plural}} set", + "requiredCredentialsSet": "{configured}/{total} required credentials set", + "credentialsSet": "{count} credential{plural} set", "credentialPlural": "s", "noRequiredCredentials": "No required credentials", - "enabledCount": "{{count}} enabled", + "enabledCount": "{count} enabled", "noServicesAvailable": "No services available", - "missing": "Missing {{labels}}." + "missing": "Missing {labels}." }, "error": "Something went wrong" }, @@ -21788,21 +21983,21 @@ "organization": "Organization", "userOwner": "User owner", "organizationOwner": "Organization owner", - "ownerLabel": "{{owner}} owner" + "ownerLabel": "{owner} owner" }, "usageScopes": { "individual": "Individual", "pooled": "Pooled", "individualUsage": "Individual usage", "pooledUsage": "Pooled usage", - "usageLabel": "{{scope}} usage" + "usageLabel": "{scope} usage" }, "seatModes": { "fixed": "Fixed", "adjustable": "Adjustable", "fixedSeats": "Fixed seats", "adjustableSeats": "Adjustable seats", - "seatBillingLabel": "{{mode}} seat billing" + "seatBillingLabel": "{mode} seat billing" }, "commerce": { "custom": "Custom", @@ -21810,23 +22005,23 @@ "selfServe": "Self-serve", "contactSales": "Contact sales", "priceUnset": "Price unset", - "monthlyPrice": "{{amount}} monthly", - "yearlyPrice": "{{amount}} yearly", - "stripeLinks": "{{count}}/3 Stripe links", - "usdIncluded": "{{value}} USD included", - "gbStorage": "{{value}} GB storage", - "concurrent": "{{value}} concurrent", - "includedUsageLabel": "{{amount}} included", - "storageLimitLabel": "{{value}} GB storage", - "concurrencyLabel": "{{value}} concurrent", - "workflowExecutionLabel": "{{value}}x workflow execution", - "workflowModelsLabel": "{{value}}x workflow models", - "functionRuntimeLabel": "{{value}}x function runtime", - "copilotLabel": "{{value}}x copilot", + "monthlyPrice": "{amount} monthly", + "yearlyPrice": "{amount} yearly", + "stripeLinks": "{count}/3 Stripe links", + "usdIncluded": "{value} USD included", + "gbStorage": "{value} GB storage", + "concurrent": "{value} concurrent", + "includedUsageLabel": "{amount} included", + "storageLimitLabel": "{value} GB storage", + "concurrencyLabel": "{value} concurrent", + "workflowExecutionLabel": "{value}x workflow execution", + "workflowModelsLabel": "{value}x workflow models", + "functionRuntimeLabel": "{value}x function runtime", + "copilotLabel": "{value}x copilot", "seatCountUnset": "Seat count unset", - "fixedSeatsCount": "{{count}} fixed seats", - "baseSeatsCount": "{{count}} base seats", - "maxSeatsCount": "{{count}} max seats", + "fixedSeatsCount": "{count} fixed seats", + "baseSeatsCount": "{count} base seats", + "maxSeatsCount": "{count} max seats", "noSelfServeSeatChanges": "No self-serve seat changes", "unlimitedSeats": "Unlimited seats" }, @@ -21834,7 +22029,7 @@ "searchPlaceholder": "Search tiers...", "createTier": "Create tier", "loadingInventory": "Loading billing inventory...", - "subscriptionCount": "{{count}} subscriptions", + "subscriptionCount": "{count} subscriptions", "currentTiersTitle": "Current tiers", "currentTiersDescription": "Open a tier to update pricing, availability, customer limits, and included usage.", "emptyTitle": "Create your first billing tier", @@ -21848,8 +22043,8 @@ "default": "Default", "notSet": "Not set", "rates": "Rates", - "workflowRunRate": "Workflow/run ${{amount}}", - "functionSecondRate": "Function/sec ${{amount}}" + "workflowRunRate": "Workflow/run ${amount}", + "functionSecondRate": "Function/sec ${amount}" } }, "create": { @@ -22029,10 +22224,10 @@ "copilotCostMultiplierBlank": "Leave blank for the default 1x." }, "summaries": { - "missing": "Missing: {{items}}", + "missing": "Missing: {items}", "untitledTier": "Untitled tier", "noPricingBullets": "No pricing bullets", - "pricingBullets": "{{count}} pricing bullets", + "pricingBullets": "{count} pricing bullets", "defaultTierMustBePublic": "default tier must be public", "defaultTierMustBePublicPlan": "default tier must be a public user plan with individual usage and fixed seats", "userTiersIndividualUsage": "User tiers must use individual usage", @@ -22060,7 +22255,7 @@ "userTiersNoOrgSeats": "User tiers do not manage organization seats", "seatMaxAboveCount": "seat maximum must stay above seat count", "noLimitsConfigured": "No included usage, storage, concurrency, rate, or retention limits configured", - "limitsConfigured": "{{count}}/7 limits configured", + "limitsConfigured": "{count}/7 limits configured", "usingBasePricingOnly": "Using base platform pricing only" } }, @@ -22073,84 +22268,84 @@ "emails": { "shared": { "tagline": "LLM Technical Trading Analysis Workflow System", - "team": "The {{brandName}} Team", - "openBrand": "Open {{brandName}}", + "team": "The {brandName} Team", + "openBrand": "Open {brandName}", "expires15": "This code expires in 15 minutes.", "expires24": "This link is valid for the next 24 hours.", "expires48": "Invitations expire in 48 hours for security.", "expires7": "This invitation expires in 7 days.", "ignore": "If you did not request this, you can safely ignore this email.", - "sentOn": "Sent on {{date}}.", - "sentOnTo": "Sent on {{date}} to {{email}}.", - "submittedOnTo": "Submitted on {{date}} to {{email}}.", - "approvedOnFor": "Approved on {{date}} for {{email}}.", - "sentOnFor": "Sent on {{date}} for your {{typeLabel}}{{emailPart}}.", - "emailPartFrom": " from {{email}}" + "sentOn": "Sent on {date}.", + "sentOnTo": "Sent on {date} to {email}.", + "submittedOnTo": "Submitted on {date} to {email}.", + "approvedOnFor": "Approved on {date} for {email}.", + "sentOnFor": "Sent on {date} for your {typeLabel}{emailPart}.", + "emailPartFrom": " from {email}" }, "footer": { - "copyright": "(c) {{year}} {{brandName}}, All Rights Reserved", + "copyright": "(c) {year} {brandName}, All Rights Reserved", "questions": "Questions? Email", "privacy": "Privacy Policy", "terms": "Terms of Service", "unsubscribe": "Unsubscribe" }, "subjects": { - "sign-in": "Sign in to {{brandName}}", - "email-verification": "Verify your email for {{brandName}}", - "forget-password": "Reset your {{brandName}} password", - "reset-password": "Reset your {{brandName}} password", - "change-email": "Verify your new email for {{brandName}}", - "chat-access": "Verification code for {{chatTitle}}", - "invitation": "You've been invited to join {{organizationName}} on {{brandName}}", - "batch-invitation": "You've been invited to join {{organizationName}} and workspaces on {{brandName}}", - "workspace-invitation": "You've been invited to join {{workspaceName}} on {{brandName}}", + "sign-in": "Sign in to {brandName}", + "email-verification": "Verify your email for {brandName}", + "forget-password": "Reset your {brandName} password", + "reset-password": "Reset your {brandName} password", + "change-email": "Verify your new email for {brandName}", + "chat-access": "Verification code for {chatTitle}", + "invitation": "You've been invited to join {organizationName} on {brandName}", + "batch-invitation": "You've been invited to join {organizationName} and workspaces on {brandName}", + "workspace-invitation": "You've been invited to join {workspaceName} on {brandName}", "help-confirmation": "Your request has been received", - "enterprise-subscription": "Your organization billing is now active on {{brandName}}", - "plan-welcome": "Your {{planName}} tier is now active on {{brandName}}", - "usage-threshold": "You're nearing your monthly budget on {{brandName}}", - "free-tier-upgrade": "Your current tier is nearing its included usage on {{brandName}}", - "payment-failed": "{{brandName}} payment failed - action required", - "waitlist-confirmation": "We received your {{brandName}} access request", - "waitlist-approved": "Your {{brandName}} access request was approved", - "careers-confirmation": "Your application to {{brandName}} - {{position}}" + "enterprise-subscription": "Your organization billing is now active on {brandName}", + "plan-welcome": "Your {planName} tier is now active on {brandName}", + "usage-threshold": "You're nearing your monthly budget on {brandName}", + "free-tier-upgrade": "Your current tier is nearing its included usage on {brandName}", + "payment-failed": "{brandName} payment failed - action required", + "waitlist-confirmation": "We received your {brandName} access request", + "waitlist-approved": "Your {brandName} access request was approved", + "careers-confirmation": "Your application to {brandName} - {position}" }, "otp": { "titles": { - "sign-in": "Sign in to {{brandName}}", - "email-verification": "Verify your email for {{brandName}}", - "forget-password": "Reset your password for {{brandName}}", - "change-email": "Verify your new email for {{brandName}}", - "chat-access": "Access {{chatTitle}}" + "sign-in": "Sign in to {brandName}", + "email-verification": "Verify your email for {brandName}", + "forget-password": "Reset your password for {brandName}", + "change-email": "Verify your new email for {brandName}", + "chat-access": "Access {chatTitle}" }, "body": "Use the code below to continue." }, "resetPassword": { "title": "Reset your password", - "intro": "We received a request to reset the password for your {{brandName}} account.", + "intro": "We received a request to reset the password for your {brandName} account.", "action": "Use the button below to choose a new password.", "cta": "Reset Password", "accountFallback": "your account email", - "sentLine": "Sent on {{date}} to {{account}}." + "sentLine": "Sent on {date} to {account}." }, "invitation": { - "preview": "{{inviterName}} invited you to join {{organizationName}} on {{brandName}}", - "title": "You've been invited to join {{organizationName}}!", - "intro": "{{inviterName}} invited you to collaborate on {{brandName}}.", + "preview": "{inviterName} invited you to join {organizationName} on {brandName}", + "title": "You've been invited to join {organizationName}!", + "intro": "{inviterName} invited you to collaborate on {brandName}.", "body": "Accept the invitation to get access to shared projects and workflows.", "cta": "Join Now" }, "batchInvitation": { - "preview": "Join {{organizationName}} on {{brandName}}", - "title": "You've been invited to join {{organizationName}}.", - "intro": "{{inviterName}} added you as a {{roleLabel}} on {{brandName}}.", + "preview": "Join {organizationName} on {brandName}", + "title": "You've been invited to join {organizationName}.", + "intro": "{inviterName} added you as a {roleLabel} on {brandName}.", "adminDescription": "As an Admin, you'll manage billing, teammates, and workspace access across the organization.", "memberDescription": "As a Member, you can collaborate on shared billing and accept workspace invites.", - "workspaceAccess": "Workspace Access ({{count}} {{workspaceWord}}):", - "workspaceLine": "- {{workspaceName}} - {{permissionLabel}}", + "workspaceAccess": "Workspace Access ({count} {workspaceWord}):", + "workspaceLine": "- {workspaceName} - {permissionLabel}", "workspaceSingular": "workspace", "workspacePlural": "workspaces", - "closing": "By accepting, you'll join {{organizationName}}.", - "closingWithWorkspaces": "By accepting, you'll join {{organizationName}} and unlock access to {{count}} {{workspaceWord}}.", + "closing": "By accepting, you'll join {organizationName}.", + "closingWithWorkspaces": "By accepting, you'll join {organizationName} and unlock access to {count} {workspaceWord}.", "cta": "Accept Invitation", "roleLabels": { "admin": "Admin", @@ -22163,17 +22358,17 @@ } }, "workspaceInvitation": { - "preview": "Join the {{workspaceName}} workspace on {{brandName}}", - "title": "You're invited to {{workspaceName}}", - "intro": "{{inviterName}} asked you to collaborate in the {{workspaceName}} workspace on {{brandName}}. Accept to access shared projects and data.", + "preview": "Join the {workspaceName} workspace on {brandName}", + "title": "You're invited to {workspaceName}", + "intro": "{inviterName} asked you to collaborate in the {workspaceName} workspace on {brandName}. Accept to access shared projects and data.", "cta": "Accept Invitation" }, "help": { "title": "Thanks for reaching out", - "preview": "{{brandName}}: we received your {{typeLabel}}", - "intro": "We received your {{typeLabel}} and will follow up shortly.", - "attachments": "You attached {{count}} {{fileWord}}. We will review everything you shared.", - "responseTime": "We typically respond within a few hours. If you need immediate help, email us anytime at {{supportEmail}}.", + "preview": "{brandName}: we received your {typeLabel}", + "intro": "We received your {typeLabel} and will follow up shortly.", + "attachments": "You attached {count} {fileWord}. We will review everything you shared.", + "responseTime": "We typically respond within a few hours. If you need immediate help, email us anytime at {supportEmail}.", "fileSingular": "file", "filePlural": "files", "typeLabels": { @@ -22187,13 +22382,13 @@ "confirmation": { "preview": "Your access request has been received", "title": "You're on the waitlist", - "intro": "We received your access request for {{brandName}} and added {{email}} to the waitlist.", + "intro": "We received your access request for {brandName} and added {email} to the waitlist.", "body": "We will review your request and email you again if this address is approved for sign up. Keep using this same email for every sign-in method." }, "approved": { "preview": "Your waitlist request has been approved", "title": "Your access is ready", - "intro": "{{email}} is now approved to create an account on {{brandName}}.", + "intro": "{email} is now approved to create an account on {brandName}.", "body": "Finish signing up with this same email address to activate your access.", "cta": "Finish sign up", "methodReminder": "Use the same approved email for email/password, Google, GitHub, or any other sign-in method." @@ -22202,8 +22397,8 @@ "billing": { "enterprise": { "title": "Organization billing activated", - "welcome": "Welcome aboard, {{userName}}.", - "body": "Your organization billing tier is live on {{brandName}}. You now have expanded capacity, advanced controls, and organization-wide access.", + "welcome": "Welcome aboard, {userName}.", + "body": "Your organization billing tier is live on {brandName}. You now have expanded capacity, advanced controls, and organization-wide access.", "cta": "Access Your Account", "nextStepsTitle": "Next steps", "nextSteps": [ @@ -22214,62 +22409,62 @@ "help": "Need help getting started? Reply to this email and our team will assist you." }, "planWelcome": { - "preview": "{{brandName}}: your {{planName}} tier is active", - "title": "{{planName}} tier activated", - "namedWelcome": "Welcome, {{userName}}!", + "preview": "{brandName}: your {planName} tier is active", + "title": "{planName} tier activated", + "namedWelcome": "Welcome, {userName}!", "welcome": "Welcome!", - "body": "You're all set on the {{planName}} tier for {{brandName}}. Explore your new limits and ship faster with your team.", + "body": "You're all set on the {planName} tier for {brandName}. Explore your new limits and ship faster with your team.", "help": "Want to discuss your tier or get personalized help getting started? Schedule a 15-minute call with our team.", "settings": "Need to invite teammates, adjust usage limits, or manage billing? Visit Settings -> Subscription anytime." }, "usage": { - "preview": "{{brandName}}: You're at {{percentUsed}}% of your {{planName}} monthly budget", - "title": "You're at {{percentUsed}}% of your budget", - "intro": "{{userNamePrefix}}your {{planName}} tier usage is nearing the monthly limit.", + "preview": "{brandName}: You're at {percentUsed}% of your {planName} monthly budget", + "title": "You're at {percentUsed}% of your budget", + "intro": "{userNamePrefix}your {planName} tier usage is nearing the monthly limit.", "recommendation": "To avoid interruptions, consider increasing your monthly limit.", - "usageLine": "{{currentUsage}} / {{limit}} used", - "percentLine": "{{percentUsed}}% of this month's budget", + "usageLine": "{currentUsage} / {limit} used", + "percentLine": "{percentUsed}% of this month's budget", "cta": "Review limits", "reason": "We send this once when your usage reaches the configured billing warning threshold so you have time to adjust your tier or limit." }, "freeTier": { "currentTierFallback": "your current tier", - "preview": "{{brandName}}: {{currentTierName}} is nearing its included usage", + "preview": "{brandName}: {currentTierName} is nearing its included usage", "title": "Your included usage is almost used", - "greeting": "Hi {{userName}},", + "greeting": "Hi {userName},", "greetingFallback": "there", - "usage": "You've used {{currentUsage}} of the {{limit}} included in {{currentTierName}} ({{percentUsed}}%).", + "usage": "You've used {currentUsage} of the {limit} included in {currentTierName} ({percentUsed}%).", "body": "Review your available billing tiers now so you can expand your usage before this month's limit interrupts new work.", "recommendedTitle": "Recommended next tier", - "recommendedTier": "Recommended next tier: {{tierName}}", - "recommendedPrice": "Starts at {{price}}/month", - "recommendedUsage": "{{usage}} included usage each month", + "recommendedTier": "Recommended next tier: {tierName}", + "recommendedPrice": "Starts at {price}/month", + "recommendedUsage": "{usage} included usage each month", "cta": "Review Billing Tiers", "oneTime": "This is a one-time notification after your default tier crosses its upgrade threshold." }, "paymentFailed": { "title": "We were unable to process your payment.", - "greeting": "Hi {{userName}},", + "greeting": "Hi {userName},", "greetingFallback": "there", - "body": "Your {{brandName}} account has been temporarily blocked to prevent service interruptions and unexpected charges. To restore access immediately, please update your payment method.", + "body": "Your {brandName} account has been temporarily blocked to prevent service interruptions and unexpected charges. To restore access immediately, please update your payment method.", "detailsTitle": "Payment Details", - "amountDue": "Amount due: {{amount}}", - "paymentMethod": "Payment method: **** {{lastFourDigits}}", - "reason": "Reason: {{reason}}", + "amountDue": "Amount due: {amount}", + "paymentMethod": "Payment method: **** {lastFourDigits}", + "reason": "Reason: {reason}", "cta": "Update Payment Method", "nextSteps": "Your workflows and automations are currently paused. Update your payment method to restore service immediately. Stripe will automatically retry the charge once payment is updated.", "help": "Common reasons for payment failures include expired cards, insufficient funds, or incorrect billing information. If you continue to experience issues, contact support.", - "sentLine": "Sent on {{date}}. This is a critical transactional notification." + "sentLine": "Sent on {date}. This is a critical transactional notification." } }, "careers": { - "preview": "Your application to {{brandName}} has been received", + "preview": "Your application to {brandName} has been received", "title": "We received your application", - "greeting": "Hello {{name}},", - "body": "Thanks for your interest in joining the {{brandName}} team. We received your application for the {{position}} role.", + "greeting": "Hello {name},", + "body": "Thanks for your interest in joining the {brandName} team. We received your application for the {position} role.", "review": "Our team carefully reviews every application and will get back to you within the next few weeks. If your qualifications match what we're looking for, we'll reach out to schedule an initial conversation.", - "explore": "In the meantime, explore our documentation at {{docsUrl}} or read the latest on our blog at {{blogUrl}}.", - "sentLine": "This confirmation was sent on {{dateTime}}." + "explore": "In the meantime, explore our documentation at {docsUrl} or read the latest on our blog at {blogUrl}.", + "sentLine": "This confirmation was sent on {dateTime}." } } } diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index 6d9f1b1d0..2bb3de610 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -47,8 +47,8 @@ "homeLabel": "Inicio", "languageLabel": "Idioma", "primaryNavigation": "Navegación principal", - "githubRepositoryAriaLabel": "Repositorio de GitHub - {{stars}} estrellas", - "homeAriaLabel": "Inicio de {{brand}}" + "githubRepositoryAriaLabel": "Repositorio de GitHub - {stars} estrellas", + "homeAriaLabel": "Inicio de {brand}" }, "registration": { "open": { @@ -178,7 +178,7 @@ "github": "GitHub", "google": "Google", "connecting": "Conectando...", - "cancelled": "Se canceló el inicio de sesión con {{provider}}. Inténtelo de nuevo." + "cancelled": "Se canceló el inicio de sesión con {provider}. Inténtelo de nuevo." }, "verify": { "eyebrow": "Verificación", @@ -186,7 +186,7 @@ "verifiedTitle": "¡Correo electrónico verificado!", "verifiedDescription": "Tu correo electrónico ha sido verificado. Redirigiendo al panel...", "disabledDescription": "Verificación de correo desactivada. Redirigiendo al panel…", - "codeSent": "Se ha enviado un código de verificación a {{email}}", + "codeSent": "Se ha enviado un código de verificación a {email}", "developmentDescription": "Modo de desarrollo: Revisa los registros de la consola para obtener el código de verificación", "missingServiceDescription": "Error: La verificación de correo está habilitada pero no hay ningún servicio de correo configurado", "instructionsWithService": "Ingresa el código de 6 dígitos para verificar tu cuenta. Si no lo ves en tu bandeja de entrada, revisa la carpeta de spam.", @@ -194,7 +194,7 @@ "verifyButton": "Verificar correo", "verifyingButton": "Verificando…", "resendPrompt": "¿No recibiste un código?", - "resendIn": "Reenviar en {{countdown}}s", + "resendIn": "Reenviar en {countdown}s", "resendButton": "Reenviar", "yourEmail": "tu correo", "errors": { @@ -370,16 +370,8 @@ "waitlist": "Honk! Presentamos TradingGoose-Studio", "open": "Honk! TradingGoose-Studio ya está aquí" }, - "leadWords": [ - "Construir", - "Probar", - "Ejecutar" - ], - "highlightWords": [ - "Análisis de Trading", - "Detección de Señales", - "Evaluación de Riesgos" - ], + "leadWords": ["Construir", "Probar", "Ejecutar"], + "highlightWords": ["Análisis de Trading", "Detección de Señales", "Evaluación de Riesgos"], "titleConnector": "tu", "suffix": "con TradingGoose", "description": "Conecta tus propios proveedores de datos, escribe indicadores personalizados para monitorear los precios del mercado y enlázalos en flujos de trabajo que activen operaciones de compra, venta o cualquier acción que definas.", @@ -412,7 +404,7 @@ }, "footer": { "description": "Plataforma de flujos de trabajo con IA para trading técnico con LLM", - "copyright": "© {{year}} {{brand}}. Creado para flujos de trabajo de trading visual.", + "copyright": "© {year} {brand}. Creado para flujos de trabajo de trading visual.", "links": { "docs": "Documentación", "blog": "Blog", @@ -654,11 +646,11 @@ }, "blog": { "pageTitle": "Blog", - "pageDescription": "Información sobre automatización de trading, diseño de flujos de trabajo y creación de estrategias más inteligentes. {{count}} artículos y contando.", + "pageDescription": "Información sobre automatización de trading, diseño de flujos de trabajo y creación de estrategias más inteligentes. {count} artículos y contando.", "searchPlaceholder": "Buscar artículos", "emptyTitle": "Aún no hay publicaciones", "emptyDescription": "Vuelve pronto, hay nuevos artículos en camino.", - "noMatches": "No hay publicaciones que coincidan con \"{{query}}\"", + "noMatches": "No hay publicaciones que coincidan con \"{query}\"", "noMatchesDescription": "Prueba con otro término de búsqueda.", "readTimeSuffix": "min de lectura", "viewArticle": "Ver artículo", @@ -666,11 +658,11 @@ "breadcrumbBlog": "Blog", "tableOfContents": "En esta página", "shareTitle": "Compartir este artículo", - "shareOn": "Compartir en {{platform}}", + "shareOn": "Compartir en {platform}", "copyLink": "Copiar enlace", "copied": "¡Copiado!", "summarizeTitle": "Resumir con IA", - "summarizeWithPlatform": "Resumir con {{platform}}", + "summarizeWithPlatform": "Resumir con {platform}", "articleSingular": "artículo", "articlePlural": "artículos" }, @@ -713,13 +705,7 @@ "experience": { "label": "Años de experiencia *", "placeholder": "Selecciona el nivel de experiencia", - "options": [ - "0-1 años", - "1-3 años", - "3-5 años", - "5-10 años", - "10+ años" - ] + "options": ["0-1 años", "1-3 años", "3-5 años", "5-10 años", "10+ años"] }, "location": { "label": "Ubicación *", @@ -772,8 +758,8 @@ "rssFeed": "RSS", "loadingMore": "Cargando...", "showMore": "Mostrar más", - "viewContributorAriaLabel": "Ver a @{{contributor}} en GitHub", - "contributorAvatarAlt": "@{{contributor}}", + "viewContributorAriaLabel": "Ver a @{contributor} en GitHub", + "contributorAvatarAlt": "@{contributor}", "breadcrumb": "Registro de cambios" }, "legal": { @@ -783,12 +769,12 @@ "terms": { "title": "Términos del servicio", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "Estos Términos del servicio regulan su acceso y uso del sitio web, la aplicación, las API y cualquier servicio alojado operado por el proyecto {{projectName}} (en conjunto, el Servicio).\n\nSi utiliza una implementación autohospedada o una implementación operada por alguien distinto del propietario del proyecto TradingGoose, ese operador es responsable de sus propios términos del servicio, avisos de privacidad, prácticas de seguridad, facturación y cumplimiento.\n\nAl acceder o usar el Servicio, usted acepta estos Términos. Si no está de acuerdo, no use el Servicio.\n\n## 1. Licencia de código abierto\n\nEl código fuente de {{projectName}} se pone a disposición por separado bajo la licencia AGPL-3.0-only del proyecto y las licencias de terceros aplicables. Estos Términos regulan el sitio web y el Servicio alojado operados por el proyecto, y no sustituyen ni reducen los derechos que le otorga la licencia del código fuente.\n\nLos detalles de licencias y atribución están disponibles en el repositorio y en la página [Licenses & Notices](/licenses).\n\n## 2. Elegibilidad, cuentas y acceso\n\nEs posible que necesite una cuenta para usar algunas partes del Servicio. Debe proporcionar información precisa, mantener sus credenciales seguras y es responsable de la actividad que ocurra a través de su cuenta.\n\nUsted es responsable de mantener la confidencialidad de las credenciales de inicio de sesión, las claves API, las conexiones OAuth, las credenciales de brokers y cualquier otro método de acceso vinculado a su cuenta o sus flujos de trabajo.\n\nPodemos suspender o restringir el acceso si creemos razonablemente que una cuenta se está utilizando de una manera que incumple estos Términos, amenaza la seguridad o crea un riesgo legal u operativo.\n\n## 3. Uso aceptable\n\nNo puede usar el Servicio para:\n\n- Infringir la ley o vulnerar los derechos de terceros.\n- Subir, transmitir o automatizar contenido ilícito, infractor, abusivo o malicioso.\n- Intentar obtener acceso no autorizado, interferir con el Servicio o interrumpir a otros usuarios.\n- Usar el Servicio para distribuir malware, spam o actividad fraudulenta.\n- Hacer un uso indebido de cuentas conectadas, credenciales OAuth, cuentas de brokers o API de terceros que no controla o para las que no está autorizado.\n- Usar el Servicio de una manera que requiera registros regulatorios, divulgaciones o permisos que no tiene.\n\n## 4. Su contenido, flujos de trabajo e integraciones\n\nUsted conserva la propiedad del contenido, archivos, prompts, definiciones de flujos de trabajo, scripts de indicadores, credenciales y otros datos que envía o conecta a través del Servicio (Su contenido).\n\nNos concede una licencia limitada para alojar, procesar, almacenar, transmitir y mostrar Su contenido solo en la medida necesaria para operar, proteger, dar soporte y mejorar el Servicio.\n\nUsted es responsable de asegurarse de que cuenta con los derechos y permisos necesarios para usar Su contenido y cualquier servicio de terceros, fuente de datos de mercado, cuenta de broker o API que conecte.\n\n## 5. Servicios de terceros y cuentas conectadas\n\nEl Servicio puede interoperar con servicios de terceros, incluidos proveedores de modelos, proveedores de almacenamiento, servicios de comunicación, proveedores de identidad, servicios de análisis, procesadores de pagos, servicios de datos de mercado y plataformas de brokers.\n\nSu uso de esos servicios de terceros sigue estando sujeto a sus propios términos, avisos de privacidad, tarifas, límites técnicos y disponibilidad.\n\nNo somos responsables de interrupciones, errores, cambios de precios, cambios de API, restricciones de cuenta, fallos de ejecución ni otras acciones realizadas por servicios de terceros.\n\n## 6. Funciones de pago y facturación\n\nAlgunas implementaciones pueden ofrecer planes de pago, facturación por uso o funciones por suscripción. Si compra acceso de pago, acepta pagar las tarifas, impuestos y cargos aplicables descritos en el momento de la compra.\n\nLa facturación puede gestionarse mediante procesadores de pago de terceros como Stripe. Nosotros no almacenamos los datos completos de las tarjetas de pago.\n\nLa falta de pago puede dar lugar a la suspensión o degradación de las funciones de pago. Los precios y los términos de los planes pueden cambiar de forma prospectiva.\n\n## 7. Solo análisis, investigación y automatización\n\n{{projectName}} se proporciona como software para análisis, investigación, gráficos, monitoreo, automatización de flujos de trabajo y operaciones técnicas relacionadas. No es un broker-dealer, una bolsa, un asesor de inversiones, un fiduciario ni un centro de ejecución.\n\nEl Servicio no proporciona asesoramiento financiero, de inversión, legal, contable ni fiscal. Cualquier salida, gráfico, alerta, script, respuesta de modelo, resultado de flujo de trabajo o automatización es solo una herramienta informativa.\n\nUsted es el único responsable de evaluar la información, revisar las salidas, gestionar el riesgo, determinar la idoneidad y decidir si realiza operaciones, envía órdenes o toma cualquier otra acción basándose en el Servicio.\n\n## 8. Acciones de trading, integraciones con brokers y datos de mercado\n\nAlgunas implementaciones pueden habilitar flujos de trabajo o herramientas que interactúan con brokers, bolsas, mercados de predicción o proveedores de datos de mercado de terceros. Cualquier acción de trading de este tipo se inicia bajo su dirección y la ejecuta, en su caso, el proveedor tercero correspondiente.\n\nNo somos responsables de operaciones, órdenes, cancelaciones, ejecuciones, ejecuciones parciales, órdenes rechazadas, ejecución retrasada, precios desactualizados, símbolos incorrectos, errores de modelo, errores en la lógica del flujo de trabajo, API no disponibles, inexactitudes en los datos de mercado ni pérdidas de ningún tipo derivadas de acciones realizadas a través del Servicio o basadas en él.\n\nUsted es responsable de configurar medidas de protección, probar flujos de trabajo, supervisar el comportamiento automatizado y confirmar que cualquier actividad de trading conectada cumple la legislación aplicable y las reglas del proveedor tercero.\n\n## 9. Disponibilidad, funciones experimentales y cambios\n\nEl Servicio puede cambiar con el tiempo. Podemos agregar, modificar, suspender o retirar funciones en cualquier momento, incluidas integraciones, capacidades alojadas y funciones experimentales.\n\nAlgunas capacidades pueden estar marcadas como experimentales, beta, vista previa o incompletas. Usted usa esas funciones bajo su propio riesgo.\n\nPodemos suspender o terminar el acceso al Servicio operado por el proyecto si incumple estos Términos, crea un riesgo de seguridad o legal, o hace un uso indebido del Servicio. Puede dejar de usar el Servicio en cualquier momento.\n\n## 10. Propiedad intelectual y marca\n\nEl Servicio incluye código de código abierto, componentes de terceros y marca y contenido propiedad del proyecto. Los materiales de código abierto y de terceros siguen sujetos a sus respectivas licencias.\n\nSalvo que una licencia lo permita expresamente, el nombre, los logotipos y los recursos de marca de {{projectName}} no pueden usarse de una manera que implique respaldo, afiliación u origen sin permiso.\n\n## 11. Exenciones de responsabilidad\n\nEl Servicio se proporciona TAL CUAL y SEGÚN DISPONIBILIDAD, sin garantías de ningún tipo en la máxima medida permitida por la ley. No garantizamos un funcionamiento ininterrumpido, exactitud, rentabilidad, disponibilidad de integraciones, idoneidad para una estrategia concreta ni que el Servicio evite pérdidas o errores.\n\n## 12. Limitación de responsabilidad\n\nEn la máxima medida permitida por la ley, no somos responsables de daños indirectos, incidentales, especiales, consecuentes, ejemplares o punitivos, ni de la pérdida de beneficios, ingresos, pérdidas de trading, pérdidas de corretaje, fondo de comercio, datos o interrupción del negocio derivados del Servicio o relacionados con él.\n\nSi la responsabilidad no puede excluirse, se limita al importe que nos pagó por el Servicio operado por el proyecto durante los 12 meses anteriores al surgimiento de la reclamación.\n\n## 13. Indemnización\n\nEn la medida permitida por la ley, usted será responsable de las reclamaciones, pérdidas y costes derivados de su uso indebido del Servicio, sus cuentas conectadas, su contenido, sus flujos de trabajo o su incumplimiento de estos Términos o de la legislación aplicable.\n\n## 14. Cambios en estos Términos\n\nPodemos actualizar estos Términos periódicamente. Cuando lo hagamos, actualizaremos la fecha de Última actualización en esta página. Su uso continuado del Servicio operado por el proyecto después de que una actualización entre en vigor significa que acepta los Términos revisados.\n\n## 15. Contacto y avisos de copyright\n\nSi tiene preguntas sobre estos Términos, o cree que el material disponible a través de un Servicio operado por el proyecto infringe sus derechos, contáctenos en [{{supportEmail}}](mailto:{{supportEmail}}).\n\nIncluya suficiente detalle para que podamos entender y revisar su solicitud. Actualmente no publicamos una dirección postal independiente para avisos legales en esta página." + "bodyMarkdown": "Estos Términos del servicio regulan su acceso y uso del sitio web, la aplicación, las API y cualquier servicio alojado operado por el proyecto {projectName} (en conjunto, el Servicio).\n\nSi utiliza una implementación autohospedada o una implementación operada por alguien distinto del propietario del proyecto TradingGoose, ese operador es responsable de sus propios términos del servicio, avisos de privacidad, prácticas de seguridad, facturación y cumplimiento.\n\nAl acceder o usar el Servicio, usted acepta estos Términos. Si no está de acuerdo, no use el Servicio.\n\n## 1. Licencia de código abierto\n\nEl código fuente de {projectName} se pone a disposición por separado bajo la licencia AGPL-3.0-only del proyecto y las licencias de terceros aplicables. Estos Términos regulan el sitio web y el Servicio alojado operados por el proyecto, y no sustituyen ni reducen los derechos que le otorga la licencia del código fuente.\n\nLos detalles de licencias y atribución están disponibles en el repositorio y en la página [Licenses & Notices](/licenses).\n\n## 2. Elegibilidad, cuentas y acceso\n\nEs posible que necesite una cuenta para usar algunas partes del Servicio. Debe proporcionar información precisa, mantener sus credenciales seguras y es responsable de la actividad que ocurra a través de su cuenta.\n\nUsted es responsable de mantener la confidencialidad de las credenciales de inicio de sesión, las claves API, las conexiones OAuth, las credenciales de brokers y cualquier otro método de acceso vinculado a su cuenta o sus flujos de trabajo.\n\nPodemos suspender o restringir el acceso si creemos razonablemente que una cuenta se está utilizando de una manera que incumple estos Términos, amenaza la seguridad o crea un riesgo legal u operativo.\n\n## 3. Uso aceptable\n\nNo puede usar el Servicio para:\n\n- Infringir la ley o vulnerar los derechos de terceros.\n- Subir, transmitir o automatizar contenido ilícito, infractor, abusivo o malicioso.\n- Intentar obtener acceso no autorizado, interferir con el Servicio o interrumpir a otros usuarios.\n- Usar el Servicio para distribuir malware, spam o actividad fraudulenta.\n- Hacer un uso indebido de cuentas conectadas, credenciales OAuth, cuentas de brokers o API de terceros que no controla o para las que no está autorizado.\n- Usar el Servicio de una manera que requiera registros regulatorios, divulgaciones o permisos que no tiene.\n\n## 4. Su contenido, flujos de trabajo e integraciones\n\nUsted conserva la propiedad del contenido, archivos, prompts, definiciones de flujos de trabajo, scripts de indicadores, credenciales y otros datos que envía o conecta a través del Servicio (Su contenido).\n\nNos concede una licencia limitada para alojar, procesar, almacenar, transmitir y mostrar Su contenido solo en la medida necesaria para operar, proteger, dar soporte y mejorar el Servicio.\n\nUsted es responsable de asegurarse de que cuenta con los derechos y permisos necesarios para usar Su contenido y cualquier servicio de terceros, fuente de datos de mercado, cuenta de broker o API que conecte.\n\n## 5. Servicios de terceros y cuentas conectadas\n\nEl Servicio puede interoperar con servicios de terceros, incluidos proveedores de modelos, proveedores de almacenamiento, servicios de comunicación, proveedores de identidad, servicios de análisis, procesadores de pagos, servicios de datos de mercado y plataformas de brokers.\n\nSu uso de esos servicios de terceros sigue estando sujeto a sus propios términos, avisos de privacidad, tarifas, límites técnicos y disponibilidad.\n\nNo somos responsables de interrupciones, errores, cambios de precios, cambios de API, restricciones de cuenta, fallos de ejecución ni otras acciones realizadas por servicios de terceros.\n\n## 6. Funciones de pago y facturación\n\nAlgunas implementaciones pueden ofrecer planes de pago, facturación por uso o funciones por suscripción. Si compra acceso de pago, acepta pagar las tarifas, impuestos y cargos aplicables descritos en el momento de la compra.\n\nLa facturación puede gestionarse mediante procesadores de pago de terceros como Stripe. Nosotros no almacenamos los datos completos de las tarjetas de pago.\n\nLa falta de pago puede dar lugar a la suspensión o degradación de las funciones de pago. Los precios y los términos de los planes pueden cambiar de forma prospectiva.\n\n## 7. Solo análisis, investigación y automatización\n\n{projectName} se proporciona como software para análisis, investigación, gráficos, monitoreo, automatización de flujos de trabajo y operaciones técnicas relacionadas. No es un broker-dealer, una bolsa, un asesor de inversiones, un fiduciario ni un centro de ejecución.\n\nEl Servicio no proporciona asesoramiento financiero, de inversión, legal, contable ni fiscal. Cualquier salida, gráfico, alerta, script, respuesta de modelo, resultado de flujo de trabajo o automatización es solo una herramienta informativa.\n\nUsted es el único responsable de evaluar la información, revisar las salidas, gestionar el riesgo, determinar la idoneidad y decidir si realiza operaciones, envía órdenes o toma cualquier otra acción basándose en el Servicio.\n\n## 8. Acciones de trading, integraciones con brokers y datos de mercado\n\nAlgunas implementaciones pueden habilitar flujos de trabajo o herramientas que interactúan con brokers, bolsas, mercados de predicción o proveedores de datos de mercado de terceros. Cualquier acción de trading de este tipo se inicia bajo su dirección y la ejecuta, en su caso, el proveedor tercero correspondiente.\n\nNo somos responsables de operaciones, órdenes, cancelaciones, ejecuciones, ejecuciones parciales, órdenes rechazadas, ejecución retrasada, precios desactualizados, símbolos incorrectos, errores de modelo, errores en la lógica del flujo de trabajo, API no disponibles, inexactitudes en los datos de mercado ni pérdidas de ningún tipo derivadas de acciones realizadas a través del Servicio o basadas en él.\n\nUsted es responsable de configurar medidas de protección, probar flujos de trabajo, supervisar el comportamiento automatizado y confirmar que cualquier actividad de trading conectada cumple la legislación aplicable y las reglas del proveedor tercero.\n\n## 9. Disponibilidad, funciones experimentales y cambios\n\nEl Servicio puede cambiar con el tiempo. Podemos agregar, modificar, suspender o retirar funciones en cualquier momento, incluidas integraciones, capacidades alojadas y funciones experimentales.\n\nAlgunas capacidades pueden estar marcadas como experimentales, beta, vista previa o incompletas. Usted usa esas funciones bajo su propio riesgo.\n\nPodemos suspender o terminar el acceso al Servicio operado por el proyecto si incumple estos Términos, crea un riesgo de seguridad o legal, o hace un uso indebido del Servicio. Puede dejar de usar el Servicio en cualquier momento.\n\n## 10. Propiedad intelectual y marca\n\nEl Servicio incluye código de código abierto, componentes de terceros y marca y contenido propiedad del proyecto. Los materiales de código abierto y de terceros siguen sujetos a sus respectivas licencias.\n\nSalvo que una licencia lo permita expresamente, el nombre, los logotipos y los recursos de marca de {projectName} no pueden usarse de una manera que implique respaldo, afiliación u origen sin permiso.\n\n## 11. Exenciones de responsabilidad\n\nEl Servicio se proporciona TAL CUAL y SEGÚN DISPONIBILIDAD, sin garantías de ningún tipo en la máxima medida permitida por la ley. No garantizamos un funcionamiento ininterrumpido, exactitud, rentabilidad, disponibilidad de integraciones, idoneidad para una estrategia concreta ni que el Servicio evite pérdidas o errores.\n\n## 12. Limitación de responsabilidad\n\nEn la máxima medida permitida por la ley, no somos responsables de daños indirectos, incidentales, especiales, consecuentes, ejemplares o punitivos, ni de la pérdida de beneficios, ingresos, pérdidas de trading, pérdidas de corretaje, fondo de comercio, datos o interrupción del negocio derivados del Servicio o relacionados con él.\n\nSi la responsabilidad no puede excluirse, se limita al importe que nos pagó por el Servicio operado por el proyecto durante los 12 meses anteriores al surgimiento de la reclamación.\n\n## 13. Indemnización\n\nEn la medida permitida por la ley, usted será responsable de las reclamaciones, pérdidas y costes derivados de su uso indebido del Servicio, sus cuentas conectadas, su contenido, sus flujos de trabajo o su incumplimiento de estos Términos o de la legislación aplicable.\n\n## 14. Cambios en estos Términos\n\nPodemos actualizar estos Términos periódicamente. Cuando lo hagamos, actualizaremos la fecha de Última actualización en esta página. Su uso continuado del Servicio operado por el proyecto después de que una actualización entre en vigor significa que acepta los Términos revisados.\n\n## 15. Contacto y avisos de copyright\n\nSi tiene preguntas sobre estos Términos, o cree que el material disponible a través de un Servicio operado por el proyecto infringe sus derechos, contáctenos en [{supportEmail}](mailto:{supportEmail}).\n\nIncluya suficiente detalle para que podamos entender y revisar su solicitud. Actualmente no publicamos una dirección postal independiente para avisos legales en esta página." }, "privacy": { "title": "Política de privacidad", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "Esta Política de privacidad describe cómo {{projectName}} gestiona los datos personales cuando el propietario del proyecto TradingGoose opera el sitio web, la aplicación, las API o los servicios alojados (en conjunto, el Servicio).\n\nSi utiliza una implementación autohospedada o una implementación operada por otra persona, ese operador es responsable de su propio aviso de privacidad, tratamiento de datos, cookies, análisis, integraciones y prácticas de seguridad.\n\n## 1. Alcance y funciones\n\nEsta Política de privacidad se aplica a las implementaciones de {{projectName}} operadas por el proyecto. En el caso de implementaciones autohospedadas o implementaciones operadas por terceros, ese operador controla su propia configuración, almacenamiento, integraciones, análisis, retención y cumplimiento.\n\nEn esos casos, el operador tercero, y no el propietario del proyecto TradingGoose, es el controlador u operador principal de los datos de usuario de esa implementación.\n\n## 2. Información que recopilamos\n\n### Datos de cuenta y autenticación\n\nPodemos recopilar la información que usted proporciona al crear o usar una cuenta, como su nombre, dirección de correo electrónico, método de inicio de sesión, datos de perfil, pertenencia a organizaciones y ajustes de cuenta.\n\n### Datos de contenido y flujos de trabajo\n\nPodemos procesar prompts, chats, archivos, documentos, definiciones de flujos de trabajo, registros, scripts de indicadores, listas de seguimiento, plantillas y otro contenido que usted envíe o genere a través del Servicio.\n\n### Datos de cuentas conectadas e integraciones\n\nSi conecta servicios de terceros, podemos recibir identificadores de cuenta, tokens OAuth, metadatos y los datos de terceros a los que nos autoriza a acceder. Según lo que habilite, esto puede incluir servicios como Google, GitHub, Microsoft, Slack, Stripe y proveedores de brokers o datos de mercado.\n\n### Datos de pago y suscripción\n\nSi se habilitan planes de pago o facturación por uso, la facturación se gestiona a través de proveedores de pago como Stripe. Podemos recibir metadatos de facturación como ID de cliente, estado de la suscripción, facturas y resultados de pago, pero no almacenamos los datos completos de las tarjetas de pago.\n\n### Datos técnicos y de uso\n\nPodemos recopilar automáticamente información como la dirección IP, el tipo de navegador, la información del dispositivo, marcas de tiempo, datos de errores, registros de solicitudes, uso de funciones, visitas a páginas y métricas de rendimiento.\n\n### Cookies, almacenamiento local y análisis\n\nEl Servicio utiliza cookies, almacenamiento local y tecnologías similares para autenticación, gestión de sesiones, preferencias, seguridad y análisis del producto.\n\nSi las funciones de análisis están habilitadas en la implementación, pueden incluir recopilación de eventos de OpenTelemetry y análisis de PostHog. PostHog puede recopilar vistas de página, clics, interacciones con formularios y datos de repetición de sesión. Los campos de contraseña se enmascaran en la repetición, pero otros campos de formulario pueden no estarlo.\n\n### Exportaciones opcionales de conjuntos de datos de entrenamiento\n\nAlgunas implementaciones pueden habilitar funciones opcionales de entrenamiento para copiloto. Si usted usa explícitamente esas funciones, el conjunto de datos registrado de edición de flujos de trabajo que decida enviar puede remitirse a un servicio de indexación o entrenamiento configurado.\n\n## 3. Fuentes de información\n\nPodemos recopilar información directamente de:\n\n- Usted, cuando crea una cuenta, sube contenido, conecta servicios o se comunica con nosotros.\n- Su navegador, su dispositivo y su uso del Servicio.\n- Cuentas y API de terceros que usted decide conectar.\n- Proveedores de pago, proveedores de análisis, proveedores de hosting y proveedores operativos.\n\nTambién podemos derivar información operativa o de diagnóstico a partir de registros, resultados de ejecución, eventos de facturación y telemetría del sistema.\n\n## 4. Cómo usamos la información\n\nPodemos usar la información que recopilamos para:\n\n- Proporcionar, mantener y proteger el Servicio.\n- Autenticar usuarios y gestionar cuentas, organizaciones y permisos.\n- Almacenar, ejecutar y resolver problemas de flujos de trabajo, chats, archivos e integraciones.\n- Procesar pagos, suscripciones, límites de uso y comunicaciones de facturación.\n- Responder a solicitudes de soporte, informes de errores y comentarios sobre el producto.\n- Supervisar rendimiento, fiabilidad, fraude, abuso e incidentes de seguridad.\n- Mejorar el Servicio y desarrollar nuevas funciones.\n- Cumplir obligaciones legales y hacer cumplir nuestros términos y políticas.\n\nSi utiliza funciones de IA o automatización, su contenido puede ser procesado por proveedores de modelos o proveedores de integración seleccionados por usted o configurados por la implementación para ofrecer la función solicitada.\n\n## 5. Cómo compartimos la información\n\nPodemos compartir información con:\n\n- Proveedores de servicios que nos ayudan a operar el Servicio, como proveedores de hosting, almacenamiento, correo electrónico, análisis, registro y pagos.\n- Plataformas de terceros, proveedores de modelos y API cuando sea necesario para ejecutar los flujos de trabajo, integraciones o funciones que usted habilite.\n- Fuerzas del orden, reguladores, tribunales u otras partes cuando la ley lo exija.\n- Un sucesor o comprador en relación con una fusión, adquisición, financiación o transferencia del Servicio.\n\nNo vendemos información personal a cambio de dinero. Aun así, pueden producirse transferencias a servicios de terceros cuando usted habilita funciones conectadas o cuando una implementación utiliza análisis u otros proveedores operativos.\n\n## 6. Google, proveedores de IA, integraciones con brokers y otros datos de servicios conectados\n\nSi conecta Google u otros servicios de terceros, accedemos y usamos esos datos solo según sea necesario para proporcionar las funciones que usted habilita, sujeto a sus permisos y a los términos del proveedor conectado.\n\nNo usamos los datos de usuarios de Google obtenidos a través de las API de Google para entrenar modelos generales de IA o ML.\n\nFuera de la función opcional de conjunto de datos de entrenamiento descrita antes, no usamos el contenido de sus flujos de trabajo para entrenar modelos generales para el Servicio.\n\n## 7. Cookies, almacenamiento local, telemetría y análisis\n\nEl Servicio utiliza cookies, almacenamiento local y tecnologías relacionadas para sesiones de inicio de sesión, estado de autenticación, preferencias de la interfaz, ajustes de cuenta, seguridad, análisis del producto y funciones operativas similares.\n\nEn las implementaciones operadas por el proyecto, la telemetría anónima puede estar habilitada de forma predeterminada y puede controlarse desde los ajustes del producto, según la implementación y el estado de su cuenta.\n\nAlgunas implementaciones también pueden habilitar PostHog o herramientas de análisis similares para análisis de páginas, análisis de interacción y repetición de sesiones. Si ejecuta una implementación autohospedada, su operador controla si esa herramienta está habilitada y a dónde se envían los datos.\n\n## 8. Procesamiento internacional\n\nLa información puede procesarse en Estados Unidos y otros países donde operan nuestros proveedores, infraestructura o proveedores de servicios conectados. Las leyes de protección de datos pueden diferir entre jurisdicciones.\n\n## 9. Retención\n\nConservamos la información durante el tiempo razonablemente necesario para operar el Servicio, mantener su cuenta, procesar la facturación, investigar abusos, cumplir obligaciones legales y resolver disputas.\n\nLos periodos de retención pueden variar según el tipo de datos y la configuración de la implementación. Los operadores autohospedados o terceros controlan sus propios ajustes de retención para sus implementaciones.\n\n## 10. Seguridad\n\nUsamos salvaguardas administrativas, técnicas y organizativas razonables para proteger la información, pero ningún sistema es completamente seguro. Usted también es responsable de proteger las credenciales de su cuenta, las claves API y las cuentas de terceros conectadas.\n\n## 11. Sus opciones y derechos\n\nSegún el lugar donde viva, puede tener derechos para acceder, corregir, eliminar, exportar u oponerse a determinados usos de sus datos personales.\n\nTambién puede controlar parte de la recopilación directamente desde los ajustes del producto, como las preferencias de telemetría anónima, o desconectando cuentas de terceros.\n\nSi se encuentra en el EEE, el Reino Unido u otra jurisdicción con derechos similares, también puede tener derechos relacionados con la restricción, la oposición, la retirada del consentimiento cuando se utilice el consentimiento y la reclamación ante una autoridad supervisora.\n\nSi es residente de California, puede tener derechos a conocer, acceder, corregir, eliminar y recibir información sobre las categorías de información personal que recopilamos, usamos y divulgamos para implementaciones operadas por el proyecto, sujeto a excepciones legales.\n\nPara realizar una solicitud relacionada con la privacidad para una implementación operada por el proyecto, contáctenos en [{{supportEmail}}](mailto:{{supportEmail}}). Si usa una implementación autohospedada o de terceros, contacte a ese operador en su lugar.\n\n## 12. Privacidad infantil\n\nEl Servicio no está dirigido a menores de 18 años y no recopilamos conscientemente datos personales de menores a través del Servicio operado por el proyecto.\n\n## 13. Servicios externos\n\nEsta Política de privacidad no cubre servicios de terceros, brokers, proveedores de modelos, proveedores de datos de mercado ni sitios a los que usted se conecte o visite a través del Servicio. Revise los propios términos y avisos de privacidad de esos proveedores.\n\n## 14. Cambios en esta política\n\nPodemos actualizar esta Política de privacidad periódicamente. Cuando lo hagamos, actualizaremos la fecha de Última actualización en esta página. Los cambios materiales se aplicarán cuando se publiquen, salvo que la ley exija un periodo de aviso más largo.\n\n## 15. Contacto\n\nSi tiene preguntas, solicitudes o reclamaciones relacionadas con esta Política de privacidad para una implementación operada por el proyecto, contáctenos en [{{supportEmail}}](mailto:{{supportEmail}}).\n\nActualmente no publicamos una dirección postal independiente en esta página. Si eso cambia, actualizaremos esta política." + "bodyMarkdown": "Esta Política de privacidad describe cómo {projectName} gestiona los datos personales cuando el propietario del proyecto TradingGoose opera el sitio web, la aplicación, las API o los servicios alojados (en conjunto, el Servicio).\n\nSi utiliza una implementación autohospedada o una implementación operada por otra persona, ese operador es responsable de su propio aviso de privacidad, tratamiento de datos, cookies, análisis, integraciones y prácticas de seguridad.\n\n## 1. Alcance y funciones\n\nEsta Política de privacidad se aplica a las implementaciones de {projectName} operadas por el proyecto. En el caso de implementaciones autohospedadas o implementaciones operadas por terceros, ese operador controla su propia configuración, almacenamiento, integraciones, análisis, retención y cumplimiento.\n\nEn esos casos, el operador tercero, y no el propietario del proyecto TradingGoose, es el controlador u operador principal de los datos de usuario de esa implementación.\n\n## 2. Información que recopilamos\n\n### Datos de cuenta y autenticación\n\nPodemos recopilar la información que usted proporciona al crear o usar una cuenta, como su nombre, dirección de correo electrónico, método de inicio de sesión, datos de perfil, pertenencia a organizaciones y ajustes de cuenta.\n\n### Datos de contenido y flujos de trabajo\n\nPodemos procesar prompts, chats, archivos, documentos, definiciones de flujos de trabajo, registros, scripts de indicadores, listas de seguimiento, plantillas y otro contenido que usted envíe o genere a través del Servicio.\n\n### Datos de cuentas conectadas e integraciones\n\nSi conecta servicios de terceros, podemos recibir identificadores de cuenta, tokens OAuth, metadatos y los datos de terceros a los que nos autoriza a acceder. Según lo que habilite, esto puede incluir servicios como Google, GitHub, Microsoft, Slack, Stripe y proveedores de brokers o datos de mercado.\n\n### Datos de pago y suscripción\n\nSi se habilitan planes de pago o facturación por uso, la facturación se gestiona a través de proveedores de pago como Stripe. Podemos recibir metadatos de facturación como ID de cliente, estado de la suscripción, facturas y resultados de pago, pero no almacenamos los datos completos de las tarjetas de pago.\n\n### Datos técnicos y de uso\n\nPodemos recopilar automáticamente información como la dirección IP, el tipo de navegador, la información del dispositivo, marcas de tiempo, datos de errores, registros de solicitudes, uso de funciones, visitas a páginas y métricas de rendimiento.\n\n### Cookies, almacenamiento local y análisis\n\nEl Servicio utiliza cookies, almacenamiento local y tecnologías similares para autenticación, gestión de sesiones, preferencias, seguridad y análisis del producto.\n\nSi las funciones de análisis están habilitadas en la implementación, pueden incluir recopilación de eventos de OpenTelemetry y análisis de PostHog. PostHog puede recopilar vistas de página, clics, interacciones con formularios y datos de repetición de sesión. Los campos de contraseña se enmascaran en la repetición, pero otros campos de formulario pueden no estarlo.\n\n### Exportaciones opcionales de conjuntos de datos de entrenamiento\n\nAlgunas implementaciones pueden habilitar funciones opcionales de entrenamiento para copiloto. Si usted usa explícitamente esas funciones, el conjunto de datos registrado de edición de flujos de trabajo que decida enviar puede remitirse a un servicio de indexación o entrenamiento configurado.\n\n## 3. Fuentes de información\n\nPodemos recopilar información directamente de:\n\n- Usted, cuando crea una cuenta, sube contenido, conecta servicios o se comunica con nosotros.\n- Su navegador, su dispositivo y su uso del Servicio.\n- Cuentas y API de terceros que usted decide conectar.\n- Proveedores de pago, proveedores de análisis, proveedores de hosting y proveedores operativos.\n\nTambién podemos derivar información operativa o de diagnóstico a partir de registros, resultados de ejecución, eventos de facturación y telemetría del sistema.\n\n## 4. Cómo usamos la información\n\nPodemos usar la información que recopilamos para:\n\n- Proporcionar, mantener y proteger el Servicio.\n- Autenticar usuarios y gestionar cuentas, organizaciones y permisos.\n- Almacenar, ejecutar y resolver problemas de flujos de trabajo, chats, archivos e integraciones.\n- Procesar pagos, suscripciones, límites de uso y comunicaciones de facturación.\n- Responder a solicitudes de soporte, informes de errores y comentarios sobre el producto.\n- Supervisar rendimiento, fiabilidad, fraude, abuso e incidentes de seguridad.\n- Mejorar el Servicio y desarrollar nuevas funciones.\n- Cumplir obligaciones legales y hacer cumplir nuestros términos y políticas.\n\nSi utiliza funciones de IA o automatización, su contenido puede ser procesado por proveedores de modelos o proveedores de integración seleccionados por usted o configurados por la implementación para ofrecer la función solicitada.\n\n## 5. Cómo compartimos la información\n\nPodemos compartir información con:\n\n- Proveedores de servicios que nos ayudan a operar el Servicio, como proveedores de hosting, almacenamiento, correo electrónico, análisis, registro y pagos.\n- Plataformas de terceros, proveedores de modelos y API cuando sea necesario para ejecutar los flujos de trabajo, integraciones o funciones que usted habilite.\n- Fuerzas del orden, reguladores, tribunales u otras partes cuando la ley lo exija.\n- Un sucesor o comprador en relación con una fusión, adquisición, financiación o transferencia del Servicio.\n\nNo vendemos información personal a cambio de dinero. Aun así, pueden producirse transferencias a servicios de terceros cuando usted habilita funciones conectadas o cuando una implementación utiliza análisis u otros proveedores operativos.\n\n## 6. Google, proveedores de IA, integraciones con brokers y otros datos de servicios conectados\n\nSi conecta Google u otros servicios de terceros, accedemos y usamos esos datos solo según sea necesario para proporcionar las funciones que usted habilita, sujeto a sus permisos y a los términos del proveedor conectado.\n\nNo usamos los datos de usuarios de Google obtenidos a través de las API de Google para entrenar modelos generales de IA o ML.\n\nFuera de la función opcional de conjunto de datos de entrenamiento descrita antes, no usamos el contenido de sus flujos de trabajo para entrenar modelos generales para el Servicio.\n\n## 7. Cookies, almacenamiento local, telemetría y análisis\n\nEl Servicio utiliza cookies, almacenamiento local y tecnologías relacionadas para sesiones de inicio de sesión, estado de autenticación, preferencias de la interfaz, ajustes de cuenta, seguridad, análisis del producto y funciones operativas similares.\n\nEn las implementaciones operadas por el proyecto, la telemetría anónima puede estar habilitada de forma predeterminada y puede controlarse desde los ajustes del producto, según la implementación y el estado de su cuenta.\n\nAlgunas implementaciones también pueden habilitar PostHog o herramientas de análisis similares para análisis de páginas, análisis de interacción y repetición de sesiones. Si ejecuta una implementación autohospedada, su operador controla si esa herramienta está habilitada y a dónde se envían los datos.\n\n## 8. Procesamiento internacional\n\nLa información puede procesarse en Estados Unidos y otros países donde operan nuestros proveedores, infraestructura o proveedores de servicios conectados. Las leyes de protección de datos pueden diferir entre jurisdicciones.\n\n## 9. Retención\n\nConservamos la información durante el tiempo razonablemente necesario para operar el Servicio, mantener su cuenta, procesar la facturación, investigar abusos, cumplir obligaciones legales y resolver disputas.\n\nLos periodos de retención pueden variar según el tipo de datos y la configuración de la implementación. Los operadores autohospedados o terceros controlan sus propios ajustes de retención para sus implementaciones.\n\n## 10. Seguridad\n\nUsamos salvaguardas administrativas, técnicas y organizativas razonables para proteger la información, pero ningún sistema es completamente seguro. Usted también es responsable de proteger las credenciales de su cuenta, las claves API y las cuentas de terceros conectadas.\n\n## 11. Sus opciones y derechos\n\nSegún el lugar donde viva, puede tener derechos para acceder, corregir, eliminar, exportar u oponerse a determinados usos de sus datos personales.\n\nTambién puede controlar parte de la recopilación directamente desde los ajustes del producto, como las preferencias de telemetría anónima, o desconectando cuentas de terceros.\n\nSi se encuentra en el EEE, el Reino Unido u otra jurisdicción con derechos similares, también puede tener derechos relacionados con la restricción, la oposición, la retirada del consentimiento cuando se utilice el consentimiento y la reclamación ante una autoridad supervisora.\n\nSi es residente de California, puede tener derechos a conocer, acceder, corregir, eliminar y recibir información sobre las categorías de información personal que recopilamos, usamos y divulgamos para implementaciones operadas por el proyecto, sujeto a excepciones legales.\n\nPara realizar una solicitud relacionada con la privacidad para una implementación operada por el proyecto, contáctenos en [{supportEmail}](mailto:{supportEmail}). Si usa una implementación autohospedada o de terceros, contacte a ese operador en su lugar.\n\n## 12. Privacidad infantil\n\nEl Servicio no está dirigido a menores de 18 años y no recopilamos conscientemente datos personales de menores a través del Servicio operado por el proyecto.\n\n## 13. Servicios externos\n\nEsta Política de privacidad no cubre servicios de terceros, brokers, proveedores de modelos, proveedores de datos de mercado ni sitios a los que usted se conecte o visite a través del Servicio. Revise los propios términos y avisos de privacidad de esos proveedores.\n\n## 14. Cambios en esta política\n\nPodemos actualizar esta Política de privacidad periódicamente. Cuando lo hagamos, actualizaremos la fecha de Última actualización en esta página. Los cambios materiales se aplicarán cuando se publiquen, salvo que la ley exija un periodo de aviso más largo.\n\n## 15. Contacto\n\nSi tiene preguntas, solicitudes o reclamaciones relacionadas con esta Política de privacidad para una implementación operada por el proyecto, contáctenos en [{supportEmail}](mailto:{supportEmail}).\n\nActualmente no publicamos una dirección postal independiente en esta página. Si eso cambia, actualizaremos esta política." }, "licenses": { "title": "Licencias y avisos", @@ -820,7 +806,7 @@ }, "warning": { "title": "Ya formas parte de un equipo", - "currentOrgWithName": "Actualmente eres miembro de \"{{name}}\". Debes salir de tu organización actual antes de aceptar una nueva invitación.", + "currentOrgWithName": "Actualmente eres miembro de \"{name}\". Debes salir de tu organización actual antes de aceptar una nueva invitación.", "currentOrg": "Ya eres miembro de una organización. Sal de tu organización actual antes de aceptar una nueva invitación.", "manageTeamSettings": "Administrar configuración del equipo" }, @@ -829,12 +815,12 @@ }, "success": { "title": "¡Bienvenido!", - "description": "Te uniste correctamente a {{name}}. Redirigiendo a tu espacio de trabajo..." + "description": "Te uniste correctamente a {name}. Redirigiendo a tu espacio de trabajo..." }, "invitation": { "organizationTitle": "Invitación a la organización", "workspaceTitle": "Invitación al espacio de trabajo", - "description": "Te han invitado a unirte a {{name}}. Haz clic en aceptar abajo para unirte.", + "description": "Te han invitado a unirte a {name}. Haz clic en aceptar abajo para unirte.", "accept": "Aceptar invitación" }, "errors": { @@ -1042,7 +1028,7 @@ "createTooltip": "Se requiere permiso de escritura para crear bases de conocimiento" }, "errors": { - "load": "Error al cargar las bases de conocimiento: {{error}}", + "load": "Error al cargar las bases de conocimiento: {error}", "retry": "Reintentar" }, "emptyState": { @@ -1078,7 +1064,7 @@ "copyId": "Copiar ID de la base de conocimiento", "deleteButtonLabel": "Eliminar base de conocimiento", "deleteTitle": "Eliminar base de conocimiento", - "deleteDescription": "¿Estás seguro de que quieres eliminar \"{{title}}\"? Esto eliminará la base de conocimiento y sus {{count}} documento{{plural}} de forma permanente.", + "deleteDescription": "¿Estás seguro de que quieres eliminar \"{title}\"? Esto eliminará la base de conocimiento y sus {count} documento{plural} de forma permanente.", "cancel": "Cancelar", "deleteConfirm": "Eliminar", "deleting": "Eliminando..." @@ -1112,7 +1098,7 @@ "nextChunk": "Siguiente fragmento", "previousPage": "página anterior", "nextPage": "página siguiente", - "editingChunk": "Editando fragmento #{{index}} • Página {{currentPage}} de {{totalPages}}", + "editingChunk": "Editando fragmento #{index} • Página {currentPage} de {totalPages}", "cancel": "Cancelar", "errors": { "failedToCreateChunk": "Error al crear el fragmento", @@ -1150,8 +1136,8 @@ "creating": "Creando...", "failedToCreateKnowledgeBase": "No se pudo crear la base de conocimiento", "unknownError": "Ocurrió un error desconocido", - "fileTooLarge": "El archivo {{name}} es demasiado grande. El tamaño máximo es 100 MB por archivo.", - "unsupportedFileType": "El archivo {{name}} tiene un formato no compatible. Utilice PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML o YML.", + "fileTooLarge": "El archivo {name} es demasiado grande. El tamaño máximo es 100 MB por archivo.", + "unsupportedFileType": "El archivo {name} tiene un formato no compatible. Utilice PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML o YML.", "processingError": "Ocurrió un error al procesar los archivos. Inténtelo de nuevo.", "validation": { "nameRequired": "El nombre es obligatorio", @@ -1180,8 +1166,8 @@ "uploadDocuments": "Cargar documentos", "uploading": "Cargando...", "processing": "Procesando...", - "fileTooLarge": "El archivo \"{{name}}\" es demasiado grande. El tamaño máximo es 100 MB.", - "unsupportedFileType": "El archivo \"{{name}}\" tiene un formato no compatible. Utilice archivos PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML o YML." + "fileTooLarge": "El archivo \"{name}\" es demasiado grande. El tamaño máximo es 100 MB.", + "unsupportedFileType": "El archivo \"{name}\" tiene un formato no compatible. Utilice archivos PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML o YML." }, "document": { "searchChunksPlaceholder": "Buscar fragmentos...", @@ -1189,13 +1175,13 @@ "deleteChunkTitle": "¿Eliminar fragmento?", "deleteChunksTitle": "¿Eliminar fragmentos?", "deleteChunkDescription": "Eliminar este fragmento lo quitará permanentemente de este documento.", - "deleteChunksDescription": "Eliminar {{count}} fragmentos los quitará permanentemente de este documento.", + "deleteChunksDescription": "Eliminar {count} fragmentos los quitará permanentemente de este documento.", "thisActionCannotBeUndone": "Esta acción no se puede deshacer.", "cancel": "Cancelar", "deleteFileTitle": "¿Eliminar archivo?", "deleteFilesTitle": "¿Eliminar archivos?", - "deleteFileDescription": "Eliminar \"{{name}}\" quitará permanentemente su archivo de origen, fragmentos y embeddings de esta base de conocimiento.", - "deleteFilesDescription": "Eliminar {{count}} archivos quitará permanentemente sus archivos de origen, fragmentos y embeddings de esta base de conocimiento.", + "deleteFileDescription": "Eliminar \"{name}\" quitará permanentemente su archivo de origen, fragmentos y embeddings de esta base de conocimiento.", + "deleteFilesDescription": "Eliminar {count} archivos quitará permanentemente sus archivos de origen, fragmentos y embeddings de esta base de conocimiento.", "delete": "Eliminar", "deleting": "Eliminando..." }, @@ -1210,8 +1196,8 @@ "addTag": "Agregar etiqueta", "emptyState": "Aún no se han agregado etiquetas. Haga clic en \"Agregar etiqueta\" para comenzar.", "advancedSettings": "Configuración avanzada", - "tagCount": "{{count}} etiqueta{{count, plural, one {} other {s}}}", - "slotsUsed": "{{used}} de {{total}} ranuras de etiqueta usadas", + "tagCount": "{count} etiqueta{count, plural, one {} other {s}}", + "slotsUsed": "{used} de {total} ranuras de etiqueta usadas", "editTag": "Editar etiqueta", "addNewTag": "Agregar nueva etiqueta", "tagName": "Nombre de la etiqueta", @@ -1234,15 +1220,15 @@ "moreTags": "Más etiquetas", "showFewerTags": "Mostrar menos etiquetas", "activeTags": "Etiquetas activas:", - "tagLabel": "Etiqueta {{index}}", + "tagLabel": "Etiqueta {index}", "deleteTagTitle": "Eliminar etiqueta", - "deleteTagDescription": "¿Está seguro de que desea eliminar la etiqueta \"{{name}}\"? Esto quitará esta etiqueta de {{count}} documento{{plural}}.", + "deleteTagDescription": "¿Está seguro de que desea eliminar la etiqueta \"{name}\"? Esto quitará esta etiqueta de {count} documento{plural}.", "thisActionCannotBeUndone": "Esta acción no se puede deshacer.", "affectedDocuments": "Documentos afectados:", "deleting": "Eliminando...", - "documentsUsingTagTitle": "Documentos que usan \"{{name}}\"", - "documentsUsingTagDescriptionSingular": "{{count}} documento usa actualmente esta definición de etiqueta.", - "documentsUsingTagDescriptionPlural": "{{count}} documentos usan actualmente esta definición de etiqueta.", + "documentsUsingTagTitle": "Documentos que usan \"{name}\"", + "documentsUsingTagDescriptionSingular": "{count} documento usa actualmente esta definición de etiqueta.", + "documentsUsingTagDescriptionPlural": "{count} documentos usan actualmente esta definición de etiqueta.", "singularIs": "usa", "pluralAre": "usan", "tagUnusedHelp": "Esta definición de etiqueta no está siendo utilizada por ningún documento. Puede eliminarla de forma segura para liberar la ranura." @@ -1286,7 +1272,7 @@ "refreshing": "Actualizando...", "chart": { "noData": "No hay datos disponibles.", - "toggleSeries": "Alternar serie {{label}}" + "toggleSeries": "Alternar serie {label}" }, "filters": { "title": "Filtros", @@ -1295,7 +1281,7 @@ "suggestedFilters": "Filtros sugeridos", "textSearch": "Búsqueda de texto", "searchPlaceholder": "Buscar registros...", - "filterOptionsPlaceholder": "No se encontraron opciones para {{title}}.", + "filterOptionsPlaceholder": "No se encontraron opciones para {title}.", "searchWorkflows": "Buscar flujos de trabajo...", "searchFolders": "Buscar carpetas...", "searchOptions": "Buscar opciones...", @@ -1305,11 +1291,11 @@ "noFolders": "No se encontraron carpetas.", "noOptions": "No se encontraron opciones.", "allWorkflows": "Todos los flujos de trabajo", - "selectedWorkflows": "{{count}} flujo de trabajo{{plural}} seleccionado", + "selectedWorkflows": "{count} flujo de trabajo{plural} seleccionado", "allFolders": "Todas las carpetas", - "selectedFolders": "{{count}} carpeta{{plural}} seleccionada", + "selectedFolders": "{count} carpeta{plural} seleccionada", "allTriggers": "Todos los disparadores", - "selectedTriggers": "{{count}} disparador{{plural}} seleccionado", + "selectedTriggers": "{count} disparador{plural} seleccionado", "allTime": "Todo el tiempo", "past30Minutes": "Últimos 30 minutos", "pastHour": "Última hora", @@ -1334,7 +1320,7 @@ "trigger": "Disparador", "timeline": "Cronología", "retentionPolicy": "Política de retención de registros", - "retentionDescription": "Los registros se eliminan automáticamente después de {{days}} días en este nivel.", + "retentionDescription": "Los registros se eliminan automáticamente después de {days} días en este nivel.", "upgradePlan": "Actualizar plan" }, "metrics": { @@ -1345,15 +1331,15 @@ }, "workflows": { "title": "Flujos de trabajo", - "legend": "Cada celda representa aproximadamente {{duration}} del rango seleccionado. Haga clic en una celda para filtrar los detalles.", - "count": "{{count}} flujo de trabajo", - "countPlural": "{{count}} flujos de trabajo", - "filteredFrom": " (filtrados de {{count}})", - "noMatches": "No se encontraron flujos de trabajo que coincidan con \"{{query}}\".", + "legend": "Cada celda representa aproximadamente {duration} del rango seleccionado. Haga clic en una celda para filtrar los detalles.", + "count": "{count} flujo de trabajo", + "countPlural": "{count} flujos de trabajo", + "filteredFrom": " (filtrados de {count})", + "noMatches": "No se encontraron flujos de trabajo que coincidan con \"{query}\".", "selectedSegment": "Segmento seleccionado", - "filteredTo": "Filtrado a {{timestamp}}", - "selectedRangeMore": " (+{{count}} segmento más{{plural}})", - "selectedRangeExecutions": "— {{count}} ejecución{{plural}}", + "filteredTo": "Filtrado a {timestamp}", + "selectedRangeMore": " (+{count} segmento más{plural})", + "selectedRangeExecutions": "— {count} ejecución{plural}", "clearFilter": "Limpiar filtro", "executions": "Ejecuciones", "success": "Éxito", @@ -1372,13 +1358,13 @@ "noExecutions": "Sin ejecuciones", "loadingMore": "Cargando más...", "scrollToLoadMore": "Desplázate para cargar más", - "succeeded": "{{success}}/{{total}} exitosos", - "segment": "Segmento {{index}}", + "succeeded": "{success}/{total} exitosos", + "segment": "Segmento {index}", "allWorkflows": "Todos los flujos de trabajo", - "multipleSelected": "{{count}} flujos de trabajo seleccionados", - "durationDay": "{{count}} día{{plural}}", - "durationHour": "{{count}} hora{{plural}}", - "durationMinute": "{{count}} minuto{{plural}}" + "multipleSelected": "{count} flujos de trabajo seleccionados", + "durationDay": "{count} día{plural}", + "durationHour": "{count} hora{plural}", + "durationMinute": "{count} minuto{plural}" } }, "list": { @@ -1396,7 +1382,7 @@ "noLogs": "No se encontraron registros", "unknownWorkflow": "Flujo de trabajo desconocido", "errorPrefix": "Error: ", - "runningMs": "{{value}} ms" + "runningMs": "{value} ms" }, "details": { "title": "Detalles del registro", @@ -1422,17 +1408,17 @@ "modelOutput": "Salida del modelo:", "total": "Total:", "tokens": "Tokens:", - "modelBreakdown": "Desglose del modelo ({{count}})", + "modelBreakdown": "Desglose del modelo ({count})", "input": "Entrada:", "output": "Salida:", - "totalCostNote": "El costo total incluye un cargo base de ejecución de {{amount}} más los costos de uso del modelo.", + "totalCostNote": "El costo total incluye un cargo base de ejecución de {amount} más los costos de uso del modelo.", "notAvailable": "No disponible", "unknownSize": "Tamaño desconocido", "unknownType": "Tipo desconocido", "unknownWorkflow": "Desconocido", "unknownLevel": "desconocido", "unknownValue": "Desconocido", - "runningMs": "{{value}} ms", + "runningMs": "{value} ms", "traceSpans": { "workflowExecution": "Ejecución del flujo de trabajo", "collapseAll": "Contraer todo", @@ -1445,21 +1431,21 @@ "initialResponse": "Respuesta inicial", "modelResponse": "Respuesta del modelo", "modelGeneration": "Generación del modelo", - "tokens": "{{count}} token{{plural}}", + "tokens": "{count} token{plural}", "tokensUnavailable": "Tokens no disponibles", - "tokensInOut": "{{input}} entrada / {{output}} salida", - "tokensTotal": "{{count}} token{{plural}} total", - "tokensTotalSuffix": " ({{count}} en total)", + "tokensInOut": "{input} entrada / {output} salida", + "tokensTotal": "{count} token{plural} total", + "tokensTotalSuffix": " ({count} en total)", "input": "Entrada", "output": "Salida", "total": "Total", "start": "Inicio", - "plusMs": "+{{ms}} ms", - "betweenBlocks": "Intervalo de {{ms}} ms", + "plusMs": "+{ms} ms", + "betweenBlocks": "Intervalo de {ms} ms", "inputSection": "Entrada", "outputSection": "Salida", "errorSection": "Error", - "segmentTimingTooltip": "{{type}}{{nameSuffix}} tomó {{duration}} ms" + "segmentTimingTooltip": "{type}{nameSuffix} tomó {duration} ms" }, "download": { "downloading": "Descargando...", @@ -1559,8 +1545,8 @@ } }, "searchEmpty": { - "workspace": "No se encontraron variables de entorno del espacio de trabajo que coincidan con \"{{query}}\".", - "personal": "No se encontraron variables de entorno personales que coincidan con \"{{query}}\"." + "workspace": "No se encontraron variables de entorno del espacio de trabajo que coincidan con \"{query}\".", + "personal": "No se encontraron variables de entorno personales que coincidan con \"{query}\"." }, "headers": { "createdAt": "Creado el", @@ -1583,7 +1569,7 @@ }, "apiKeys": { "title": "Claves API", - "cardTitle": "Claves API de {{scope}}", + "cardTitle": "Claves API de {scope}", "searchPlaceholder": "Buscar claves...", "scope": { "workspace": "Espacio de trabajo", @@ -1605,7 +1591,7 @@ "button": "Crear clave" } }, - "searchEmpty": "No se encontraron claves API de {{scope}} que coincidan con \"{{query}}\".", + "searchEmpty": "No se encontraron claves API de {scope} que coincidan con \"{query}\".", "headers": { "createdAt": "Fecha de creación", "name": "Nombre", @@ -1616,20 +1602,20 @@ "labels": { "never": "Nunca", "billingTier": "Nivel de facturación", - "lastUsed": "Último uso: {{date}}", + "lastUsed": "Último uso: {date}", "saveName": "Guardar nombre de la clave API", - "rename": "Renombrar clave API de {{scope}}", - "reveal": "Revelar clave API de {{scope}}", - "hide": "Ocultar clave API de {{scope}}", - "copy": "Copiar clave API de {{scope}}", - "save": "Guardar clave API de {{scope}}", + "rename": "Renombrar clave API de {scope}", + "reveal": "Revelar clave API de {scope}", + "hide": "Ocultar clave API de {scope}", + "copy": "Copiar clave API de {scope}", + "save": "Guardar clave API de {scope}", "cancelRename": "Cancelar cambio de nombre", - "delete": "Eliminar clave API de {{scope}}", + "delete": "Eliminar clave API de {scope}", "nameRequired": "El nombre es obligatorio", - "duplicateName": "Ya existe una clave API de {{scope}} llamada \"{{name}}\".", - "failedRename": "Error al renombrar la clave API de {{scope}}.", - "unableRename": "No se puede renombrar la clave API de {{scope}}. Inténtalo de nuevo.", - "failedCreate": "Error al crear la clave API de {{scope}}. Inténtalo de nuevo.", + "duplicateName": "Ya existe una clave API de {scope} llamada \"{name}\".", + "failedRename": "Error al renombrar la clave API de {scope}.", + "unableRename": "No se puede renombrar la clave API de {scope}. Inténtalo de nuevo.", + "failedCreate": "Error al crear la clave API de {scope}. Inténtalo de nuevo.", "workspaceAccess": "Esta clave otorga acceso a todos los flujos de trabajo y archivos dentro de este espacio de trabajo. Cópiala inmediatamente después de crearla, ya que no podrás volver a verla.", "personalAccess": "Esta clave otorga acceso a tus flujos de trabajo y archivos personales. Cópiala inmediatamente después de crearla, ya que no podrás volver a verla.", "onlyTimeYouWillSee": "Esta es la única vez que verás la clave completa. Cópiala y guárdala de forma segura.", @@ -1637,15 +1623,15 @@ "workspacePermissions": "Necesitas acceso de edición o administrador para gestionar las claves API del espacio de trabajo." }, "dialogs": { - "createTitle": "Crear clave API de {{scope}}", + "createTitle": "Crear clave API de {scope}", "createNameLabel": "Nombre", "createNamePlaceholder": "p. ej., Servidor MCP de producción", "createButton": "Crear clave", - "newKeyTitle": "Tu clave API de {{scope}}", + "newKeyTitle": "Tu clave API de {scope}", "newKeyDescription": "Esta es la única vez que verás la clave completa. Cópiala y guárdala de forma segura.", - "deleteTitle": "¿Eliminar la clave API de {{scope}}?", + "deleteTitle": "¿Eliminar la clave API de {scope}?", "deleteDescription": "Esto revocará inmediatamente el acceso de cualquier integración que use esta clave.", - "deletePrompt": "Escribe {{name}} para confirmar.", + "deletePrompt": "Escribe {name} para confirmar.", "deletePlaceholder": "Nombre de la clave API", "cancel": "Cancelar", "deleteButton": "Eliminar clave", @@ -1684,7 +1670,7 @@ "searchPlaceholder": "Buscar órdenes...", "filters": "Filtros", "orderFilters": "Filtros de órdenes", - "showingOf": "Mostrando {{loadedCount}} de {{totalCount}}", + "showingOf": "Mostrando {loadedCount} de {totalCount}", "clear": "Limpiar", "allProviders": "Todos los proveedores", "allEnvironments": "Todos los entornos", @@ -1761,7 +1747,7 @@ "disconnect": "Desconectar", "emptyState": { "noConnectible": "No hay integraciones conectables configuradas.", - "noSearchMatches": "No se encontraron servicios que coincidan con \"{{query}}\"" + "noSearchMatches": "No se encontraron servicios que coincidan con \"{query}\"" }, "errors": { "loadAvailability": "Error al cargar la disponibilidad del proveedor", @@ -1774,7 +1760,7 @@ "upload": { "idle": "Subir archivo", "uploading": "Subiendo...", - "uploadingWithCount": "Subiendo {{completed}}/{{total}}...", + "uploadingWithCount": "Subiendo {completed}/{total}...", "button": "Subir archivo" }, "headers": { @@ -1798,7 +1784,7 @@ }, "deleteDialog": { "title": "¿Eliminar archivo?", - "descriptionWithName": "Eliminar \"{{name}}\" lo eliminará permanentemente de este espacio de trabajo.", + "descriptionWithName": "Eliminar \"{name}\" lo eliminará permanentemente de este espacio de trabajo.", "description": "Eliminar este archivo lo eliminará permanentemente de este espacio de trabajo.", "warning": "Esta acción no se puede deshacer.", "cancel": "Cancelar", @@ -1808,7 +1794,7 @@ "errors": { "billingTier": "Nivel de facturación", "uploadFailed": "Error al cargar", - "unsupportedFileType": "Tipo de archivo no compatible: {{files}}" + "unsupportedFileType": "Tipo de archivo no compatible: {files}" } }, "userMenu": { @@ -1824,7 +1810,7 @@ "loggingOut": "Cerrando sesión…", "billingPortalSelectOrganization": "Seleccione una organización para gestionar la facturación.", "billingPortalFailed": "Error al abrir el portal de facturación", - "themeLabel": "Tema: {{theme}}", + "themeLabel": "Tema: {theme}", "languageLabel": "Idioma", "themeOptions": { "light": "Claro", @@ -1875,8 +1861,8 @@ "nameRequired": "Proporcione un nombre.", "saveError": "No se pueden guardar los ajustes del perfil.", "nameRequiredValidation": "El nombre es obligatorio", - "profilePictureFileTooLarge": "El archivo {{name}} es demasiado grande. El tamaño máximo es 5 MB.", - "profilePictureUnsupportedFormat": "El archivo {{name}} no es un formato de imagen compatible. Use PNG o JPEG.", + "profilePictureFileTooLarge": "El archivo {name} es demasiado grande. El tamaño máximo es 5 MB.", + "profilePictureUnsupportedFormat": "El archivo {name} no es un formato de imagen compatible. Use PNG o JPEG.", "profilePictureUpdateError": "Error al actualizar la foto de perfil", "profilePictureRemoveError": "Error al eliminar la foto de perfil", "unableToUpdateProfilePicture": "No se puede actualizar la foto de perfil.", @@ -1905,7 +1891,7 @@ "dropImagesBrowse": "Arrastra imágenes aquí o haz clic para examinar", "imageHint": "JPEG, PNG, WebP, GIF (máx. 20MB cada uno)", "uploadedImages": "Imágenes subidas", - "previewAlt": "Vista previa {{index}}", + "previewAlt": "Vista previa {index}", "cancel": "Cancelar", "submit": "Enviar", "submitting": "Enviando...", @@ -1916,8 +1902,8 @@ "subjectRequired": "El asunto es obligatorio", "messageRequired": "El mensaje es obligatorio", "requestTypeRequired": "Seleccione un tipo de solicitud", - "fileTooLarge": "El archivo {{name}} es demasiado grande. El tamaño máximo es 20 MB.", - "unsupportedFormat": "El archivo {{name}} tiene un formato no compatible. Use JPEG, PNG, WebP o GIF.", + "fileTooLarge": "El archivo {name} es demasiado grande. El tamaño máximo es 20 MB.", + "unsupportedFormat": "El archivo {name} tiene un formato no compatible. Use JPEG, PNG, WebP o GIF.", "processing": "Ocurrió un error al procesar las imágenes. Inténtelo de nuevo.", "submitFailed": "Error al enviar la solicitud de ayuda", "unknown": "Ocurrió un error desconocido" @@ -1955,7 +1941,7 @@ "custom": "Personalizado", "seats": "Asientos" }, - "seatsText": "{{count}} asientos", + "seatsText": "{count} asientos", "descriptions": { "manage": "Abra el Portal de Facturación de Stripe para cancelar, restaurar o actualizar su suscripción.", "usageNotifications": "Envíeme un correo electrónico cuando el uso alcance el umbral de advertencia de facturación.", @@ -1981,7 +1967,7 @@ "actions": { "manage": "Gestionar", "contact": "Contactar", - "upgradeTo": "Actualizar a {{name}}" + "upgradeTo": "Actualizar a {name}" }, "badges": { "resolvePayment": "Resolver pago", @@ -2000,8 +1986,8 @@ }, "team": { "error": "Error", - "defaultTeamName": "Equipo de {{name}}", - "billingHowWorksSeatCost": "El costo de {{seats}} asiento{{plural}} es de ${{amount}} al mes.", + "defaultTeamName": "Equipo de {name}", + "billingHowWorksSeatCost": "El costo de {seats} asiento{plural} es de ${amount} al mes.", "billingHowWorksUsageTracked": "El uso se rastrea en todos los espacios de trabajo de la organización.", "billingHowWorksIncreaseLimit": "Aumenta el límite cuando la organización necesite más capacidad.", "billingHowWorksOverage": "Los excesos se facturan según el plan activo.", @@ -2025,16 +2011,16 @@ "subscriptionMayNeedTransfer": "Es posible que tu suscripción deba transferirse a esta organización.", "setUpTeamSubscription": "Configurar suscripción de equipo", "seats": "Asientos", - "pricePerSeat": "({{price}}/mes cada uno)", - "used": "{{count}} usado", - "total": "{{count}} en total", + "pricePerSeat": "({price}/mes cada uno)", + "used": "{count} usado", + "total": "{count} en total", "removeSeat": "Eliminar asiento", "addSeat": "Agregar asiento", "seat": "asiento", "numberOfSeats": "Número de asientos", - "yourTeamWillHave": "Tu equipo tendrá {{count}} {{seatWord}} con un total de ${{cost}} créditos de inferencia por mes.", - "minimumSeatsNoMax": "Mínimo {{minimum}} asientos. No hay límite máximo de asientos para este plan.", - "chooseBetweenSeats": "Elige entre {{minimum}} y {{maximum}} asientos para este plan.", + "yourTeamWillHave": "Tu equipo tendrá {count} {seatWord} con un total de ${cost} créditos de inferencia por mes.", + "minimumSeatsNoMax": "Mínimo {minimum} asientos. No hay límite máximo de asientos para este plan.", + "chooseBetweenSeats": "Elige entre {minimum} y {maximum} asientos para este plan.", "currentSeats": "Asientos actuales:", "newSeats": "Asientos nuevos:", "monthlyCostChange": "Cambio de costo mensual:", @@ -2043,7 +2029,7 @@ "leaveOrganization": "Salir de la organización", "removeTeamMember": "Eliminar miembro del equipo", "leaveOrganizationDescription": "¿Estás seguro de que quieres salir de esta organización? Perderás el acceso a todos los recursos del equipo.", - "removeMemberDescription": "¿Estás seguro de que quieres eliminar a {{name}} del equipo?", + "removeMemberDescription": "¿Estás seguro de que quieres eliminar a {name} del equipo?", "alsoReduceSeatCount": "Reducir también el número de asientos en mi suscripción", "reduceSeatCountDescription": "Si se selecciona, el número de asientos de tu equipo se reducirá en 1, lo que disminuirá tu facturación mensual.", "thisActionCannotBeUndone": "Esta acción no se puede deshacer.", @@ -2055,7 +2041,7 @@ "noPublicAdjustableTier": "No hay ningún nivel de organización ajustable público configurado", "addSeats": { "title": "Agregar asientos de equipo", - "description": "Cada asiento cuesta ${{price}}/mes y proporciona ${{price}} en créditos de inferencia mensuales. Ajusta la cantidad de asientos con licencia para tu equipo.", + "description": "Cada asiento cuesta ${price}/mes y proporciona ${price} en créditos de inferencia mensuales. Ajusta la cantidad de asientos con licencia para tu equipo.", "confirm": "Actualizar asientos" }, "billing": { @@ -2075,7 +2061,7 @@ "members": { "title": "Miembros del equipo", "empty": "Aún no hay miembros en el equipo.", - "sharedUsage": "Uso compartido: ${{amount}}", + "sharedUsage": "Uso compartido: ${amount}", "pending": "Pendiente", "billing": "Facturación", "usage": "Uso", @@ -2097,13 +2083,13 @@ "unavailable": "Invitación no disponible", "workspaceAccess": "Acceso al espacio de trabajo", "optional": "Opcional", - "selected": "{{count}} espacio de trabajo seleccionado{{plural}}", + "selected": "{count} espacio de trabajo seleccionado{plural}", "grantAccess": "Conceder acceso a espacios de trabajo específicos y elegir un nivel de permiso.", "noWorkspacesAvailable": "No hay espacios de trabajo disponibles.", "needAdminAccess": "Necesita acceso de administrador para asignar espacios de trabajo.", "owner": "Propietario", "sentSuccess": "Invitación enviada.", - "sentSuccessWithAccess": "Invitación enviada con acceso a {{count}} espacio de trabajo{{plural}}.", + "sentSuccessWithAccess": "Invitación enviada con acceso a {count} espacio de trabajo{plural}.", "invalidEmail": "Ingrese una dirección de correo electrónico válida.", "permissions": { "read": { @@ -2180,7 +2166,7 @@ "providerError": "Error al configurar el proveedor SSO", "reloadError": "Error al recargar los proveedores SSO", "validation": { - "fieldRequired": "{{field}} es obligatorio.", + "fieldRequired": "{field} es obligatorio.", "providerIdRequired": "El ID del proveedor es obligatorio.", "providerIdPattern": "Use solo letras, números y guiones.", "issuerUrlRequired": "La URL del emisor es obligatoria.", @@ -2235,11 +2221,11 @@ "webhook": { "common": { "configureButton": "Configurar webhook", - "connectedLabel": "{{provider}} conectado", - "configureTitle": "Configurar webhook de {{provider}}", - "editTitle": "Editar webhook de {{provider}}", + "connectedLabel": "{provider} conectado", + "configureTitle": "Configurar webhook de {provider}", + "editTitle": "Editar webhook de {provider}", "close": "Cerrar", - "learnMoreAbout": "Más información sobre {{topic}}", + "learnMoreAbout": "Más información sobre {topic}", "showSecret": "Mostrar secreto", "hideSecret": "Ocultar secreto", "copyValue": "Copiar valor", @@ -2254,14 +2240,14 @@ "validConfiguration": "La configuración del webhook es válida", "testFailure": "La prueba del webhook falló", "telegramSslError": "Telegram requiere una URL HTTPS pública con un certificado SSL válido.", - "telegramTestFailure": "La prueba del webhook de Telegram falló: {{error}}", + "telegramTestFailure": "La prueba del webhook de Telegram falló: {error}", "testError": "Ocurrió un error inesperado al probar el webhook", "testUrlLabel": "URL de prueba del webhook", "testUrlHint": "Usa esta URL temporal para enviar una solicitud de ejemplo a tu flujo.", "generate": "Generar", "generating": "Generando...", "regenerate": "Regenerar", - "expiresAt": "Caduca el {{timestamp}}", + "expiresAt": "Caduca el {timestamp}", "delete": "Eliminar", "deleting": "Eliminando...", "testWebhook": "Probar webhook", @@ -2315,7 +2301,7 @@ "copyWebhookUrl": "Copia la URL del webhook.", "configureService": "Pégala en el servicio que enviará el webhook.", "includeBearerHeader": "Incluye un encabezado Authorization con el token generado.", - "includeNamedHeader": "Incluye un encabezado {{header}} con el token generado." + "includeNamedHeader": "Incluye un encabezado {header} con el token generado." } }, "github": { @@ -2347,7 +2333,7 @@ "addWebhook": "Ve a Webhooks y haz clic en \"Add webhook\".", "pastePayloadUrlPrefix": "Pega la", "pastePayloadUrlSuffix": "en el campo Payload URL.", - "selectContentType": "Establece Content type en {{contentType}}.", + "selectContentType": "Establece Content type en {contentType}.", "enterWebhookSecretPrefix": "Copia el secreto generado en", "enterWebhookSecretSuffix": ".", "setSslVerification": "Activa la verificación SSL salvo que estés probando en un entorno controlado.", @@ -2386,7 +2372,7 @@ "notice": { "payloadTitle": "Ejemplo de carga útil de evento de Gmail" }, - "fallbackLabels": { + "defaultLabels": { "inbox": "Bandeja de entrada", "sent": "Enviados", "important": "Importante", @@ -2425,7 +2411,7 @@ "notice": { "payloadTitle": "Ejemplo de carga útil de evento de Outlook" }, - "fallbackFolders": { + "defaultFolders": { "inbox": "Bandeja de entrada", "sentItems": "Elementos enviados", "drafts": "Borradores", @@ -2702,7 +2688,7 @@ "downloadConsoleCsv": "Descargar CSV de la consola", "downloadCsv": "Descargar CSV", "clearConsole": "Limpiar consola", - "noResults": "No se encontraron resultados para \"{{query}}\"", + "noResults": "No se encontraron resultados para \"{query}\"", "showLess": "Mostrar menos", "showMore": "Mostrar más", "copyValue": "Copiar valor", @@ -2727,10 +2713,10 @@ "noConsoleEntries": "No hay entradas en la consola", "generatedImageAlt": "Imagen generada", "downloadImageFailed": "No se pudo descargar la imagen. Inténtalo de nuevo más tarde.", - "summaryItemsSingular": "{{count}} elemento", - "summaryItemsPlural": "{{count}} elementos", - "summaryKeysSingular": "{{count}} clave", - "summaryKeysPlural": "{{count}} claves" + "summaryItemsSingular": "{count} elemento", + "summaryItemsPlural": "{count} elementos", + "summaryKeysSingular": "{count} clave", + "summaryKeysPlural": "{count} claves" }, "workflowChat": { "selectWorkspace": "Selecciona un espacio de trabajo para cargar flujos de trabajo.", @@ -2744,22 +2730,22 @@ "fileUploadError": "Error de carga de archivos", "attachFiles": "Adjuntar archivos", "attach": "Adjuntar", - "maximumFilesAllowed": "Máximo {{maxFiles}} archivos permitidos", - "selectedFiles": "{{count}}/{{maxFiles}} archivos", + "maximumFilesAllowed": "Máximo {maxFiles} archivos permitidos", + "selectedFiles": "{count}/{maxFiles} archivos", "removeFile": "Quitar archivo", "dropFilesHere": "Suelta archivos aquí para adjuntarlos", "typeMessage": "Escribe un mensaje...", - "uploadedFiles": "Se cargaron {{count}} archivo{{plural}}", - "fileTooLarge": "{{name}} es demasiado grande (máx. {{maxSize}} MB)", - "fileTypeNotSupported": "El tipo de {{name}} no es compatible", - "fileAlreadyAdded": "{{name}} ya fue agregado", + "uploadedFiles": "Se cargaron {count} archivo{plural}", + "fileTooLarge": "{name} es demasiado grande (máx. {maxSize} MB)", + "fileTypeNotSupported": "El tipo de {name} no es compatible", + "fileAlreadyAdded": "{name} ya fue agregado", "workflowExecutionFailed": "La ejecución del flujo de trabajo falló.", "errorPrefix": "Error: " }, "workflowOutputSelect": { "defaultPlaceholder": "Seleccionar fuentes de salida", - "fallbackBlockName": "Bloque {{id}}", - "selectedCount": "{{count}} seleccionados", + "defaultBlockName": "Bloque {id}", + "selectedCount": "{count} seleccionados", "searchPlaceholder": "Buscar salidas...", "noMatchingOutputs": "No hay salidas coincidentes.", "noOutputsAvailable": "No hay salidas disponibles." @@ -2823,16 +2809,16 @@ "serverNameRequired": "El nombre del servidor es obligatorio.", "failedToRefreshMcpServer": "No se pudieron actualizar las herramientas del servidor MCP.", "failedToSaveMcpServer": "No se pudo guardar el servidor MCP.", - "toolCount": "{{count}} herramientas", + "toolCount": "{count} herramientas", "loading": "Cargando...", "connected": "Conectado", "error": "Error", "draft": "Borrador", "disconnected": "Desconectado", "unnamedServer": "Servidor sin nombre", - "updated": "Actualizado {{time}}", - "toolsRefreshed": "Herramientas actualizadas {{time}}", - "lastConnected": "Última conexión {{time}}", + "updated": "Actualizado {time}", + "toolsRefreshed": "Herramientas actualizadas {time}", + "lastConnected": "Última conexión {time}", "lastError": "Último error", "noSharedMcpServerSelected": "Este color aún no tiene un servidor MCP compartido seleccionado.", "mcpServerNotFound": "No se encontró el servidor MCP.", @@ -2840,12 +2826,12 @@ "serverNameIsRequired": "El nombre del servidor es obligatorio.", "relativeTime": { "justNow": "ahora mismo", - "minutesAgo": "hace {{count}} min", - "hoursAgo": "hace {{count}} h", - "daysAgo": "hace {{count}} d", - "weeksAgo": "hace {{count}} sem", - "monthsAgo": "hace {{count}} meses", - "yearsAgo": "hace {{count}} a" + "minutesAgo": "hace {count} min", + "hoursAgo": "hace {count} h", + "daysAgo": "hace {count} d", + "weeksAgo": "hace {count} sem", + "monthsAgo": "hace {count} meses", + "yearsAgo": "hace {count} a" } }, "triggerList": { @@ -2854,7 +2840,7 @@ "searchPlaceholder": "Buscar disparadores", "openTriggerList": "Haz clic para agregar disparador", "close": "Cerrar", - "noResults": "No se encontraron resultados para \"{{query}}\"" + "noResults": "No se encontraron resultados para \"{query}\"" }, "workflowToolbar": { "selectWorkspace": "Selecciona un espacio de trabajo para explorar bloques", @@ -2862,14 +2848,13 @@ "tools": "Herramientas", "triggers": "Disparadores", "special": "Especial", - "browseLabel": "Explorar {{label}}", - "searchPlaceholder": "Buscar {{label}}...", - "noResults": "No se encontraron {{label}}." + "browseLabel": "Explorar {label}", + "searchPlaceholder": "Buscar {label}...", + "noResults": "No se encontraron {label}." }, "workflowLabels": { "systemPrompt": "Prompt del sistema", "userPrompt": "Prompt del usuario", - "model": "Modelo", "temperature": "Temperatura", "Signal Briefing": "Resumen de señales", "Indicator Monitor": "Monitor de indicadores", @@ -2909,10 +2894,8 @@ "decisionRouter": "Enrutador de decisiones", "increasePosition": "Aumentar posición", "reduceExposure": "Reducir exposición", - "apiKey": "Clave API", "skills": "Habilidades", "tools": "Herramientas", - "url": "URL", "method": "Método", "queryParams": "Parámetros de consulta", "headers": "Encabezados", @@ -2928,7 +2911,6 @@ "reasoningEffort": "Esfuerzo de razonamiento", "verbosity": "Nivel de detalle", "configured": "Configurado", - "value": "Valor", "items": "Elementos", "fields": "Campos", "object": "Objeto", @@ -2949,41 +2931,22 @@ "nextStep": "Siguiente paso", "locked": "Bloqueado", "deployed": "Desplegado", - "deployedWithVersion": "Desplegado (v{{version}})", + "deployedWithVersion": "Desplegado (v{version})", "notDeployed": "No desplegado", "disabled": "Deshabilitado", - "key": "Clave", "start": "Inicio", "end": "Fin", - "removeSkill": "Eliminar {{name}}", + "removeSkill": "Eliminar {name}", "currentWorkflow": "Flujo actual", "currentSkill": "Habilidad actual", "currentTool": "Herramienta actual", "currentIndicator": "Indicador actual", "currentMcpServer": "Servidor MCP actual", - "task": "Tarea", - "Task": "Tarea", - "variables": "Variables", - "Variables": "Variables", - "startingUrl": "URL de inicio", - "Starting URL": "URL de inicio", - "outputSchema": "Esquema de salida", - "Output Schema": "Esquema de salida", - "anthropicApiKey": "Clave de API de Anthropic", - "Anthropic API Key": "Clave de API de Anthropic", "workflows": "Flujos de trabajo", "customTools": "Herramientas personalizadas", "indicators": "Indicadores", "mcpServers": "Servidores MCP", "allWorkflows": "Todos los flujos de trabajo", - "contentToValidate": "Contenido a validar", - "enterContentToValidate": "Ingresa el contenido a validar", - "validationType": "Tipo de validación", - "validJson": "JSON válido", - "regexMatch": "Coincidencia regex", - "hallucinationCheck": "Verificación de alucinaciones", - "piiDetection": "Detección de PII", - "regexPattern": "Patrón regex", "operation": "Operación", "id": "Identificador", "role": "Rol", @@ -3001,12 +2964,6 @@ "enterMemoryIdentifierToDelete": "Ingresa el identificador de memoria para eliminar", "selectAgentRole": "Selecciona el rol del agente", "enterMessageContent": "Ingresa el contenido del mensaje", - "Enter the starting URL for the agent": "Introduce la URL de inicio del agente", - "enterTheStartingUrlForTheAgent": "Introduce la URL de inicio del agente", - "enterTheTaskOrGoalForTheAgentToAchieveReferenceVariablesUsingKeySyntax": "Introduce la tarea u objetivo que el agente debe lograr. Haz referencia a variables usando la sintaxis %key%.", - "Enter your Anthropic API key": "Introduce tu clave de API de Anthropic", - "enterYourAnthropicApiKey": "Introduce tu clave de API de Anthropic", - "knowledgeBase": "Base de conocimiento", "selectKnowledgeBase": "Seleccionar base de conocimiento", "selectKnowledgeBases": "Seleccionar bases de conocimiento", "searchQuery": "Consulta de búsqueda", @@ -3022,13 +2979,13 @@ "searchTeams": "Buscar equipos...", "searchKnowledgeBases": "Buscar bases de conocimiento...", "searchFiles": "Buscar archivos...", - "searchItems": "Buscar {{itemName}}...", - "loadingItems": "Cargando {{itemName}}...", - "noItemsFound": "No se encontraron {{itemName}}.", - "noItemsFoundInService": "No se encontraron {{itemName}} en tu {{serviceName}}.", - "connectProviderAccountToContinue": "Conecta una cuenta de {{providerName}} para continuar.", - "connectProviderAccount": "Conectar cuenta de {{providerName}}", - "openInProvider": "Abrir en {{providerName}}", + "searchItems": "Buscar {itemName}...", + "loadingItems": "Cargando {itemName}...", + "noItemsFound": "No se encontraron {itemName}.", + "noItemsFoundInService": "No se encontraron {itemName} en tu {serviceName}.", + "connectProviderAccountToContinue": "Conecta una cuenta de {providerName} para continuar.", + "connectProviderAccount": "Conectar cuenta de {providerName}", + "openInProvider": "Abrir en {providerName}", "openInDrive": "Abrir en Drive", "openInConfluence": "Abrir en Confluence", "openInJira": "Abrir en Jira", @@ -3059,25 +3016,11 @@ "selectContact": "Selecciona un contacto", "selectItem": "Selecciona un elemento", "selectATeamFirst": "Selecciona primero un equipo", - "typeOrSelectModel": "Escribe o selecciona un modelo...", - "confidence": "Confianza", "numberOfResults": "Número de resultados", - "numberOfChunksToRetrieve": "Número de fragmentos a recuperar", - "enterYourApiKey": "Ingresa tu clave API", - "piiTypesToDetect": "Tipos de PII a detectar", - "action": "Acción", - "blockRequest": "Bloquear solicitud", - "maskPii": "Enmascarar PII", - "language": "Idioma", - "english": "Inglés", - "spanish": "Español", - "italian": "Italiano", - "polish": "Polaco", - "finnish": "Finlandés", "configurePiiTypes": "Configurar tipos de PII", "noneSelected": "Ninguno seleccionado", "allSelected": "Todos seleccionados", - "selectedCount": "{{count}} seleccionados", + "selectedCount": "{count} seleccionados", "selectPiiTypesToDetect": "Seleccionar tipos de PII a detectar", "choosePiiTypesToDetect": "Elige qué tipos de información de identificación personal detectar y bloquear.", "selectAllEntities": "Seleccionar todas las entidades", @@ -3147,7 +3090,7 @@ "contacts": "Contactos", "tagFilters": "Filtros de etiquetas", "noFilters": "Sin filtros", - "filtersApplied": "{{count}} filtro(s) aplicado(s)", + "filtersApplied": "{count} filtro(s) aplicado(s)", "tagName": "Nombre de etiqueta", "selectTag": "Selecciona una etiqueta", "tryDifferentSearchOrAccount": "Intenta una búsqueda o una cuenta diferente.", @@ -3174,51 +3117,7 @@ "missingRequiredFields": "Faltan campos obligatorios", "saveConfigReturnedFalse": "Guardar configuración devolvió falso", "anErrorOccurredWhileSaving": "Se produjo un error al guardar.", - "common": "Comunes", - "usa": "EE. UU.", - "uk": "Reino Unido", - "spain": "España", - "italy": "Italia", - "poland": "Polonia", - "singapore": "Singapur", - "australia": "Australia", - "india": "India", "other": "Otros", - "personName": "Nombre de persona", - "emailAddress": "Dirección de correo electrónico", - "phoneNumber": "Número de teléfono", - "location": "Ubicación", - "dateOrTime": "Fecha u hora", - "ipAddress": "Dirección IP", - "creditCardNumber": "Número de tarjeta de crédito", - "internationalBankAccountNumber": "Número internacional de cuenta bancaria (IBAN)", - "cryptocurrencyWalletAddress": "Dirección de billetera de criptomonedas", - "medicalLicenseNumber": "Número de licencia médica", - "nationalityReligionPoliticalGroup": "Nacionalidad / religión / grupo político", - "usBankAccountNumber": "Número de cuenta bancaria de EE. UU.", - "usDriverLicenseNumber": "Número de licencia de conducir de EE. UU.", - "usIndividualTaxpayerIdentificationNumber": "Número de identificación personal del contribuyente de EE. UU. (ITIN)", - "usPassportNumber": "Número de pasaporte de EE. UU.", - "usSocialSecurityNumber": "Número de Seguro Social de EE. UU.", - "ukNationalInsuranceNumber": "Número de Seguro Nacional del Reino Unido", - "ukNhsNumber": "Número del NHS del Reino Unido", - "spanishNifNumber": "Número NIF español", - "spanishNieNumber": "Número NIE español", - "italianFiscalCode": "Código fiscal italiano", - "italianDriverLicense": "Licencia de conducir italiana", - "italianIdentityCard": "Documento de identidad italiano", - "italianPassport": "Pasaporte italiano", - "polishPesel": "PESEL polaco", - "singaporeNricFin": "NRIC/FIN de Singapur", - "australianBusinessNumber": "Número de empresa australiano (ABN)", - "australianCompanyNumber": "Número de compañía australiano (ACN)", - "australianTaxFileNumber": "Número de archivo fiscal australiano (TFN)", - "australianMedicareNumber": "Número de Medicare australiano", - "indianAadhaar": "Aadhaar de la India", - "indianPan": "PAN de la India", - "indianVehicleRegistration": "Matrícula vehicular de la India", - "indianVoterNumber": "Número de votante de la India", - "indianPassport": "Pasaporte de la India", "Provider": "Proveedor", "Text": "Texto", "Prompt": "Instrucción", @@ -3268,7 +3167,6 @@ "Variable Assignments": "Asignaciones de variables", "Wait Amount": "Tiempo de espera", "Unit": "Unidad", - "enterJsonSchema": "Introduce el esquema JSON...", "describeTheAIAgentYouWantToCreate": "Describe el agente de IA que quieres crear...", "enterSystemPrompt": "Introduce el prompt del sistema...", "enterContextOrUserMessage": "Introduce contexto o mensaje de usuario...", @@ -3309,7 +3207,15 @@ "eventPayloadExample": "Ejemplo de carga útil del evento", "webhookUrlWillBeGenerated": "La URL del webhook se generará", "setupInstructions": "Instrucciones de configuración", - "enabled": "Habilitado" + "enabled": "Habilitado", + "pleaseSelectSharePointCredentialsFirst": "Selecciona primero las credenciales de SharePoint", + "pleaseSelectMicrosoftPlannerCredentialsFirst": "Selecciona primero las credenciales de Microsoft Planner", + "pleaseEnterAPlanIdFirst": "Introduce primero un ID de plan", + "pleaseSelectMicrosoftTeamsCredentialsFirst": "Selecciona primero las credenciales de Microsoft Teams", + "pleaseSelectWealthboxCredentialsFirst": "Selecciona primero las credenciales de Wealthbox", + "pleaseSelectGoogleDriveCredentialsFirst": "Selecciona primero las credenciales de Google Drive", + "url": "URL", + "value": "Valor" }, "blockEditor": { "blockNames": { @@ -3465,7 +3371,9 @@ "youtube": "YouTube", "zendesk": "Zendesk", "zep": "Zep", - "zoom": "Zoom" + "zoom": "Zoom", + "portfolio_detail": "Detalle de portafolio", + "watchlist": "Lista de seguimiento" }, "blockDescriptions": { "response": "Envía una respuesta de API estructurada", @@ -3618,7 +3526,9 @@ "youtube": "Interactuar con videos, canales y listas de reproducción de YouTube", "zendesk": "Gestionar tickets de soporte, usuarios y organizaciones en Zendesk", "zep": "Memoria a largo plazo para agentes de IA", - "zoom": "Crear y gestionar reuniones y grabaciones de Zoom" + "zoom": "Crear y gestionar reuniones y grabaciones de Zoom", + "portfolio_detail": "Obtiene el detalle completo del portafolio desde una cuenta de broker seleccionada.", + "watchlist": "Lee listas de seguimiento y agrega o elimina elementos de listado." }, "blockLongDescriptions": { "response": "Integra Response en el flujo de trabajo. Puede enviar, construir o editar respuestas estructuradas en una respuesta final del flujo de trabajo.", @@ -3755,10 +3665,7 @@ }, "headers": { "title": "Encabezados de Response", - "columns": [ - "Clave", - "Valor" - ], + "columns": ["Clave", "Valor"], "description": "Encabezados HTTP adicionales para incluir en la respuesta" } }, @@ -6774,7 +6681,7 @@ "title": "Cuenta de Google" }, "findText": { - "placeholder": "Text to find (e.g., {{placeholder}})", + "placeholder": "Texto a buscar (p. ej., {{placeholder}})", "title": "Buscar texto" }, "folderId": { @@ -10825,7 +10732,7 @@ "title": "Nombre de archivo" }, "fileReference": { - "placeholder": "Referenciar archivo from previous block (e.g., {{block_1.file}})", + "placeholder": "Referenciar archivo del bloque anterior (p. ej., {{block_1.file}})", "title": "Archivo" }, "folderName": { @@ -15031,11 +14938,6 @@ "title": "URL" } }, - "stagehand_agent": { - "variables": { - "title": "Variables" - } - }, "stripe": { "active": { "options": [ @@ -17997,6 +17899,299 @@ "waitingRoom": { "title": "Waiting Room" } + }, + "stagehand_agent": { + "startUrl": { + "title": "URL de inicio", + "placeholder": "Introduce la URL de inicio del agente" + }, + "task": { + "title": "Tarea", + "placeholder": "Introduce la tarea u objetivo que el agente debe lograr. Haz referencia a variables usando la sintaxis %key%." + }, + "variables": { + "title": "Variables", + "columns": ["Clave", "Valor"] + }, + "apiKey": { + "title": "Clave de API de Anthropic", + "placeholder": "Introduce tu clave de API de Anthropic" + }, + "outputSchema": { + "title": "Esquema de salida", + "placeholder": "Introduce el esquema JSON..." + } + }, + "guardrails": { + "input": { + "title": "Contenido a validar", + "placeholder": "Ingresa el contenido a validar" + }, + "validationType": { + "title": "Tipo de validación", + "options": [ + { + "id": "json", + "label": "JSON válido" + }, + { + "id": "regex", + "label": "Coincidencia regex" + }, + { + "id": "hallucination", + "label": "Verificación de alucinaciones" + }, + { + "id": "pii", + "label": "Detección de PII" + } + ] + }, + "regex": { + "title": "Patrón regex" + }, + "knowledgeBaseId": { + "title": "Base de conocimiento", + "placeholder": "Seleccionar base de conocimiento" + }, + "model": { + "title": "Modelo", + "placeholder": "Escribe o selecciona un modelo..." + }, + "threshold": { + "title": "Confianza" + }, + "topK": { + "title": "Número de fragmentos a recuperar" + }, + "apiKey": { + "title": "Clave API", + "placeholder": "Ingresa tu clave API" + }, + "piiEntityTypes": { + "title": "Tipos de PII a detectar", + "options": [ + { + "id": "PERSON", + "label": "Nombre de persona", + "group": "Comunes" + }, + { + "id": "EMAIL_ADDRESS", + "label": "Dirección de correo electrónico", + "group": "Comunes" + }, + { + "id": "PHONE_NUMBER", + "label": "Número de teléfono", + "group": "Comunes" + }, + { + "id": "LOCATION", + "label": "Ubicación", + "group": "Comunes" + }, + { + "id": "DATE_TIME", + "label": "Fecha u hora", + "group": "Comunes" + }, + { + "id": "IP_ADDRESS", + "label": "Dirección IP", + "group": "Comunes" + }, + { + "id": "URL", + "label": "URL", + "group": "Comunes" + }, + { + "id": "CREDIT_CARD", + "label": "Número de tarjeta de crédito", + "group": "Comunes" + }, + { + "id": "IBAN_CODE", + "label": "Número internacional de cuenta bancaria (IBAN)", + "group": "Comunes" + }, + { + "id": "CRYPTO", + "label": "Dirección de billetera de criptomonedas", + "group": "Comunes" + }, + { + "id": "MEDICAL_LICENSE", + "label": "Número de licencia médica", + "group": "Comunes" + }, + { + "id": "NRP", + "label": "Nacionalidad / religión / grupo político", + "group": "Comunes" + }, + { + "id": "US_BANK_NUMBER", + "label": "Número de cuenta bancaria de EE. UU.", + "group": "EE. UU." + }, + { + "id": "US_DRIVER_LICENSE", + "label": "Número de licencia de conducir de EE. UU.", + "group": "EE. UU." + }, + { + "id": "US_ITIN", + "label": "Número de identificación personal del contribuyente de EE. UU. (ITIN)", + "group": "EE. UU." + }, + { + "id": "US_PASSPORT", + "label": "Número de pasaporte de EE. UU.", + "group": "EE. UU." + }, + { + "id": "US_SSN", + "label": "Número de Seguro Social de EE. UU.", + "group": "EE. UU." + }, + { + "id": "UK_NINO", + "label": "Número de Seguro Nacional del Reino Unido", + "group": "Reino Unido" + }, + { + "id": "UK_NHS", + "label": "Número del NHS del Reino Unido", + "group": "Reino Unido" + }, + { + "id": "ES_NIF", + "label": "Número NIF español", + "group": "España" + }, + { + "id": "ES_NIE", + "label": "Número NIE español", + "group": "España" + }, + { + "id": "IT_FISCAL_CODE", + "label": "Código fiscal italiano", + "group": "Italia" + }, + { + "id": "IT_DRIVER_LICENSE", + "label": "Licencia de conducir italiana", + "group": "Italia" + }, + { + "id": "IT_IDENTITY_CARD", + "label": "Documento de identidad italiano", + "group": "Italia" + }, + { + "id": "IT_PASSPORT", + "label": "Pasaporte italiano", + "group": "Italia" + }, + { + "id": "PL_PESEL", + "label": "PESEL polaco", + "group": "Polonia" + }, + { + "id": "SG_NRIC_FIN", + "label": "NRIC/FIN de Singapur", + "group": "Singapur" + }, + { + "id": "AU_ABN", + "label": "Número de empresa australiano (ABN)", + "group": "Australia" + }, + { + "id": "AU_ACN", + "label": "Número de compañía australiano (ACN)", + "group": "Australia" + }, + { + "id": "AU_TFN", + "label": "Número de archivo fiscal australiano (TFN)", + "group": "Australia" + }, + { + "id": "AU_MEDICARE", + "label": "Número de Medicare australiano", + "group": "Australia" + }, + { + "id": "IN_AADHAAR", + "label": "Aadhaar de la India", + "group": "India" + }, + { + "id": "IN_PAN", + "label": "PAN de la India", + "group": "India" + }, + { + "id": "IN_VEHICLE_REGISTRATION", + "label": "Matrícula vehicular de la India", + "group": "India" + }, + { + "id": "IN_VOTER", + "label": "Número de votante de la India", + "group": "India" + }, + { + "id": "IN_PASSPORT", + "label": "Pasaporte de la India", + "group": "India" + } + ] + }, + "piiMode": { + "title": "Acción", + "options": [ + { + "id": "block", + "label": "Bloquear solicitud" + }, + { + "id": "mask", + "label": "Enmascarar PII" + } + ] + }, + "piiLanguage": { + "title": "Idioma", + "options": [ + { + "id": "en", + "label": "Inglés" + }, + { + "id": "es", + "label": "Español" + }, + { + "id": "it", + "label": "Italiano" + }, + { + "id": "pl", + "label": "Polaco" + }, + { + "id": "fi", + "label": "Finlandés" + } + ] + } } }, "variablesInput": { @@ -18008,7 +18203,7 @@ "noVariablesDefinedInWorkflow": "No hay variables definidas en este flujo.", "addVariablesInPanel": "Agrégalas en el panel Variables.", "valueLabel": "Valor", - "typedValuePlaceholder": "Valor de tipo {{type}}", + "typedValuePlaceholder": "Valor de tipo {type}", "allVariablesAssignedButton": "Todas las variables asignadas", "addVariableAssignment": "Agregar asignación de variable", "objectValuePlaceholder": "{\n \"key\": \"value\"\n}", @@ -18021,7 +18216,7 @@ "selectedWorkflowNeedsInputTrigger": "El flujo seleccionado necesita un disparador de entrada con campos definidos" }, "evalInput": { - "metricLabel": "Métrica {{index}}", + "metricLabel": "Métrica {index}", "addMetric": "Agregar métrica", "deleteMetric": "Eliminar métrica", "name": "Nombre", @@ -18032,7 +18227,7 @@ "maxValue": "Valor máximo" }, "inputFormat": { - "addTitle": "Agregar {{title}}", + "addTitle": "Agregar {title}", "deleteField": "Eliminar campo", "name": "Nombre", "type": "Tipo", @@ -18112,12 +18307,12 @@ }, "oauthRequiredModal": { "additionalAccessRequired": "Se requiere acceso adicional", - "toolRequiresAccess": "La herramienta \"{{toolName}}\" requiere acceso a tu cuenta de {{providerName}} para funcionar correctamente.", - "connectProvider": "Conectar {{providerName}}", - "connectProviderDescription": "Necesitas conectar tu cuenta de {{providerName}} para continuar", + "toolRequiresAccess": "La herramienta \"{toolName}\" requiere acceso a tu cuenta de {providerName} para funcionar correctamente.", + "connectProvider": "Conectar {providerName}", + "connectProviderDescription": "Necesitas conectar tu cuenta de {providerName} para continuar", "permissionsRequested": "Permisos solicitados", "cancel": "Cancelar", - "connectService": "Conectar {{serviceName}}", + "connectService": "Conectar {serviceName}", "connectNow": "Conectar ahora" }, "dropdown": { @@ -18179,19 +18374,19 @@ "errors": { "failedToFetchDocuments": "No se pudieron obtener los documentos" }, - "chunkCountSingular": "{{count}} fragmento", - "chunkCountPlural": "{{count}} fragmentos" + "chunkCountSingular": "{count} fragmento", + "chunkCountPlural": "{count} fragmentos" }, "knowledgeTagFilters": { "addFilter": "Agregar filtro", - "appliedCountSingular": "{{count}} filtro aplicado", - "appliedCountPlural": "{{count}} filtros aplicados" + "appliedCountSingular": "{count} filtro aplicado", + "appliedCountPlural": "{count} filtros aplicados" }, "documentTagEntry": { "typeText": "Texto", "prefillExistingTags": "Rellenar etiquetas existentes", "addTag": "Agregar etiqueta", - "tagSlotsUsed": "{{used}} de {{total}} espacios de etiquetas usados" + "tagSlotsUsed": "{used} de {total} espacios de etiquetas usados" }, "confluenceFileSelector": { "errors": { @@ -18274,9 +18469,9 @@ "addTool": "Agregar herramienta", "searchTools": "Buscar herramientas...", "account": "Cuenta", - "selectProviderAccount": "Selecciona la cuenta de {{provider}}", - "selectParameter": "Selecciona {{label}}", - "enterParameter": "Ingresa {{label}}", + "selectProviderAccount": "Selecciona la cuenta de {provider}", + "selectParameter": "Selecciona {label}", + "enterParameter": "Ingresa {label}", "enterJsonArrayOrCommaSeparatedValues": "Ingresa un arreglo JSON, por ejemplo, [\"item1\", \"item2\"] o valores separados por comas", "selectToolToConfigureParameters": "Selecciona una herramienta para configurar sus parámetros", "loadingToolSchema": "Cargando esquema de la herramienta...", @@ -18308,8 +18503,8 @@ "unsupportedValue": "Valor no compatible" }, "triggerWarning": { - "duplicateTitle": "Solo se permite un disparador de {{triggerName}}", - "duplicateDescription": "Un flujo solo puede tener un bloque disparador de {{triggerName}}. Elimina el existente antes de agregar uno nuevo.", + "duplicateTitle": "Solo se permite un disparador de {triggerName}", + "duplicateDescription": "Un flujo solo puede tener un bloque disparador de {triggerName}. Elimina el existente antes de agregar uno nuevo.", "dismiss": "Entendido" }, "templateModal": { @@ -18421,8 +18616,8 @@ "title": "Notificaciones por webhook", "searchPlaceholder": "Buscar webhooks...", "emptyState": "Haz clic en \"Agregar webhook\" para comenzar", - "webhookLabel": "Webhook {{index}}", - "noSearchMatches": "No se encontraron webhooks que coincidan con \"{{query}}\"", + "webhookLabel": "Webhook {index}", + "noSearchMatches": "No se encontraron webhooks que coincidan con \"{query}\"", "actions": { "copyUrl": "Copiar URL del webhook", "test": "Probar webhook", @@ -18527,7 +18722,7 @@ "validationFailed": "La validación falló. Revisa la configuración del webhook e inténtalo de nuevo." }, "testStatus": { - "success": "Webhook de prueba enviado correctamente ({{status}})", + "success": "Webhook de prueba enviado correctamente ({status})", "failure": "La prueba del webhook falló.", "sendFailed": "No se pudo enviar el webhook de prueba" }, @@ -20147,7 +20342,7 @@ "selectBlockToViewPreviewDetails": "Selecciona un bloque para ver sus detalles de vista previa.", "nodeNotFound": "No se encontró el nodo", "selectedNodeUnavailable": "El nodo seleccionado ya no está disponible.", - "missingBlockConfiguration": "Falta la configuración del bloque para `{{type}}`.", + "missingBlockConfiguration": "Falta la configuración del bloque para `{type}`.", "controlsUnavailable": "Controles no disponibles", "saveName": "Guardar nombre", "renameNode": "Renombrar nodo", @@ -20169,7 +20364,7 @@ "collectionItems": "Elementos de la colección", "collectionItemsPlaceholder": "['item1', 'item2', 'item3']", "parallelItems": "Elementos en paralelo", - "enterValueBetween": "Introduce un valor entre 1 y {{max}}", + "enterValueBetween": "Introduce un valor entre 1 y {max}", "hideAdditionalFields": "Ocultar campos adicionales", "showAdditionalFields": "Mostrar campos adicionales", "additionalFields": "Campos adicionales", @@ -20177,7 +20372,7 @@ "blockNoEditableFields": "Este bloque no tiene campos editables.", "requiredField": "Este campo es obligatorio", "invalidJson": "JSON no válido", - "unknownInputType": "Tipo de entrada desconocido: {{type}}", + "unknownInputType": "Tipo de entrada desconocido: {type}", "loop": "Bucle", "parallel": "Paralelo", "start": "Inicio", @@ -20194,7 +20389,7 @@ "checkingWorkflowPermissions": "Comprobando permisos del flujo", "writePermissionRequiredToRunWorkflows": "Se requieren permisos de escritura para ejecutar flujos", "usageLimitExceeded": "Límite de uso superado", - "usageLimitExceededDescription": "Has usado {{currentUsage}}$ de {{limit}}$. Actualiza tu plan para continuar.", + "usageLimitExceededDescription": "Has usado {currentUsage}$ de {limit}$. Actualiza tu plan para continuar.", "run": "Ejecutar" }, "floatingControls": { @@ -20212,7 +20407,7 @@ }, "summary": { "objectItem": "[Objeto]", - "additionalCount": "+{{count}} más" + "additionalCount": "+{count} más" }, "selectWorkspaceToLoadWorkflows": "Selecciona un espacio de trabajo para cargar flujos de trabajo.", "noWorkflowsAvailable": "No hay flujos de trabajo disponibles en este espacio de trabajo.", @@ -20232,7 +20427,7 @@ "close": "Cerrar", "coreTriggers": "Triggers principales", "integrationTriggers": "Triggers de integración", - "noResultsFound": "No se encontraron resultados para \"{{query}}\"" + "noResultsFound": "No se encontraron resultados para \"{query}\"" }, "connectionStatus": { "reconnected": "Reconectado", @@ -20241,8 +20436,8 @@ "refreshPageToContinueEditing": "Actualiza la página para seguir editando" }, "triggerWarning": { - "duplicateTitle": "Solo se permite un trigger de {{triggerName}}", - "duplicateDescription": "Un flujo de trabajo solo puede tener un bloque trigger de {{triggerName}}. Elimina el existente antes de agregar uno nuevo.", + "duplicateTitle": "Solo se permite un trigger de {triggerName}", + "duplicateDescription": "Un flujo de trabajo solo puede tener un bloque trigger de {triggerName}. Elimina el existente antes de agregar uno nuevo.", "dismiss": "Entendido" } }, @@ -20299,7 +20494,7 @@ "deployApi": "Desplegar API", "needsRedeployment": "Necesita redepliegue", "deployWorkflowTitle": "Desplegar flujo", - "deployVersion": "Desplegar {{versionName}}", + "deployVersion": "Desplegar {versionName}", "active": "activo", "inactive": "Inactivo", "close": "Cerrar", @@ -20337,7 +20532,7 @@ "reviewTriggerBeforeDeployment": "Revisa este disparador antes del despliegue. No se requiere configuración adicional.", "triggerConfigurationUnavailable": "La configuración del disparador no está disponible.", "noAdditionalTriggerConfigurationRequired": "Este disparador se despliega con el flujo. No se requiere configuración adicional.", - "completeRequiredFieldsBeforeDeploying": "Completa los campos obligatorios antes de desplegar: {{fields}}.", + "completeRequiredFieldsBeforeDeploying": "Completa los campos obligatorios antes de desplegar: {fields}.", "saveTriggerConfigurationBeforeDeploying": "Guarda esta configuración del disparador para aprovisionar su webhook antes de desplegar.", "triggerSettingsChangedSinceLastSave": "La configuración del disparador cambió desde el último guardado. Guarda el disparador antes de desplegar.", "triggerConfigurationReady": "La configuración del disparador parece lista. Revisa los valores abajo antes de desplegar.", @@ -20391,7 +20586,7 @@ "failedToSavePassword": "No se pudo guardar la contraseña del chat", "auth": { "accessControl": "Control de acceso", - "selectAccessAriaLabel": "Seleccionar acceso de tipo {{type}}", + "selectAccessAriaLabel": "Seleccionar acceso de tipo {type}", "publicAccess": "Acceso público", "publicAccessDescription": "Cualquiera puede acceder a tu chat", "passwordProtected": "Protegido con contraseña", @@ -20440,8 +20635,8 @@ "tooltip": "Seleccionar proveedor de datos de mercado", "selectionUnavailable": "La selección de proveedor no está disponible", "noProviders": "No hay proveedores", - "selectedLabel": "Mercado: {{providerName}}", - "fallbackProviderName": "Mercado" + "selectedLabel": "Mercado: {providerName}", + "defaultProviderName": "Mercado" }, "tradingSelector": { "placeholder": "Seleccionar proveedor de trading", @@ -20449,8 +20644,8 @@ "tooltip": "Seleccionar broker", "selectionUnavailable": "La selección de proveedor no está disponible", "noProviders": "No hay proveedores", - "selectedLabel": "Broker: {{providerName}}", - "fallbackProviderName": "Bróker" + "selectedLabel": "Broker: {providerName}", + "defaultProviderName": "Bróker" }, "accountSelector": { "placeholder": "Seleccionar cuenta", @@ -20459,24 +20654,24 @@ "loadingAccount": "Cargando cuenta...", "loadingProviderConnection": "Cargando conexión del proveedor...", "unableToLoadProviderConnection": "No se pudo cargar la conexión del proveedor.", - "selectConnection": "Selecciona una conexión de {{providerName}}.", - "noAccountConnected": "No hay ninguna cuenta de {{providerName}} conectada.", + "selectConnection": "Selecciona una conexión de {providerName}.", + "noAccountConnected": "No hay ninguna cuenta de {providerName} conectada.", "loadingBrokerAccounts": "Cargando cuentas del broker...", "unableToLoadBrokerAccounts": "No se pudieron cargar las cuentas del broker.", "noBrokerAccountsFound": "No se encontraron cuentas del broker.", - "reconnectAccount": "Reconectar cuenta de {{providerName}}", - "connectAccount": "Conectar cuenta de {{providerName}}", - "fallbackProviderName": "Bróker" + "reconnectAccount": "Reconectar cuenta de {providerName}", + "connectAccount": "Conectar cuenta de {providerName}", + "defaultProviderName": "Bróker" }, "settingsButton": { - "triggerLabel": "Configurar proveedor {{providerName}}", + "triggerLabel": "Configurar proveedor {providerName}", "triggerTooltip": "Ajustes del proveedor", "title": "Ajustes del proveedor", "description": "Guarda las credenciales para este widget.", "select": "Seleccionar", "save": "Guardar", "cancel": "Cancelar", - "fallbackProviderName": "Mercado" + "defaultProviderName": "Mercado" } }, "watchlist": { @@ -20502,10 +20697,10 @@ "selectLabel": "Seleccionar watchlist", "searchPlaceholder": "Buscar watchlists...", "noWatchlistsFound": "No se encontraron watchlists.", - "renameAriaLabel": "Renombrar {{name}}", - "deleteAriaLabel": "Eliminar {{name}}", + "renameAriaLabel": "Renombrar {name}", + "deleteAriaLabel": "Eliminar {name}", "deleteDialogTitle": "¿Eliminar watchlist?", - "deleteDialogDescription": "Esta acción eliminará permanentemente \"{{name}}\".", + "deleteDialogDescription": "Esta acción eliminará permanentemente \"{name}\".", "deleteDialogDescriptionFallback": "Esta acción eliminará permanentemente esta watchlist.", "cancel": "Cancelar", "delete": "Eliminar" @@ -20529,7 +20724,7 @@ "collapseSection": "Colapsar sección", "expandSection": "Expandir sección", "deleteSymbolDialogTitle": "¿Eliminar símbolo?", - "deleteSymbolDialogDescription": "Eliminar {{name}} lo quitará de la watchlist.", + "deleteSymbolDialogDescription": "Eliminar {name} lo quitará de la watchlist.", "deleteSymbolDialogDescriptionFallback": "Eliminar este símbolo lo quitará de la watchlist.", "deleteSymbolDialogDescriptionHighlight": "Esta acción no se puede deshacer.", "deleteSectionDialogTitle": "¿Eliminar sección?", @@ -20583,10 +20778,10 @@ }, "validation": { "nameRequired": "El nombre de la skill es obligatorio.", - "nameTooLong": "El nombre de la skill debe tener {{max}} caracteres o menos.", + "nameTooLong": "El nombre de la skill debe tener {max} caracteres o menos.", "descriptionRequired": "La descripción de la skill es obligatoria.", "contentRequired": "El contenido de la skill es obligatorio.", - "duplicateName": "Ya existe una skill llamada \"{{name}}\".", + "duplicateName": "Ya existe una skill llamada \"{name}\".", "saveFailed": "No se pudo guardar la skill." }, "form": { @@ -20746,7 +20941,7 @@ "schemaMustBeFunctionType": "El esquema debe tener un campo \"type\" con valor \"function\"", "schemaMustHaveFunctionName": "El esquema debe tener un objeto \"function\" con un campo \"name\"", "failedToValidateSchema": "No se pudo validar el esquema de la herramienta personalizada. Revisa tus datos e inténtalo de nuevo.", - "duplicateName": "Ya existe una herramienta llamada \"{{name}}\"", + "duplicateName": "Ya existe una herramienta llamada \"{name}\"", "failedToSave": "No se pudo guardar la herramienta personalizada. Revisa tus datos e inténtalo de nuevo." }, "form": { @@ -20822,10 +21017,10 @@ "refreshingQuotes": "Actualizando cotizaciones", "noHoldingsWithMarketListings": "No hay posiciones con listados de mercado", "noMarketProvider": "Sin proveedor de mercado", - "quoteMetricsUseFirst": "Las métricas de cotización usan las primeras {{cap}} de {{total}} posiciones", - "quotedPositionsSummary": "{{quoted}}/{{total}} cotizadas", + "quoteMetricsUseFirst": "Las métricas de cotización usan las primeras {cap} de {total} posiciones", + "quotedPositionsSummary": "{quoted}/{total} cotizadas", "performanceHistoryUnavailable": "El historial de rendimiento no está disponible para la cuenta seleccionada.", - "asOf": "A fecha de {{date}}", + "asOf": "A fecha de {date}", "return": "Rendimiento", "start": "Inicio", "current": "Actual", @@ -20847,7 +21042,7 @@ "orderSubmitted": "Orden enviada", "submitting": "Enviando...", "orderPrefix": "Orden", - "submitOrder": "Enviar orden {{side}}", + "submitOrder": "Enviar orden {side}", "unknown": "desconocido" } }, @@ -20897,15 +21092,15 @@ "selectTimeInForce": "Selecciona una vigencia.", "selectOrderSize": "Selecciona el tamaño de la orden.", "notionalSizingIsNotSupportedForThisOrderType": "El tamaño notional no es compatible con este tipo de orden.", - "notionalSizingRequires": "El tamaño notional requiere {{values}}.", - "fieldNotSupportedForThisOrderType": "{{field}} no es compatible con este tipo de orden.", - "enterValid": "Introduce un valor válido para {{field}}.", - "oneOfFieldsRequired": "Se requiere {{fields}}.", + "notionalSizingRequires": "El tamaño notional requiere {values}.", + "fieldNotSupportedForThisOrderType": "{field} no es compatible con este tipo de orden.", + "enterValid": "Introduce un valor válido para {field}.", + "oneOfFieldsRequired": "Se requiere {fields}.", "marketPrice": "Precio de mercado", "notional": "Notional", "quantity": "Cantidad", "orderType": "Tipo de orden", - "chooseHowTo": "Elige cómo {{side}}", + "chooseHowTo": "Elige cómo {side}", "timeInForce": "Vigencia", "limitPrice": "Precio límite", "stopPrice": "Precio stop", @@ -20923,8 +21118,8 @@ "orderSubmitted": "Orden enviada", "submitting": "Enviando...", "orderPrefix": "Orden", - "sideLabel": "{{side}}", - "submitOrder": "Enviar orden {{side}}" + "sideLabel": "{side}", + "submitOrder": "Enviar orden {side}" } }, "dataChart": { @@ -20964,7 +21159,7 @@ "timezone": { "exchange": "Bolsa", "utc": "UTC", - "tooltip": "Zona horaria: {{timezone}}", + "tooltip": "Zona horaria: {timezone}", "tooltipFallback": "Zona horaria de la bolsa", "searchPlaceholder": "Buscar zonas horarias...", "loading": "Cargando zonas horarias...", @@ -20972,7 +21167,7 @@ }, "range": { "allAvailableData": "Todos los datos disponibles", - "rangeIntervalTooltip": "{{range}} en intervalo de {{interval}}", + "rangeIntervalTooltip": "{range} en intervalo de {interval}", "presets": { "1d": "1D", "5d": "5D", @@ -21012,7 +21207,7 @@ }, "normalization": { "ariaLabel": "Normalización", - "tooltip": "Normalización: {{mode}}", + "tooltip": "Normalización: {mode}", "unavailable": "Normalización no disponible", "noOptions": "No hay opciones de normalización.", "modes": { @@ -21030,7 +21225,7 @@ "freehand": "Herramientas a mano alzada", "shapes": "Herramientas de formas" }, - "unavailable": "{{tool}} no está disponible en esta sesión", + "unavailable": "{tool} no está disponible en esta sesión", "tools": { "TrendLine": "Línea de tendencia", "Ray": "Rayo", @@ -21075,14 +21270,14 @@ "removeIndicator": "Eliminar indicador", "remove": "Eliminar", "errorTitle": "Error del indicador", - "compileFailed": "{{name}} no se pudo compilar.", + "compileFailed": "{name} no se pudo compilar.", "errorGuidance": "Revisa las entradas o el script del indicador e inténtalo de nuevo.", "settingsSubtitle": "Ajustes del indicador", "close": "Cerrar", "noConfigurableInputs": "No hay entradas configurables.", "cancel": "Cancelar", "save": "Guardar", - "plotFallback": "Gráfico {{index}}", + "plotFallback": "Gráfico {index}", "executionErrorFallback": "No se pudieron ejecutar los indicadores", "metadataLabels": { "Length": "Longitud", @@ -21105,7 +21300,7 @@ "close": "C:" }, "listingOverlay": { - "flagAlt": "Bandera de {{countryCode}}" + "flagAlt": "Bandera de {countryCode}" }, "errors": { "failedToLoadSeriesData": "No se pudieron cargar los datos de la serie", @@ -21140,7 +21335,7 @@ "label": "Listado", "listingFallback": "Listado", "selectListing": "Seleccionar listado", - "flagAlt": "Bandera de {{countryCode}}", + "flagAlt": "Bandera de {countryCode}", "searching": "Buscando...", "noListingsFound": "No se encontraron listados.", "searchPlaceholder": "Buscar listados..." @@ -21148,8 +21343,8 @@ }, "layoutTabs": { "createNewLayout": "Crear nuevo diseño", - "renameAriaLabel": "Renombrar {{name}}", - "deleteAriaLabel": "Eliminar {{name}}" + "renameAriaLabel": "Renombrar {name}", + "deleteAriaLabel": "Eliminar {name}" }, "monitor": { "title": "Monitoreo", @@ -21180,7 +21375,7 @@ "errors": { "activateView": "No se pudo activar la vista", "configViewsUnavailable": "Las vistas de configuración no están disponibles en este momento.", - "createDefaultView": "No se pudo crear la vista predeterminada {{name}}.", + "createDefaultView": "No se pudo crear la vista predeterminada {name}.", "createMonitor": "No se pudo crear el monitor", "createView": "No se pudo crear la vista", "deleteMonitor": "No se pudo eliminar el monitor", @@ -21282,13 +21477,13 @@ "today": "Hoy", "boundaries": "Límites", "todayAndBoundaries": "Hoy + límites", - "shownCount": "{{count}} mostrados", - "itemsCount": "{{count}} elementos", - "executionsCount": "{{count}} ejecuciones", - "setCount": "{{count}} configurados", + "shownCount": "{count} mostrados", + "itemsCount": "{count} elementos", + "executionsCount": "{count} ejecuciones", + "setCount": "{count} configurados", "all": "Todos", "allExecutions": "Todas las ejecuciones", - "monitorsCount": "{{count}} monitores", + "monitorsCount": "{count} monitores", "monitorControls": "Controles del monitor" }, "execution": { @@ -21302,7 +21497,7 @@ "closeInspector": "Cerrar inspector", "kanban": "Kanban", "timeline": "Cronología", - "limitLabel": "Límite {{count}}", + "limitLabel": "Límite {count}", "running": "En ejecución", "unknown": "Desconocido", "unknownListing": "Activo desconocido", @@ -21322,7 +21517,7 @@ "loadingRecords": "Cargando registros de monitor...", "noExecutions": "Sin ejecuciones", "noOutcome": "Sin resultado", - "addMonitorIn": "Agregar monitor en {{title}}" + "addMonitorIn": "Agregar monitor en {title}" }, "configSearch": { "activeMonitors": "Monitores activos", @@ -21331,7 +21526,7 @@ "hasLastExecutionLog": "Tiene registro de última ejecución", "hasLastOutcome": "Tiene último resultado", "invalidTokensPrefix": "Tokens inválidos de la consulta de configuración", - "lastOutcome": "Último resultado: {{outcome}}", + "lastOutcome": "Último resultado: {outcome}", "noLastExecution": "Sin última ejecución", "noLastExecutionLog": "Sin registro de última ejecución", "noLastOutcome": "Sin último resultado", @@ -21346,7 +21541,7 @@ "loading": "Cargando zonas horarias...", "placeholder": "UTC", "searchPlaceholder": "Buscar zonas horarias...", - "triggerLabel": "Zona horaria: {{label}}" + "triggerLabel": "Zona horaria: {label}" }, "editor": { "createTitle": "Crear monitor", @@ -21394,7 +21589,7 @@ "scaleLabel": "Escala", "scaleAriaLabel": "Escala de la cronología", "searchZoomLevels": "Buscar niveles de zoom...", - "zoomLabel": "Zoom: {{zoom}}", + "zoomLabel": "Zoom: {zoom}", "scrollPrevious": "Desplazarse al rango de fechas anterior", "scrollNext": "Desplazarse al siguiente rango de fechas", "groupsTitle": "Grupos", @@ -21413,9 +21608,9 @@ "navigationAriaLabel": "Encabezado del chat", "titleFallback": "Chat de TradingGoose", "brandName": "TradingGoose", - "logoAlt": "Logotipo de {{title}}", - "githubRepositoryAriaLabel": "Ver el repositorio de TradingGoose en GitHub con {{stars}} estrellas", - "homeAriaLabel": "Ir a la página principal de {{brand}}" + "logoAlt": "Logotipo de {title}", + "githubRepositoryAriaLabel": "Ver el repositorio de TradingGoose en GitHub con {stars} estrellas", + "homeAriaLabel": "Ir a la página principal de {brand}" }, "error": { "title": "Chat no disponible", @@ -21453,8 +21648,8 @@ "startVoiceConversation": "Iniciar conversación por voz", "send": "Enviar", "stop": "Detener", - "fileTooLarge": "{{name}} es demasiado grande.", - "fileAlreadyAdded": "{{name}} ya está adjunto." + "fileTooLarge": "{name} es demasiado grande.", + "fileAlreadyAdded": "{name} ya está adjunto." }, "auth": { "password": { @@ -21480,7 +21675,7 @@ "title": "Ingresa tu correo", "verifyTitle": "Verifica tu correo", "description": "Este chat usa verificación por correo.", - "verifiedDescription": "Enviamos un código de verificación a {{email}}.", + "verifiedDescription": "Enviamos un código de verificación a {email}.", "label": "Correo electrónico", "placeholder": "Ingresa tu correo", "submit": "Enviar código", @@ -21489,7 +21684,7 @@ "verifying": "Verificando...", "instructions": "Introduce el código de 6 dígitos que te enviamos.", "resendPrompt": "¿No recibiste un código?", - "resendIn": "Reenviar en {{countdown}}s", + "resendIn": "Reenviar en {countdown}s", "resend": "Reenviar código", "changeEmail": "Cambiar correo", "validation": { @@ -21621,7 +21816,7 @@ "searchPlaceholder": "Buscar entradas de la lista de espera...", "mode": "Modo", "loading": "Cargando la configuración de registro...", - "selectedCount": "{{count}} seleccionados", + "selectedCount": "{count} seleccionados", "submitted": "Enviado", "timeRanges": { "all": "Todo el tiempo", @@ -21651,7 +21846,7 @@ }, "emptyState": "Ninguna entrada de la lista de espera coincide con la búsqueda actual.", "selectVisible": "Seleccionar entradas visibles de la lista de espera", - "selectEntry": "Seleccionar {{email}}", + "selectEntry": "Seleccionar {email}", "never": "Nunca", "error": "Algo salió mal", "modes": { @@ -21683,8 +21878,8 @@ "title": "Configuración", "description": "Configura el comportamiento de ejecución y los valores predeterminados del endpoint para este servicio.", "none": "Este servicio no expone configuraciones almacenadas.", - "storedValue": "Valor guardado: {{value}}", - "defaultValue": "Predeterminado: {{value}}", + "storedValue": "Valor guardado: {value}", + "defaultValue": "Predeterminado: {value}", "notConfigured": "Sin configurar", "notSet": "Sin establecer", "enabled": "Habilitado", @@ -21696,21 +21891,21 @@ "optional": "Opcional" }, "placeholders": { - "enterValue": "Ingresa {{label}}", - "replaceValue": "Ingresa un nuevo {{label}} para reemplazar el valor guardado" + "enterValue": "Ingresa {label}", + "replaceValue": "Ingresa un nuevo {label} para reemplazar el valor guardado" }, "actions": { - "saveField": "Guardar {{label}}", - "cancelEditingField": "Cancelar edición de {{label}}", - "editField": "Editar {{label}}", - "clearField": "Borrar {{label}}" + "saveField": "Guardar {label}", + "cancelEditingField": "Cancelar edición de {label}", + "editField": "Editar {label}", + "clearField": "Borrar {label}" }, "summary": { - "requiredCredentialsSet": "{{configured}}/{{total}} credenciales obligatorias configuradas", + "requiredCredentialsSet": "{configured}/{total} credenciales obligatorias configuradas", "noRequiredCredentials": "No hay credenciales obligatorias", - "requiredSettingsResolved": "{{configured}}/{{total}} configuraciones obligatorias resueltas", + "requiredSettingsResolved": "{configured}/{total} configuraciones obligatorias resueltas", "noRequiredSettings": "No hay configuraciones obligatorias", - "missing": "Falta {{labels}}." + "missing": "Falta {labels}." }, "footer": { "saving": "Guardando cambios...", @@ -21740,29 +21935,29 @@ "description": "Define los secretos para este proveedor OAuth gestionado por el sistema.", "none": "Este proveedor no requiere credenciales almacenadas.", "noMatches": "Ninguna credencial coincide con la búsqueda actual.", - "fallbackDescription": "Credencial del proveedor" + "defaultDescription": "Credencial del proveedor" }, "services": { "title": "Servicios OAuth", "description": "Activa o desactiva los servicios que heredan las credenciales de este proveedor.", "none": "Este proveedor no expone servicios OAuth.", "noMatches": "Ningún servicio coincide con la búsqueda actual.", - "inheritsFrom": "Hereda credenciales de {{name}}." + "inheritsFrom": "Hereda credenciales de {name}." }, "placeholders": { - "enterValue": "Ingresa {{label}}", - "replaceValue": "Ingresa un nuevo {{label}} para reemplazar el valor guardado" + "enterValue": "Ingresa {label}", + "replaceValue": "Ingresa un nuevo {label} para reemplazar el valor guardado" }, "summary": { - "serviceCount": "{{count}} servicio{{plural}}", + "serviceCount": "{count} servicio{plural}", "servicePlural": "es", - "requiredCredentialsSet": "{{configured}}/{{total}} credenciales obligatorias configuradas", - "credentialsSet": "{{count}} credencial{{plural}} configurada{{plural}}", + "requiredCredentialsSet": "{configured}/{total} credenciales obligatorias configuradas", + "credentialsSet": "{count} credencial{plural} configurada{plural}", "credentialPlural": "es", "noRequiredCredentials": "No hay credenciales obligatorias", - "enabledCount": "{{count}} habilitados", + "enabledCount": "{count} habilitados", "noServicesAvailable": "No hay servicios disponibles", - "missing": "Falta {{labels}}." + "missing": "Falta {labels}." }, "error": "Algo salió mal" }, @@ -21788,21 +21983,21 @@ "organization": "Organización", "userOwner": "Propietario usuario", "organizationOwner": "Propietario de organización", - "ownerLabel": "Propietario {{owner}}" + "ownerLabel": "Propietario {owner}" }, "usageScopes": { "individual": "Individual", "pooled": "Compartido", "individualUsage": "Uso individual", "pooledUsage": "Uso compartido", - "usageLabel": "Uso {{scope}}" + "usageLabel": "Uso {scope}" }, "seatModes": { "fixed": "Fijo", "adjustable": "Ajustable", "fixedSeats": "Asientos fijos", "adjustableSeats": "Asientos ajustables", - "seatBillingLabel": "Facturación de asientos {{mode}}" + "seatBillingLabel": "Facturación de asientos {mode}" }, "commerce": { "custom": "Personalizado", @@ -21810,23 +22005,23 @@ "selfServe": "Autoservicio", "contactSales": "Contactar ventas", "priceUnset": "Precio sin definir", - "monthlyPrice": "{{amount}} mensual", - "yearlyPrice": "{{amount}} anual", - "stripeLinks": "{{count}}/3 enlaces de Stripe", - "usdIncluded": "{{value}} USD incluidos", - "gbStorage": "{{value}} GB de almacenamiento", - "concurrent": "{{value}} concurrentes", - "includedUsageLabel": "{{amount}} incluidos", - "storageLimitLabel": "{{value}} GB de almacenamiento", - "concurrencyLabel": "{{value}} concurrentes", - "workflowExecutionLabel": "{{value}}x ejecución de flujo", - "workflowModelsLabel": "{{value}}x modelos de flujo", - "functionRuntimeLabel": "{{value}}x tiempo de ejecución de funciones", - "copilotLabel": "{{value}}x copiloto", + "monthlyPrice": "{amount} mensual", + "yearlyPrice": "{amount} anual", + "stripeLinks": "{count}/3 enlaces de Stripe", + "usdIncluded": "{value} USD incluidos", + "gbStorage": "{value} GB de almacenamiento", + "concurrent": "{value} concurrentes", + "includedUsageLabel": "{amount} incluidos", + "storageLimitLabel": "{value} GB de almacenamiento", + "concurrencyLabel": "{value} concurrentes", + "workflowExecutionLabel": "{value}x ejecución de flujo", + "workflowModelsLabel": "{value}x modelos de flujo", + "functionRuntimeLabel": "{value}x tiempo de ejecución de funciones", + "copilotLabel": "{value}x copiloto", "seatCountUnset": "Cantidad de asientos sin definir", - "fixedSeatsCount": "{{count}} asientos fijos", - "baseSeatsCount": "{{count}} asientos base", - "maxSeatsCount": "{{count}} asientos máximos", + "fixedSeatsCount": "{count} asientos fijos", + "baseSeatsCount": "{count} asientos base", + "maxSeatsCount": "{count} asientos máximos", "noSelfServeSeatChanges": "Sin cambios de asientos en autoservicio", "unlimitedSeats": "Asientos ilimitados" }, @@ -21834,7 +22029,7 @@ "searchPlaceholder": "Buscar niveles...", "createTier": "Crear nivel", "loadingInventory": "Cargando inventario de facturación...", - "subscriptionCount": "{{count}} suscripciones", + "subscriptionCount": "{count} suscripciones", "currentTiersTitle": "Niveles actuales", "currentTiersDescription": "Abre un nivel para actualizar precios, disponibilidad, límites para clientes y uso incluido.", "emptyTitle": "Crea tu primer nivel de facturación", @@ -21848,8 +22043,8 @@ "default": "Predeterminado", "notSet": "Sin definir", "rates": "Tarifas", - "workflowRunRate": "Flujo/ejecución ${{amount}}", - "functionSecondRate": "Función/segundo ${{amount}}" + "workflowRunRate": "Flujo/ejecución ${amount}", + "functionSecondRate": "Función/segundo ${amount}" } }, "create": { @@ -22029,10 +22224,10 @@ "copilotCostMultiplierBlank": "Déjalo en blanco para el 1x predeterminado." }, "summaries": { - "missing": "Falta: {{items}}", + "missing": "Falta: {items}", "untitledTier": "Nivel sin título", "noPricingBullets": "Sin puntos de precios", - "pricingBullets": "{{count}} puntos de precios", + "pricingBullets": "{count} puntos de precios", "defaultTierMustBePublic": "el nivel predeterminado debe ser público", "defaultTierMustBePublicPlan": "el nivel predeterminado debe ser un plan público de usuario con uso individual y asientos fijos", "userTiersIndividualUsage": "los niveles de usuario deben usar uso individual", @@ -22060,7 +22255,7 @@ "userTiersNoOrgSeats": "Los niveles de usuario no gestionan asientos de organización", "seatMaxAboveCount": "el máximo de asientos debe permanecer por encima de la cantidad de asientos", "noLimitsConfigured": "No hay límites configurados de uso incluido, almacenamiento, concurrencia, tasa o retención", - "limitsConfigured": "{{count}}/7 límites configurados", + "limitsConfigured": "{count}/7 límites configurados", "usingBasePricingOnly": "Usando solo el precio base de la plataforma" } }, @@ -22073,84 +22268,84 @@ "emails": { "shared": { "tagline": "Sistema de flujos de análisis técnico con LLM para trading", - "team": "El equipo de {{brandName}}", - "openBrand": "Abrir {{brandName}}", + "team": "El equipo de {brandName}", + "openBrand": "Abrir {brandName}", "expires15": "Este código vence en 15 minutos.", "expires24": "Este enlace es válido durante las próximas 24 horas.", "expires48": "Las invitaciones vencen en 48 horas por seguridad.", "expires7": "Esta invitación vence en 7 días.", "ignore": "Si no solicitaste esto, puedes ignorar este correo.", - "sentOn": "Enviado el {{date}}.", - "sentOnTo": "Enviado el {{date}} a {{email}}.", - "submittedOnTo": "Enviado el {{date}} a {{email}}.", - "approvedOnFor": "Aprobado el {{date}} para {{email}}.", - "sentOnFor": "Enviado el {{date}} para tu {{typeLabel}}{{emailPart}}.", - "emailPartFrom": " desde {{email}}" + "sentOn": "Enviado el {date}.", + "sentOnTo": "Enviado el {date} a {email}.", + "submittedOnTo": "Enviado el {date} a {email}.", + "approvedOnFor": "Aprobado el {date} para {email}.", + "sentOnFor": "Enviado el {date} para tu {typeLabel}{emailPart}.", + "emailPartFrom": " desde {email}" }, "footer": { - "copyright": "(c) {{year}} {{brandName}}, Todos los derechos reservados", + "copyright": "(c) {year} {brandName}, Todos los derechos reservados", "questions": "¿Preguntas? Escribe a", "privacy": "Política de privacidad", "terms": "Términos de servicio", "unsubscribe": "Cancelar suscripción" }, "subjects": { - "sign-in": "Inicia sesión en {{brandName}}", - "email-verification": "Verifica tu correo para {{brandName}}", - "forget-password": "Restablece tu contraseña de {{brandName}}", - "reset-password": "Restablece tu contraseña de {{brandName}}", - "change-email": "Verifica tu nuevo correo para {{brandName}}", - "chat-access": "Código de verificación para {{chatTitle}}", - "invitation": "Te invitaron a unirte a {{organizationName}} en {{brandName}}", - "batch-invitation": "Te invitaron a unirte a {{organizationName}} y a espacios de trabajo en {{brandName}}", - "workspace-invitation": "Te invitaron a unirte a {{workspaceName}} en {{brandName}}", + "sign-in": "Inicia sesión en {brandName}", + "email-verification": "Verifica tu correo para {brandName}", + "forget-password": "Restablece tu contraseña de {brandName}", + "reset-password": "Restablece tu contraseña de {brandName}", + "change-email": "Verifica tu nuevo correo para {brandName}", + "chat-access": "Código de verificación para {chatTitle}", + "invitation": "Te invitaron a unirte a {organizationName} en {brandName}", + "batch-invitation": "Te invitaron a unirte a {organizationName} y a espacios de trabajo en {brandName}", + "workspace-invitation": "Te invitaron a unirte a {workspaceName} en {brandName}", "help-confirmation": "Recibimos tu solicitud", - "enterprise-subscription": "La facturación de tu organización ya está activa en {{brandName}}", - "plan-welcome": "Tu nivel {{planName}} ya está activo en {{brandName}}", - "usage-threshold": "Te acercas a tu presupuesto mensual en {{brandName}}", - "free-tier-upgrade": "Tu nivel actual se acerca a su uso incluido en {{brandName}}", - "payment-failed": "Falló el pago de {{brandName}} - acción requerida", - "waitlist-confirmation": "Recibimos tu solicitud de acceso a {{brandName}}", - "waitlist-approved": "Tu solicitud de acceso a {{brandName}} fue aprobada", - "careers-confirmation": "Tu postulación a {{brandName}} - {{position}}" + "enterprise-subscription": "La facturación de tu organización ya está activa en {brandName}", + "plan-welcome": "Tu nivel {planName} ya está activo en {brandName}", + "usage-threshold": "Te acercas a tu presupuesto mensual en {brandName}", + "free-tier-upgrade": "Tu nivel actual se acerca a su uso incluido en {brandName}", + "payment-failed": "Falló el pago de {brandName} - acción requerida", + "waitlist-confirmation": "Recibimos tu solicitud de acceso a {brandName}", + "waitlist-approved": "Tu solicitud de acceso a {brandName} fue aprobada", + "careers-confirmation": "Tu postulación a {brandName} - {position}" }, "otp": { "titles": { - "sign-in": "Inicia sesión en {{brandName}}", - "email-verification": "Verifica tu correo para {{brandName}}", - "forget-password": "Restablece tu contraseña de {{brandName}}", - "change-email": "Verifica tu nuevo correo para {{brandName}}", - "chat-access": "Accede a {{chatTitle}}" + "sign-in": "Inicia sesión en {brandName}", + "email-verification": "Verifica tu correo para {brandName}", + "forget-password": "Restablece tu contraseña de {brandName}", + "change-email": "Verifica tu nuevo correo para {brandName}", + "chat-access": "Accede a {chatTitle}" }, "body": "Usa el código siguiente para continuar." }, "resetPassword": { "title": "Restablece tu contraseña", - "intro": "Recibimos una solicitud para restablecer la contraseña de tu cuenta de {{brandName}}.", + "intro": "Recibimos una solicitud para restablecer la contraseña de tu cuenta de {brandName}.", "action": "Usa el botón siguiente para elegir una nueva contraseña.", "cta": "Restablecer contraseña", "accountFallback": "el correo de tu cuenta", - "sentLine": "Enviado el {{date}} a {{account}}." + "sentLine": "Enviado el {date} a {account}." }, "invitation": { - "preview": "{{inviterName}} te invitó a unirte a {{organizationName}} en {{brandName}}", - "title": "Te invitaron a unirte a {{organizationName}}.", - "intro": "{{inviterName}} te invitó a colaborar en {{brandName}}.", + "preview": "{inviterName} te invitó a unirte a {organizationName} en {brandName}", + "title": "Te invitaron a unirte a {organizationName}.", + "intro": "{inviterName} te invitó a colaborar en {brandName}.", "body": "Acepta la invitación para acceder a proyectos y flujos compartidos.", "cta": "Unirse ahora" }, "batchInvitation": { - "preview": "Únete a {{organizationName}} en {{brandName}}", - "title": "Te invitaron a unirte a {{organizationName}}.", - "intro": "{{inviterName}} te agregó como {{roleLabel}} en {{brandName}}.", + "preview": "Únete a {organizationName} en {brandName}", + "title": "Te invitaron a unirte a {organizationName}.", + "intro": "{inviterName} te agregó como {roleLabel} en {brandName}.", "adminDescription": "Como administrador, podrás gestionar facturación, compañeros y acceso a espacios de trabajo de la organización.", "memberDescription": "Como miembro, puedes colaborar en facturación compartida y aceptar invitaciones a espacios de trabajo.", - "workspaceAccess": "Acceso a espacios de trabajo ({{count}} {{workspaceWord}}):", - "workspaceLine": "- {{workspaceName}} - {{permissionLabel}}", + "workspaceAccess": "Acceso a espacios de trabajo ({count} {workspaceWord}):", + "workspaceLine": "- {workspaceName} - {permissionLabel}", "workspaceSingular": "espacio", "workspacePlural": "espacios", - "closing": "Al aceptar, te unirás a {{organizationName}}.", - "closingWithWorkspaces": "Al aceptar, te unirás a {{organizationName}} y tendrás acceso a {{count}} {{workspaceWord}}.", + "closing": "Al aceptar, te unirás a {organizationName}.", + "closingWithWorkspaces": "Al aceptar, te unirás a {organizationName} y tendrás acceso a {count} {workspaceWord}.", "cta": "Aceptar invitación", "roleLabels": { "admin": "Administrador", @@ -22163,17 +22358,17 @@ } }, "workspaceInvitation": { - "preview": "Únete al espacio {{workspaceName}} en {{brandName}}", - "title": "Te invitaron a {{workspaceName}}", - "intro": "{{inviterName}} te pidió colaborar en el espacio {{workspaceName}} en {{brandName}}. Acepta para acceder a proyectos y datos compartidos.", + "preview": "Únete al espacio {workspaceName} en {brandName}", + "title": "Te invitaron a {workspaceName}", + "intro": "{inviterName} te pidió colaborar en el espacio {workspaceName} en {brandName}. Acepta para acceder a proyectos y datos compartidos.", "cta": "Aceptar invitación" }, "help": { "title": "Gracias por escribirnos", - "preview": "{{brandName}}: recibimos tu {{typeLabel}}", - "intro": "Recibimos tu {{typeLabel}} y responderemos pronto.", - "attachments": "Adjuntaste {{count}} {{fileWord}}. Revisaremos todo lo que compartiste.", - "responseTime": "Normalmente respondemos en pocas horas. Si necesitas ayuda inmediata, escríbenos a {{supportEmail}}.", + "preview": "{brandName}: recibimos tu {typeLabel}", + "intro": "Recibimos tu {typeLabel} y responderemos pronto.", + "attachments": "Adjuntaste {count} {fileWord}. Revisaremos todo lo que compartiste.", + "responseTime": "Normalmente respondemos en pocas horas. Si necesitas ayuda inmediata, escríbenos a {supportEmail}.", "fileSingular": "archivo", "filePlural": "archivos", "typeLabels": { @@ -22187,13 +22382,13 @@ "confirmation": { "preview": "Recibimos tu solicitud de acceso", "title": "Estás en la lista de espera", - "intro": "Recibimos tu solicitud de acceso a {{brandName}} y agregamos {{email}} a la lista de espera.", + "intro": "Recibimos tu solicitud de acceso a {brandName} y agregamos {email} a la lista de espera.", "body": "Revisaremos tu solicitud y te enviaremos otro correo si esta dirección se aprueba para registrarse. Usa este mismo correo en todos los métodos de inicio de sesión." }, "approved": { "preview": "Tu solicitud de lista de espera fue aprobada", "title": "Tu acceso está listo", - "intro": "{{email}} ya puede crear una cuenta en {{brandName}}.", + "intro": "{email} ya puede crear una cuenta en {brandName}.", "body": "Termina el registro con esta misma dirección de correo para activar tu acceso.", "cta": "Terminar registro", "methodReminder": "Usa el mismo correo aprobado para email/contraseña, Google, GitHub o cualquier otro método de inicio de sesión." @@ -22202,8 +22397,8 @@ "billing": { "enterprise": { "title": "Facturación de organización activada", - "welcome": "Bienvenido, {{userName}}.", - "body": "El nivel de facturación de tu organización ya está activo en {{brandName}}. Ahora tienes más capacidad, controles avanzados y acceso para toda la organización.", + "welcome": "Bienvenido, {userName}.", + "body": "El nivel de facturación de tu organización ya está activo en {brandName}. Ahora tienes más capacidad, controles avanzados y acceso para toda la organización.", "cta": "Acceder a tu cuenta", "nextStepsTitle": "Siguientes pasos", "nextSteps": [ @@ -22214,62 +22409,62 @@ "help": "¿Necesitas ayuda para empezar? Responde a este correo y nuestro equipo te asistirá." }, "planWelcome": { - "preview": "{{brandName}}: tu nivel {{planName}} está activo", - "title": "Nivel {{planName}} activado", - "namedWelcome": "¡Bienvenido, {{userName}}!", + "preview": "{brandName}: tu nivel {planName} está activo", + "title": "Nivel {planName} activado", + "namedWelcome": "¡Bienvenido, {userName}!", "welcome": "¡Bienvenido!", - "body": "Ya estás listo en el nivel {{planName}} de {{brandName}}. Explora tus nuevos límites y avanza más rápido con tu equipo.", + "body": "Ya estás listo en el nivel {planName} de {brandName}. Explora tus nuevos límites y avanza más rápido con tu equipo.", "help": "¿Quieres hablar sobre tu nivel o recibir ayuda personalizada? Agenda una llamada de 15 minutos con nuestro equipo.", "settings": "¿Necesitas invitar compañeros, ajustar límites de uso o gestionar facturación? Visita Configuración -> Suscripción cuando quieras." }, "usage": { - "preview": "{{brandName}}: estás al {{percentUsed}}% de tu presupuesto mensual de {{planName}}", - "title": "Estás al {{percentUsed}}% de tu presupuesto", - "intro": "{{userNamePrefix}}el uso de tu nivel {{planName}} se acerca al límite mensual.", + "preview": "{brandName}: estás al {percentUsed}% de tu presupuesto mensual de {planName}", + "title": "Estás al {percentUsed}% de tu presupuesto", + "intro": "{userNamePrefix}el uso de tu nivel {planName} se acerca al límite mensual.", "recommendation": "Para evitar interrupciones, considera aumentar tu límite mensual.", - "usageLine": "{{currentUsage}} / {{limit}} usados", - "percentLine": "{{percentUsed}}% del presupuesto de este mes", + "usageLine": "{currentUsage} / {limit} usados", + "percentLine": "{percentUsed}% del presupuesto de este mes", "cta": "Revisar límites", "reason": "Enviamos este correo una vez cuando tu uso alcanza el umbral de advertencia configurado, para que tengas tiempo de ajustar tu nivel o límite." }, "freeTier": { "currentTierFallback": "tu nivel actual", - "preview": "{{brandName}}: {{currentTierName}} se acerca a su uso incluido", + "preview": "{brandName}: {currentTierName} se acerca a su uso incluido", "title": "Tu uso incluido está casi agotado", - "greeting": "Hola {{userName}},", + "greeting": "Hola {userName},", "greetingFallback": "there", - "usage": "Has usado {{currentUsage}} de los {{limit}} incluidos en {{currentTierName}} ({{percentUsed}}%).", + "usage": "Has usado {currentUsage} de los {limit} incluidos en {currentTierName} ({percentUsed}%).", "body": "Revisa los niveles de facturación disponibles para ampliar tu uso antes de que el límite de este mes interrumpa nuevo trabajo.", "recommendedTitle": "Siguiente nivel recomendado", - "recommendedTier": "Siguiente nivel recomendado: {{tierName}}", - "recommendedPrice": "Desde {{price}}/mes", - "recommendedUsage": "{{usage}} de uso incluido cada mes", + "recommendedTier": "Siguiente nivel recomendado: {tierName}", + "recommendedPrice": "Desde {price}/mes", + "recommendedUsage": "{usage} de uso incluido cada mes", "cta": "Revisar niveles de facturación", "oneTime": "Esta es una notificación única después de que tu nivel predeterminado cruza el umbral de actualización." }, "paymentFailed": { "title": "No pudimos procesar tu pago.", - "greeting": "Hola {{userName}},", + "greeting": "Hola {userName},", "greetingFallback": "there", - "body": "Tu cuenta de {{brandName}} fue bloqueada temporalmente para evitar interrupciones del servicio y cargos inesperados. Para restaurar el acceso de inmediato, actualiza tu método de pago.", + "body": "Tu cuenta de {brandName} fue bloqueada temporalmente para evitar interrupciones del servicio y cargos inesperados. Para restaurar el acceso de inmediato, actualiza tu método de pago.", "detailsTitle": "Detalles del pago", - "amountDue": "Importe pendiente: {{amount}}", - "paymentMethod": "Método de pago: **** {{lastFourDigits}}", - "reason": "Motivo: {{reason}}", + "amountDue": "Importe pendiente: {amount}", + "paymentMethod": "Método de pago: **** {lastFourDigits}", + "reason": "Motivo: {reason}", "cta": "Actualizar método de pago", "nextSteps": "Tus flujos y automatizaciones están pausados. Actualiza tu método de pago para restaurar el servicio de inmediato. Stripe reintentará el cargo automáticamente cuando el pago se actualice.", "help": "Las causas comunes incluyen tarjetas vencidas, fondos insuficientes o datos de facturación incorrectos. Si el problema continúa, contacta a soporte.", - "sentLine": "Enviado el {{date}}. Esta es una notificación transaccional crítica." + "sentLine": "Enviado el {date}. Esta es una notificación transaccional crítica." } }, "careers": { - "preview": "Recibimos tu postulación a {{brandName}}", + "preview": "Recibimos tu postulación a {brandName}", "title": "Recibimos tu postulación", - "greeting": "Hola {{name}},", - "body": "Gracias por tu interés en unirte al equipo de {{brandName}}. Recibimos tu postulación para el puesto {{position}}.", + "greeting": "Hola {name},", + "body": "Gracias por tu interés en unirte al equipo de {brandName}. Recibimos tu postulación para el puesto {position}.", "review": "Nuestro equipo revisa cuidadosamente cada postulación y te responderá en las próximas semanas. Si tus calificaciones coinciden con lo que buscamos, nos pondremos en contacto para agendar una primera conversación.", - "explore": "Mientras tanto, explora nuestra documentación en {{docsUrl}} o lee lo último en nuestro blog en {{blogUrl}}.", - "sentLine": "Esta confirmación se envió el {{dateTime}}." + "explore": "Mientras tanto, explora nuestra documentación en {docsUrl} o lee lo último en nuestro blog en {blogUrl}.", + "sentLine": "Esta confirmación se envió el {dateTime}." } } } diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index ac5763a40..35ecd007c 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -47,8 +47,8 @@ "homeLabel": "首页", "languageLabel": "语言", "primaryNavigation": "主导航", - "githubRepositoryAriaLabel": "GitHub 仓库 - {{stars}} 星", - "homeAriaLabel": "{{brand}} 首页" + "githubRepositoryAriaLabel": "GitHub 仓库 - {stars} 星", + "homeAriaLabel": "{brand} 首页" }, "registration": { "open": { @@ -178,7 +178,7 @@ "github": "GitHub", "google": "Google", "connecting": "正在连接...", - "cancelled": "{{provider}} 登录已取消。请重试。" + "cancelled": "{provider} 登录已取消。请重试。" }, "verify": { "eyebrow": "验证", @@ -186,7 +186,7 @@ "verifiedTitle": "邮箱已验证!", "verifiedDescription": "您的邮箱已验证。正在重定向到仪表盘...", "disabledDescription": "邮箱验证已禁用。正在重定向到仪表盘...", - "codeSent": "验证码已发送至 {{email}}", + "codeSent": "验证码已发送至 {email}", "developmentDescription": "开发模式:请查看控制台日志以获取验证码", "missingServiceDescription": "错误:邮箱验证已启用但未配置邮件服务", "instructionsWithService": "输入6位验证码以验证您的账户。如果在收件箱中未找到,请检查垃圾邮件文件夹。", @@ -194,7 +194,7 @@ "verifyButton": "验证邮箱", "verifyingButton": "正在验证...", "resendPrompt": "未收到验证码?", - "resendIn": "{{countdown}}秒后重新发送", + "resendIn": "{countdown}秒后重新发送", "resendButton": "重新发送", "yourEmail": "您的邮箱", "errors": { @@ -370,25 +370,12 @@ "waitlist": "嘟!隆重推出 TradingGoose-Studio", "open": "嘟!TradingGoose-Studio 已上线" }, - "leadWords": [ - "构建", - "测试", - "运行" - ], - "highlightWords": [ - "交易分析", - "信号检测", - "风险评估" - ], + "leadWords": ["构建", "测试", "运行"], + "highlightWords": ["交易分析", "信号检测", "风险评估"], "titleConnector": "你的", "suffix": "与 TradingGoose", "description": "连接你自己的数据提供商,编写自定义指标以监控市场价格,并将其接入工作流,从而触发交易、卖出、买入或你定义的任何操作。", - "featureBadges": [ - "AI Agent 工作流", - "自定义指标", - "自带数据", - "集成" - ], + "featureBadges": ["AI Agent 工作流", "自定义指标", "自带数据", "集成"], "learnMore": "了解更多", "logoAlt": "TradingGoose 标志" }, @@ -412,7 +399,7 @@ }, "footer": { "description": "面向技术型LLM交易的AI工作流平台", - "copyright": "© {{year}} {{brand}}。专为可视化交易工作流打造。", + "copyright": "© {year} {brand}。专为可视化交易工作流打造。", "links": { "docs": "文档", "blog": "博客", @@ -610,21 +597,13 @@ "badge": "工作空间", "title": "组件布局", "description": "拆分工作区,将组件并排或堆叠放置。为每个工作区保存并切换命名的布局。", - "bullets": [ - "递归拆分", - "每个工作区保存的布局", - "共享组件操作菜单" - ] + "bullets": ["递归拆分", "每个工作区保存的布局", "共享组件操作菜单"] }, { "badge": "图表", "title": "指标与实时数据", "description": "内置指标以及一个用于编写自定义指标的 PineTS 编辑器。连接您自己的数据提供商,实时监控价格。", - "bullets": [ - "可配置的指标输入", - "每根 K 线实时重新执行", - "十字准线图例与图表标记" - ] + "bullets": ["可配置的指标输入", "每根 K 线实时重新执行", "十字准线图例与图表标记"] }, { "badge": "工作流", @@ -654,11 +633,11 @@ }, "blog": { "pageTitle": "博客", - "pageDescription": "关于交易自动化、工作流设计和构建更智能策略的洞察。{{count}} 篇文章,持续更新。", + "pageDescription": "关于交易自动化、工作流设计和构建更智能策略的洞察。{count} 篇文章,持续更新。", "searchPlaceholder": "搜索文章", "emptyTitle": "暂无文章", "emptyDescription": "请稍后再来——新文章即将发布。", - "noMatches": "未找到匹配\"{{query}}\"的文章", + "noMatches": "未找到匹配\"{query}\"的文章", "noMatchesDescription": "请尝试其他搜索词。", "readTimeSuffix": "分钟阅读", "viewArticle": "查看详情", @@ -666,11 +645,11 @@ "breadcrumbBlog": "博客", "tableOfContents": "本页内容", "shareTitle": "分享本文", - "shareOn": "在{{platform}}上分享", + "shareOn": "在{platform}上分享", "copyLink": "复制链接", "copied": "已复制!", "summarizeTitle": "AI 摘要", - "summarizeWithPlatform": "使用{{platform}}生成摘要", + "summarizeWithPlatform": "使用{platform}生成摘要", "articleSingular": "篇文章", "articlePlural": "篇文章" }, @@ -713,13 +692,7 @@ "experience": { "label": "工作年限 *", "placeholder": "请选择经验级别", - "options": [ - "0-1 年", - "1-3 年", - "3-5 年", - "5-10 年", - "10 年以上" - ] + "options": ["0-1 年", "1-3 年", "3-5 年", "5-10 年", "10 年以上"] }, "location": { "label": "所在地 *", @@ -772,8 +745,8 @@ "rssFeed": "RSS 订阅源", "loadingMore": "加载中...", "showMore": "显示更多", - "viewContributorAriaLabel": "在 GitHub 上查看 @{{contributor}}", - "contributorAvatarAlt": "@{{contributor}}", + "viewContributorAriaLabel": "在 GitHub 上查看 @{contributor}", + "contributorAvatarAlt": "@{contributor}", "breadcrumb": "更新日志" }, "legal": { @@ -783,12 +756,12 @@ "terms": { "title": "服务条款", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "这些服务条款管辖您对 {{projectName}} 网站、应用程序、API 以及任何由项目运营的托管服务(统称为“服务”)的访问和使用。\n\n如果您使用的是自行托管部署或由非 TradingGoose 项目所有者运营的部署,则该运营商对其自身的服务条款、隐私声明、安全实践、计费和合规性负责。\n\n通过访问或使用服务,即表示您同意这些条款。如果您不同意,请勿使用服务。\n\n## 1. 开源许可证\n\n{{projectName}} 源代码根据项目的 AGPL-3.0-only 许可证和适用的第三方许可证单独提供。这些条款管理项目运营的网站和托管服务,并且不会取代或减少源代码许可证授予您的权利。\n\n许可证和署名详情可在仓库以及 [许可证与声明](/licenses) 页面获取。\n\n## 2. 资格、账户与访问\n\n您可能需要拥有账户才能使用服务的某些部分。您必须提供准确的信息,保护您的凭证安全,并对通过您账户发生的活动负责。\n\n您有责任维护登录凭证、API 密钥、OAuth 连接、经纪商凭证以及链接到您账户或工作流的任何其他访问方式的机密性。\n\n如果我们合理认为账户的使用违反了这些条款、威胁安全或造成法律或运营风险,我们可能会暂停或限制访问。\n\n## 3. 可接受使用\n\n您不得将服务用于:\n\n- 违反法律或侵犯他人权利。\n- 上传、传输或自动化非法、侵权、滥用或恶意内容。\n- 尝试未经授权的访问、干扰服务或干扰其他用户。\n- 使用服务分发恶意软件、垃圾邮件或进行欺诈活动。\n- 滥用您不控制或未获授权使用的关联账户、OAuth 凭证、经纪商账户或第三方 API。\n- 以需要您不具备的监管注册、披露或许可的方式使用服务。\n\n## 4. 您的内容、工作流与集成\n\n您保留通过服务提交或连接的内容、文件、提示词、工作流定义、指标脚本、凭证和其他数据(“您的内容”)的所有权。\n\n您授予我们有限的许可,仅用于运营、保护、支持和改进服务所需的主机、处理、存储、传输和展示您的内容。\n\n您有责任确保拥有使用您的内容以及您连接的任何第三方服务、市场数据源、经纪商账户或 API 所需的权利和权限。\n\n## 5. 第三方服务与关联账户\n\n服务可能与第三方服务互操作,包括模型提供商、存储提供商、通信服务、身份提供商、分析服务、支付处理器、市场数据服务和经纪商平台。\n\n您对这些第三方服务的使用仍受其自身条款、隐私声明、费用、技术限制和可用性的约束。\n\n对于第三方服务的中断、错误、价格变更、API 更改、账户限制、执行失败或其他行为,我们概不负责。\n\n## 6. 付费功能与计费\n\n某些部署可能提供付费计划、按使用量计费或订阅功能。如果您购买付费访问权限,即表示您同意支付购买时描述的适用费用、税费和收费。\n\n计费可能由第三方支付处理器(如 Stripe)处理。我们本身不存储完整的支付卡详细信息。\n\n未能支付可能导致付费功能被暂停或降级。定价和计划条款可能发生前瞻性变更。\n\n## 7. 仅限分析、研究与自动化\n\n{{projectName}} 作为提供分析、研究、图表、监控、工作流自动化及相关技术操作的软件。它不是经纪-交易商、交易所、投资顾问、受托人或执行场所。\n\n服务不提供财务、投资、法律、会计或税务建议。任何输出、图表、警报、脚本、模型响应、工作流结果或自动化工具仅供信息参考。\n\n您全权负责评估信息、审核输出、管理风险、确定适宜性,并决定是否基于服务进行交易、提交订单或采取任何其他行动。\n\n## 8. 交易操作、经纪商集成与市场数据\n\n某些部署可能启用与第三方经纪商、交易所、预测市场或市场数据提供商交互的工作流或工具。任何此类交易操作均由您指示发起,并由相关第三方提供商(如有)执行。\n\n对于因通过或基于服务采取的行动而产生的交易、订单、取消、成交、部分成交、拒绝订单、延迟执行、过时价格、错误代码、模型错误、工作流逻辑错误、不可用 API、市场数据不准确或任何类型的损失,我们概不负责。\n\n您有责任配置保护措施、测试工作流、监督自动化行为,并确认任何连接的交易活动符合适用法律和第三方提供商的规则。\n\n## 9. 可用性、实验性功能与变更\n\n服务可能随时间变化。我们可能随时添加、修改、暂停或删除功能,包括集成、托管功能和实验性功能。\n\n某些功能可能被标记为实验性、测试版、预览版或以其他方式被视为不完整。您需自行承担使用这些功能的风险。\n\n如果您违反这些条款、造成安全或法律风险或滥用服务,我们可能暂停或终止您对项目运营服务的访问权限。您可以随时停止使用服务。\n\n## 10. 知识产权与品牌\n\n服务包括开源代码、第三方组件以及项目拥有的品牌和内容。开源和第三方材料仍受其各自许可证的约束。\n\n除非许可证明确允许,否则未经许可,不得以暗示背书、关联或来源的方式使用 {{projectName}} 名称、徽标和品牌资产。\n\n## 11. 免责声明\n\n在法律允许的最大范围内,服务按“现状”和“可用”提供,不附带任何形式的保证。我们不保证服务不间断运行、准确性、盈利性、集成的可用性、适用于特定策略,或服务能够防止损失或错误。\n\n## 12. 责任限制\n\n在法律允许的最大范围内,对于因服务引起或与之相关的间接、附带、特殊、后果性、惩戒性或惩罚性损害赔偿,或利润损失、收入损失、交易损失、经纪损失、商誉损失、数据损失或业务中断,我们概不负责。\n\n如果无法排除责任,则责任上限为您就项目运营服务在索赔发生前 12 个月内向我们支付的金额。\n\n## 13. 赔偿\n\n在法律允许的范围内,您将对因滥用服务、您的关联账户、您的内容、您的工作流或违反这些条款或适用法律而引起的索赔、损失和费用负责。\n\n## 14. 条款变更\n\n我们可能不时更新这些条款。更新时,我们将更新本页面的最后更新日期。更新生效后您继续使用项目运营服务即表示您接受修订后的条款。\n\n## 15. 联系方式与版权通知\n\n如果您对这些条款有疑问,或者认为通过项目运营服务提供的材料侵犯了您的权利,请通过 [{{supportEmail}}](mailto:{{supportEmail}}) 联系我们。\n\n请提供足够详细的信息以便我们理解和审核您的请求。我们目前不在此页面公布用于法律通知的单独邮寄地址。" + "bodyMarkdown": "这些服务条款管辖您对 {projectName} 网站、应用程序、API 以及任何由项目运营的托管服务(统称为“服务”)的访问和使用。\n\n如果您使用的是自行托管部署或由非 TradingGoose 项目所有者运营的部署,则该运营商对其自身的服务条款、隐私声明、安全实践、计费和合规性负责。\n\n通过访问或使用服务,即表示您同意这些条款。如果您不同意,请勿使用服务。\n\n## 1. 开源许可证\n\n{projectName} 源代码根据项目的 AGPL-3.0-only 许可证和适用的第三方许可证单独提供。这些条款管理项目运营的网站和托管服务,并且不会取代或减少源代码许可证授予您的权利。\n\n许可证和署名详情可在仓库以及 [许可证与声明](/licenses) 页面获取。\n\n## 2. 资格、账户与访问\n\n您可能需要拥有账户才能使用服务的某些部分。您必须提供准确的信息,保护您的凭证安全,并对通过您账户发生的活动负责。\n\n您有责任维护登录凭证、API 密钥、OAuth 连接、经纪商凭证以及链接到您账户或工作流的任何其他访问方式的机密性。\n\n如果我们合理认为账户的使用违反了这些条款、威胁安全或造成法律或运营风险,我们可能会暂停或限制访问。\n\n## 3. 可接受使用\n\n您不得将服务用于:\n\n- 违反法律或侵犯他人权利。\n- 上传、传输或自动化非法、侵权、滥用或恶意内容。\n- 尝试未经授权的访问、干扰服务或干扰其他用户。\n- 使用服务分发恶意软件、垃圾邮件或进行欺诈活动。\n- 滥用您不控制或未获授权使用的关联账户、OAuth 凭证、经纪商账户或第三方 API。\n- 以需要您不具备的监管注册、披露或许可的方式使用服务。\n\n## 4. 您的内容、工作流与集成\n\n您保留通过服务提交或连接的内容、文件、提示词、工作流定义、指标脚本、凭证和其他数据(“您的内容”)的所有权。\n\n您授予我们有限的许可,仅用于运营、保护、支持和改进服务所需的主机、处理、存储、传输和展示您的内容。\n\n您有责任确保拥有使用您的内容以及您连接的任何第三方服务、市场数据源、经纪商账户或 API 所需的权利和权限。\n\n## 5. 第三方服务与关联账户\n\n服务可能与第三方服务互操作,包括模型提供商、存储提供商、通信服务、身份提供商、分析服务、支付处理器、市场数据服务和经纪商平台。\n\n您对这些第三方服务的使用仍受其自身条款、隐私声明、费用、技术限制和可用性的约束。\n\n对于第三方服务的中断、错误、价格变更、API 更改、账户限制、执行失败或其他行为,我们概不负责。\n\n## 6. 付费功能与计费\n\n某些部署可能提供付费计划、按使用量计费或订阅功能。如果您购买付费访问权限,即表示您同意支付购买时描述的适用费用、税费和收费。\n\n计费可能由第三方支付处理器(如 Stripe)处理。我们本身不存储完整的支付卡详细信息。\n\n未能支付可能导致付费功能被暂停或降级。定价和计划条款可能发生前瞻性变更。\n\n## 7. 仅限分析、研究与自动化\n\n{projectName} 作为提供分析、研究、图表、监控、工作流自动化及相关技术操作的软件。它不是经纪-交易商、交易所、投资顾问、受托人或执行场所。\n\n服务不提供财务、投资、法律、会计或税务建议。任何输出、图表、警报、脚本、模型响应、工作流结果或自动化工具仅供信息参考。\n\n您全权负责评估信息、审核输出、管理风险、确定适宜性,并决定是否基于服务进行交易、提交订单或采取任何其他行动。\n\n## 8. 交易操作、经纪商集成与市场数据\n\n某些部署可能启用与第三方经纪商、交易所、预测市场或市场数据提供商交互的工作流或工具。任何此类交易操作均由您指示发起,并由相关第三方提供商(如有)执行。\n\n对于因通过或基于服务采取的行动而产生的交易、订单、取消、成交、部分成交、拒绝订单、延迟执行、过时价格、错误代码、模型错误、工作流逻辑错误、不可用 API、市场数据不准确或任何类型的损失,我们概不负责。\n\n您有责任配置保护措施、测试工作流、监督自动化行为,并确认任何连接的交易活动符合适用法律和第三方提供商的规则。\n\n## 9. 可用性、实验性功能与变更\n\n服务可能随时间变化。我们可能随时添加、修改、暂停或删除功能,包括集成、托管功能和实验性功能。\n\n某些功能可能被标记为实验性、测试版、预览版或以其他方式被视为不完整。您需自行承担使用这些功能的风险。\n\n如果您违反这些条款、造成安全或法律风险或滥用服务,我们可能暂停或终止您对项目运营服务的访问权限。您可以随时停止使用服务。\n\n## 10. 知识产权与品牌\n\n服务包括开源代码、第三方组件以及项目拥有的品牌和内容。开源和第三方材料仍受其各自许可证的约束。\n\n除非许可证明确允许,否则未经许可,不得以暗示背书、关联或来源的方式使用 {projectName} 名称、徽标和品牌资产。\n\n## 11. 免责声明\n\n在法律允许的最大范围内,服务按“现状”和“可用”提供,不附带任何形式的保证。我们不保证服务不间断运行、准确性、盈利性、集成的可用性、适用于特定策略,或服务能够防止损失或错误。\n\n## 12. 责任限制\n\n在法律允许的最大范围内,对于因服务引起或与之相关的间接、附带、特殊、后果性、惩戒性或惩罚性损害赔偿,或利润损失、收入损失、交易损失、经纪损失、商誉损失、数据损失或业务中断,我们概不负责。\n\n如果无法排除责任,则责任上限为您就项目运营服务在索赔发生前 12 个月内向我们支付的金额。\n\n## 13. 赔偿\n\n在法律允许的范围内,您将对因滥用服务、您的关联账户、您的内容、您的工作流或违反这些条款或适用法律而引起的索赔、损失和费用负责。\n\n## 14. 条款变更\n\n我们可能不时更新这些条款。更新时,我们将更新本页面的最后更新日期。更新生效后您继续使用项目运营服务即表示您接受修订后的条款。\n\n## 15. 联系方式与版权通知\n\n如果您对这些条款有疑问,或者认为通过项目运营服务提供的材料侵犯了您的权利,请通过 [{supportEmail}](mailto:{supportEmail}) 联系我们。\n\n请提供足够详细的信息以便我们理解和审核您的请求。我们目前不在此页面公布用于法律通知的单独邮寄地址。" }, "privacy": { "title": "隐私政策", "lastUpdatedDate": "2026-03-28", - "bodyMarkdown": "本隐私政策描述了当 {{projectName}} 项目所有者运营网站、应用程序、API 或托管服务(统称为“服务”)时,{{projectName}} 如何处理个人数据。\n\n如果您使用的是自托管部署或由他人运营的部署,则该运营商负责其自身的隐私声明、数据处理、Cookie、分析、集成和安全实践。\n\n## 1. 范围与角色\n\n本隐私政策适用于 {{projectName}} 项目运营的部署。对于自托管部署或由他人运营的部署,该运营商控制其自身的配置、存储、集成、分析、保留和合规性。\n\n在这些情况下,第三方运营商(而非 {{projectName}} 项目所有者)是该部署用户数据的主要控制者或运营者。\n\n## 2. 我们收集的信息\n\n### 账户与身份验证数据\n\n我们可能会收集您在创建或使用账户时提供的信息,例如您的姓名、电子邮件地址、登录方式、个人资料详情、组织成员身份和账户设置。\n\n### 内容与工作流数据\n\n我们可能会处理您通过服务提交或生成的提示、聊天记录、文件、文档、工作流定义、日志、指标脚本、自选股列表、模板以及其他内容。\n\n### 关联账户与集成数据\n\n如果您连接第三方服务,我们可能会收到账户标识符、OAuth 令牌、元数据以及您授权我们访问的第三方数据。根据您启用的内容,这可能包括 Google、GitHub、Microsoft、Slack、Stripe 以及券商或市场数据提供商等服务。\n\n### 付款与订阅数据\n\n如果启用了付费计划或使用量计费,计费将通过 Stripe 等支付提供商处理。我们可能会收到计费元数据,如客户 ID、订阅状态、发票和付款结果,但我们不会自行存储完整的支付卡详细信息。\n\n### 技术与使用数据\n\n我们可能会自动收集信息,例如 IP 地址、浏览器类型、设备信息、时间戳、错误数据、请求日志、功能使用情况、页面访问次数和性能指标。\n\n### Cookie、本地存储与分析\n\n服务使用 Cookie、本地存储和类似技术进行身份验证、会话管理、偏好设置、安全性和产品分析。\n\n如果部署启用了分析功能,则可能包括 OpenTelemetry 事件收集和 PostHog 分析。PostHog 可能会收集页面浏览量、点击次数、表单交互和会话重放数据。密码字段在重放中会被屏蔽,但其他表单输入可能不会被屏蔽。\n\n### 可选训练数据集导出\n\n某些部署可能启用可选的副驾驶训练功能。如果您明确使用这些功能,您选择提交的记录工作流编辑数据集可能会被发送到已配置的索引或训练服务。\n\n## 3. 信息来源\n\n我们可能直接从以下来源收集信息:\n\n- 您(当您创建账户、上传内容、连接服务或联系我们时)。\n- 您的浏览器、设备和服务的使用情况。\n- 您选择连接的第三方账户和 API。\n- 支付提供商、分析提供商、托管供应商和运营供应商。\n\n我们还可能从日志、执行结果、计费事件和系统遥测中获取运营或诊断信息。\n\n## 4. 我们如何使用信息\n\n我们可能使用收集的信息来:\n\n- 提供、维护和保护服务。\n- 对用户进行身份验证并管理账户、组织和权限。\n- 存储、执行工作流、聊天、文件和集成并进行故障排除。\n- 处理付款、订阅、使用限制和计费通信。\n- 响应支持请求、错误报告和产品反馈。\n- 监控性能、可靠性、欺诈、滥用和安全事件。\n- 改进服务和开发新功能。\n- 遵守法律义务并执行我们的条款和政策。\n\n如果您使用 AI 或自动化功能,您的内容可能会由您选择的或部署配置的模型提供商或集成提供商处理,以便交付请求的功能。\n\n## 5. 我们如何共享信息\n\n我们可能会与以下各方共享信息:\n\n- 帮助我们运营服务的服务提供商,例如托管、存储、电子邮件、分析、日志记录和支付供应商。\n- 运行您启用的工作流、集成或功能所需的第三方平台、模型提供商和 API。\n- 法律要求的执法机构、监管机构、法院或其他相关方。\n- 与服务相关的合并、收购、融资或转让中的继承者或购买方。\n\n我们不会为了金钱而出售个人信息。当您启用连接功能或部署使用分析或其他运营供应商时,仍可能发生第三方服务传输。\n\n## 6. Google、AI 提供商、券商集成和其他连接服务的数据\n\n如果您连接 Google 或其他第三方服务,我们仅在提供您启用的功能所需时访问和使用这些数据,并遵守您的权限和连接提供商的条款。\n\n我们不会将通过 Google API 获得的 Google 用户数据用于训练通用 AI 或 ML 模型。\n\n除了上述可选训练数据集功能外,我们不会使用您的工作流内容来训练服务的通用模型。\n\n## 7. Cookie、本地存储、遥测与分析\n\n服务使用 Cookie、本地存储和相关技术进行登录会话、身份验证状态、UI 偏好设置、账户设置、安全性、产品分析和类似的运营功能。\n\n对于项目运营的部署,匿名遥测可能默认启用,并且可能通过产品设置进行控制,具体取决于部署和您的账户状态。\n\n某些部署还可能启用 PostHog 或类似的分析工具,用于页面分析、交互分析和会话重放。如果您运行自托管部署,您的运营商控制此类工具是否启用以及数据发送位置。\n\n## 8. 国际处理\n\n信息可能在美国以及我们的供应商、基础设施或连接服务提供商运营的其他国家/地区进行处理。不同司法管辖区的数据保护法律可能有所不同。\n\n## 9. 保留期限\n\n我们会在合理必要的期限内保留信息,以运营服务、维护您的账户、处理计费、调查滥用、遵守法律义务和解决争议。\n\n保留期限可能因数据类型和部署配置而异。自托管或第三方运营商控制其部署的自身保留设置。\n\n## 10. 安全\n\n我们采用合理的行政、技术和组织保障措施来保护信息,但没有任何系统是绝对安全的。您也有责任保护您的账户凭据、API 密钥和关联的第三方账户。\n\n## 11. 您的选择与权利\n\n根据您所在的地点,您可能拥有访问、更正、删除、导出或反对某些个人数据使用的权利。\n\n您还可以通过产品设置(例如匿名遥测偏好)或断开第三方账户连接来直接控制某些数据收集。\n\n如果您位于欧洲经济区、英国或具有类似权利的其他司法管辖区,您可能还拥有限制、反对、撤回同意(在使用同意的情况下)以及向监管机构投诉的权利。\n\n如果您是加州居民,您可能拥有了解、访问、更正、删除和接收我们为项目运营的部署所收集、使用和披露的个人信息类别的信息,但需遵守法律例外情况。\n\n如需就项目运营的部署提出隐私相关请求,请通过 [{{supportEmail}}](mailto:{{supportEmail}}) 联系我们。如果您使用的是自托管或第三方部署,请联系该运营商。\n\n## 12. 儿童隐私\n\n服务不面向 18 岁以下儿童,我们不会通过项目运营的服务故意收集儿童的个人数据。\n\n## 13. 外部服务\n\n本隐私政策不涵盖您通过服务连接的第三方服务、券商、模型提供商、市场数据提供商或网站。请查看这些提供商自身的条款和隐私声明。\n\n## 14. 本政策的变更\n\n我们可能会不时更新本隐私政策。更新时,我们将更新此页面上的“最后更新”日期。重大变更将在发布时生效,除非法律要求更长的通知期。\n\n## 15. 联系方式\n\n如果您对项目运营的部署的本隐私政策有疑问、请求或投诉,请通过 [{{supportEmail}}](mailto:{{supportEmail}}) 联系我们。\n\n我们目前不在本页面上公布单独的邮寄地址。如果情况有变,我们将更新本政策。" + "bodyMarkdown": "本隐私政策描述了当 {projectName} 项目所有者运营网站、应用程序、API 或托管服务(统称为“服务”)时,{projectName} 如何处理个人数据。\n\n如果您使用的是自托管部署或由他人运营的部署,则该运营商负责其自身的隐私声明、数据处理、Cookie、分析、集成和安全实践。\n\n## 1. 范围与角色\n\n本隐私政策适用于 {projectName} 项目运营的部署。对于自托管部署或由他人运营的部署,该运营商控制其自身的配置、存储、集成、分析、保留和合规性。\n\n在这些情况下,第三方运营商(而非 {projectName} 项目所有者)是该部署用户数据的主要控制者或运营者。\n\n## 2. 我们收集的信息\n\n### 账户与身份验证数据\n\n我们可能会收集您在创建或使用账户时提供的信息,例如您的姓名、电子邮件地址、登录方式、个人资料详情、组织成员身份和账户设置。\n\n### 内容与工作流数据\n\n我们可能会处理您通过服务提交或生成的提示、聊天记录、文件、文档、工作流定义、日志、指标脚本、自选股列表、模板以及其他内容。\n\n### 关联账户与集成数据\n\n如果您连接第三方服务,我们可能会收到账户标识符、OAuth 令牌、元数据以及您授权我们访问的第三方数据。根据您启用的内容,这可能包括 Google、GitHub、Microsoft、Slack、Stripe 以及券商或市场数据提供商等服务。\n\n### 付款与订阅数据\n\n如果启用了付费计划或使用量计费,计费将通过 Stripe 等支付提供商处理。我们可能会收到计费元数据,如客户 ID、订阅状态、发票和付款结果,但我们不会自行存储完整的支付卡详细信息。\n\n### 技术与使用数据\n\n我们可能会自动收集信息,例如 IP 地址、浏览器类型、设备信息、时间戳、错误数据、请求日志、功能使用情况、页面访问次数和性能指标。\n\n### Cookie、本地存储与分析\n\n服务使用 Cookie、本地存储和类似技术进行身份验证、会话管理、偏好设置、安全性和产品分析。\n\n如果部署启用了分析功能,则可能包括 OpenTelemetry 事件收集和 PostHog 分析。PostHog 可能会收集页面浏览量、点击次数、表单交互和会话重放数据。密码字段在重放中会被屏蔽,但其他表单输入可能不会被屏蔽。\n\n### 可选训练数据集导出\n\n某些部署可能启用可选的副驾驶训练功能。如果您明确使用这些功能,您选择提交的记录工作流编辑数据集可能会被发送到已配置的索引或训练服务。\n\n## 3. 信息来源\n\n我们可能直接从以下来源收集信息:\n\n- 您(当您创建账户、上传内容、连接服务或联系我们时)。\n- 您的浏览器、设备和服务的使用情况。\n- 您选择连接的第三方账户和 API。\n- 支付提供商、分析提供商、托管供应商和运营供应商。\n\n我们还可能从日志、执行结果、计费事件和系统遥测中获取运营或诊断信息。\n\n## 4. 我们如何使用信息\n\n我们可能使用收集的信息来:\n\n- 提供、维护和保护服务。\n- 对用户进行身份验证并管理账户、组织和权限。\n- 存储、执行工作流、聊天、文件和集成并进行故障排除。\n- 处理付款、订阅、使用限制和计费通信。\n- 响应支持请求、错误报告和产品反馈。\n- 监控性能、可靠性、欺诈、滥用和安全事件。\n- 改进服务和开发新功能。\n- 遵守法律义务并执行我们的条款和政策。\n\n如果您使用 AI 或自动化功能,您的内容可能会由您选择的或部署配置的模型提供商或集成提供商处理,以便交付请求的功能。\n\n## 5. 我们如何共享信息\n\n我们可能会与以下各方共享信息:\n\n- 帮助我们运营服务的服务提供商,例如托管、存储、电子邮件、分析、日志记录和支付供应商。\n- 运行您启用的工作流、集成或功能所需的第三方平台、模型提供商和 API。\n- 法律要求的执法机构、监管机构、法院或其他相关方。\n- 与服务相关的合并、收购、融资或转让中的继承者或购买方。\n\n我们不会为了金钱而出售个人信息。当您启用连接功能或部署使用分析或其他运营供应商时,仍可能发生第三方服务传输。\n\n## 6. Google、AI 提供商、券商集成和其他连接服务的数据\n\n如果您连接 Google 或其他第三方服务,我们仅在提供您启用的功能所需时访问和使用这些数据,并遵守您的权限和连接提供商的条款。\n\n我们不会将通过 Google API 获得的 Google 用户数据用于训练通用 AI 或 ML 模型。\n\n除了上述可选训练数据集功能外,我们不会使用您的工作流内容来训练服务的通用模型。\n\n## 7. Cookie、本地存储、遥测与分析\n\n服务使用 Cookie、本地存储和相关技术进行登录会话、身份验证状态、UI 偏好设置、账户设置、安全性、产品分析和类似的运营功能。\n\n对于项目运营的部署,匿名遥测可能默认启用,并且可能通过产品设置进行控制,具体取决于部署和您的账户状态。\n\n某些部署还可能启用 PostHog 或类似的分析工具,用于页面分析、交互分析和会话重放。如果您运行自托管部署,您的运营商控制此类工具是否启用以及数据发送位置。\n\n## 8. 国际处理\n\n信息可能在美国以及我们的供应商、基础设施或连接服务提供商运营的其他国家/地区进行处理。不同司法管辖区的数据保护法律可能有所不同。\n\n## 9. 保留期限\n\n我们会在合理必要的期限内保留信息,以运营服务、维护您的账户、处理计费、调查滥用、遵守法律义务和解决争议。\n\n保留期限可能因数据类型和部署配置而异。自托管或第三方运营商控制其部署的自身保留设置。\n\n## 10. 安全\n\n我们采用合理的行政、技术和组织保障措施来保护信息,但没有任何系统是绝对安全的。您也有责任保护您的账户凭据、API 密钥和关联的第三方账户。\n\n## 11. 您的选择与权利\n\n根据您所在的地点,您可能拥有访问、更正、删除、导出或反对某些个人数据使用的权利。\n\n您还可以通过产品设置(例如匿名遥测偏好)或断开第三方账户连接来直接控制某些数据收集。\n\n如果您位于欧洲经济区、英国或具有类似权利的其他司法管辖区,您可能还拥有限制、反对、撤回同意(在使用同意的情况下)以及向监管机构投诉的权利。\n\n如果您是加州居民,您可能拥有了解、访问、更正、删除和接收我们为项目运营的部署所收集、使用和披露的个人信息类别的信息,但需遵守法律例外情况。\n\n如需就项目运营的部署提出隐私相关请求,请通过 [{supportEmail}](mailto:{supportEmail}) 联系我们。如果您使用的是自托管或第三方部署,请联系该运营商。\n\n## 12. 儿童隐私\n\n服务不面向 18 岁以下儿童,我们不会通过项目运营的服务故意收集儿童的个人数据。\n\n## 13. 外部服务\n\n本隐私政策不涵盖您通过服务连接的第三方服务、券商、模型提供商、市场数据提供商或网站。请查看这些提供商自身的条款和隐私声明。\n\n## 14. 本政策的变更\n\n我们可能会不时更新本隐私政策。更新时,我们将更新此页面上的“最后更新”日期。重大变更将在发布时生效,除非法律要求更长的通知期。\n\n## 15. 联系方式\n\n如果您对项目运营的部署的本隐私政策有疑问、请求或投诉,请通过 [{supportEmail}](mailto:{supportEmail}) 联系我们。\n\n我们目前不在本页面上公布单独的邮寄地址。如果情况有变,我们将更新本政策。" }, "licenses": { "title": "许可与声明", @@ -820,7 +793,7 @@ }, "warning": { "title": "已是团队成员", - "currentOrgWithName": "您当前是 \"{{name}}\" 的成员。在接受新邀请之前,您必须离开当前组织。", + "currentOrgWithName": "您当前是 \"{name}\" 的成员。在接受新邀请之前,您必须离开当前组织。", "currentOrg": "您已经是一个组织的成员。在接受新邀请之前,请离开当前组织。", "manageTeamSettings": "管理团队设置" }, @@ -829,12 +802,12 @@ }, "success": { "title": "欢迎!", - "description": "您已成功加入 {{name}}。正在重定向到您的工作区..." + "description": "您已成功加入 {name}。正在重定向到您的工作区..." }, "invitation": { "organizationTitle": "组织邀请", "workspaceTitle": "工作区邀请", - "description": "您已受邀加入 {{name}}。请点击下方接受以加入。", + "description": "您已受邀加入 {name}。请点击下方接受以加入。", "accept": "接受邀请" }, "errors": { @@ -1042,7 +1015,7 @@ "createTooltip": "需要写入权限才能创建知识库" }, "errors": { - "load": "加载知识库时出错:{{error}}", + "load": "加载知识库时出错:{error}", "retry": "重试" }, "emptyState": { @@ -1078,7 +1051,7 @@ "copyId": "复制知识库 ID", "deleteButtonLabel": "删除知识库", "deleteTitle": "删除知识库", - "deleteDescription": "确定要删除“{{title}}”吗?此操作将永久删除该知识库及其 {{count}} 个文档{{plural}}。", + "deleteDescription": "确定要删除“{title}”吗?此操作将永久删除该知识库及其 {count} 个文档{plural}。", "cancel": "取消", "deleteConfirm": "删除", "deleting": "删除中..." @@ -1112,7 +1085,7 @@ "nextChunk": "下一个块", "previousPage": "上一页", "nextPage": "下一页", - "editingChunk": "编辑块 #{{index}} • 第 {{currentPage}} 页,共 {{totalPages}} 页", + "editingChunk": "编辑块 #{index} • 第 {currentPage} 页,共 {totalPages} 页", "cancel": "取消", "errors": { "failedToCreateChunk": "创建块失败", @@ -1150,8 +1123,8 @@ "creating": "正在创建...", "failedToCreateKnowledgeBase": "创建知识库失败", "unknownError": "发生未知错误", - "fileTooLarge": "文件 {{name}} 过大。单个文件最大 100MB。", - "unsupportedFileType": "文件 {{name}} 格式不受支持。请使用 PDF、DOC、DOCX、TXT、CSV、XLS、XLSX、MD、PPT、PPTX、HTML、JSON、YAML 或 YML 格式。", + "fileTooLarge": "文件 {name} 过大。单个文件最大 100MB。", + "unsupportedFileType": "文件 {name} 格式不受支持。请使用 PDF、DOC、DOCX、TXT、CSV、XLS、XLSX、MD、PPT、PPTX、HTML、JSON、YAML 或 YML 格式。", "processingError": "处理文件时发生错误,请重试。", "validation": { "nameRequired": "名称不能为空", @@ -1180,8 +1153,8 @@ "uploadDocuments": "上传文档", "uploading": "正在上传...", "processing": "正在处理...", - "fileTooLarge": "文件“{{name}}”过大。最大大小为 100MB。", - "unsupportedFileType": "文件“{{name}}”格式不支持。请使用 PDF、DOC、DOCX、TXT、CSV、XLS、XLSX、MD、PPT、PPTX、HTML、JSON、YAML 或 YML 文件。" + "fileTooLarge": "文件“{name}”过大。最大大小为 100MB。", + "unsupportedFileType": "文件“{name}”格式不支持。请使用 PDF、DOC、DOCX、TXT、CSV、XLS、XLSX、MD、PPT、PPTX、HTML、JSON、YAML 或 YML 文件。" }, "document": { "searchChunksPlaceholder": "搜索块...", @@ -1189,13 +1162,13 @@ "deleteChunkTitle": "删除块?", "deleteChunksTitle": "删除块?", "deleteChunkDescription": "删除此块将永久将其从此文档中移除。", - "deleteChunksDescription": "删除 {{count}} 个块将永久将其从此文档中移除。", + "deleteChunksDescription": "删除 {count} 个块将永久将其从此文档中移除。", "thisActionCannotBeUndone": "此操作无法撤消。", "cancel": "取消", "deleteFileTitle": "删除文件?", "deleteFilesTitle": "删除文件?", - "deleteFileDescription": "删除\"{{name}}\"将永久移除其源文件、数据块和嵌入内容,从此知识库中清除。", - "deleteFilesDescription": "删除 {{count}} 个文件将永久移除其源文件、数据块和嵌入内容,从此知识库中清除。", + "deleteFileDescription": "删除\"{name}\"将永久移除其源文件、数据块和嵌入内容,从此知识库中清除。", + "deleteFilesDescription": "删除 {count} 个文件将永久移除其源文件、数据块和嵌入内容,从此知识库中清除。", "delete": "删除", "deleting": "正在删除..." }, @@ -1210,8 +1183,8 @@ "addTag": "添加标签", "emptyState": "尚未添加任何标签。点击\"添加标签\"开始操作。", "advancedSettings": "高级设置", - "tagCount": "{{count}} 个标签", - "slotsUsed": "已使用 {{used}} / {{total}} 个标签位", + "tagCount": "{count} 个标签", + "slotsUsed": "已使用 {used} / {total} 个标签位", "editTag": "编辑标签", "addNewTag": "添加新标签", "tagName": "标签名称", @@ -1234,15 +1207,15 @@ "moreTags": "更多标签", "showFewerTags": "显示较少标签", "activeTags": "活跃标签:", - "tagLabel": "标签 {{index}}", + "tagLabel": "标签 {index}", "deleteTagTitle": "删除标签", - "deleteTagDescription": "确定要删除标签“{{name}}”吗?这将从{{count}}个文档中移除此标签。", + "deleteTagDescription": "确定要删除标签“{name}”吗?这将从{count}个文档中移除此标签。", "thisActionCannotBeUndone": "此操作无法撤销。", "affectedDocuments": "受影响的文档:", "deleting": "正在删除...", - "documentsUsingTagTitle": "使用“{{name}}”的文档", - "documentsUsingTagDescriptionSingular": "有 {{count}} 个文档当前正在使用此标签定义。", - "documentsUsingTagDescriptionPlural": "有 {{count}} 个文档当前正在使用此标签定义。", + "documentsUsingTagTitle": "使用“{name}”的文档", + "documentsUsingTagDescriptionSingular": "有 {count} 个文档当前正在使用此标签定义。", + "documentsUsingTagDescriptionPlural": "有 {count} 个文档当前正在使用此标签定义。", "singularIs": "是", "pluralAre": "是", "tagUnusedHelp": "此标签定义未被任何文档使用。您可以安全地删除它以释放标签槽位。" @@ -1286,7 +1259,7 @@ "refreshing": "正在刷新...", "chart": { "noData": "暂无可用数据。", - "toggleSeries": "切换序列 {{label}}" + "toggleSeries": "切换序列 {label}" }, "filters": { "title": "筛选条件", @@ -1295,7 +1268,7 @@ "suggestedFilters": "建议筛选条件", "textSearch": "文本搜索", "searchPlaceholder": "搜索日志...", - "filterOptionsPlaceholder": "未找到{{title}}的相关选项。", + "filterOptionsPlaceholder": "未找到{title}的相关选项。", "searchWorkflows": "搜索工作流...", "searchFolders": "搜索文件夹...", "searchOptions": "搜索选项...", @@ -1305,11 +1278,11 @@ "noFolders": "未找到文件夹。", "noOptions": "未找到选项。", "allWorkflows": "所有工作流", - "selectedWorkflows": "已选择 {{count}} 个工作流", + "selectedWorkflows": "已选择 {count} 个工作流", "allFolders": "所有文件夹", - "selectedFolders": "已选择 {{count}} 个文件夹", + "selectedFolders": "已选择 {count} 个文件夹", "allTriggers": "所有触发器", - "selectedTriggers": "已选择 {{count}} 个触发器", + "selectedTriggers": "已选择 {count} 个触发器", "allTime": "所有时间", "past30Minutes": "过去30分钟", "pastHour": "过去1小时", @@ -1334,7 +1307,7 @@ "trigger": "触发器", "timeline": "时间线", "retentionPolicy": "日志保留策略", - "retentionDescription": "在此层级,日志将在{{days}}天后自动删除。", + "retentionDescription": "在此层级,日志将在{days}天后自动删除。", "upgradePlan": "升级计划" }, "metrics": { @@ -1345,15 +1318,15 @@ }, "workflows": { "title": "工作流", - "legend": "每个单元格代表所选范围的 {{duration}}。点击某个单元格可筛选详细信息。", - "count": "{{count}} 个工作流", - "countPlural": "{{count}} 个工作流", - "filteredFrom": "(从 {{count}} 个中筛选)", - "noMatches": "未找到匹配 \"{{query}}\" 的工作流。", + "legend": "每个单元格代表所选范围的 {duration}。点击某个单元格可筛选详细信息。", + "count": "{count} 个工作流", + "countPlural": "{count} 个工作流", + "filteredFrom": "(从 {count} 个中筛选)", + "noMatches": "未找到匹配 \"{query}\" 的工作流。", "selectedSegment": "已选片段", - "filteredTo": "筛选至 {{timestamp}}", - "selectedRangeMore": "(+{{count}} 个片段)", - "selectedRangeExecutions": "— {{count}} 次执行", + "filteredTo": "筛选至 {timestamp}", + "selectedRangeMore": "(+{count} 个片段)", + "selectedRangeExecutions": "— {count} 次执行", "clearFilter": "清除筛选", "executions": "执行次数", "success": "成功", @@ -1372,13 +1345,13 @@ "noExecutions": "无执行", "loadingMore": "正在加载更多...", "scrollToLoadMore": "滚动以加载更多", - "succeeded": "{{success}}/{{total}} 成功", - "segment": "分段 {{index}}", + "succeeded": "{success}/{total} 成功", + "segment": "分段 {index}", "allWorkflows": "所有工作流", - "multipleSelected": "已选择 {{count}} 个工作流", - "durationDay": "{{count}} 天", - "durationHour": "{{count}} 小时", - "durationMinute": "{{count}} 分钟" + "multipleSelected": "已选择 {count} 个工作流", + "durationDay": "{count} 天", + "durationHour": "{count} 小时", + "durationMinute": "{count} 分钟" } }, "list": { @@ -1396,7 +1369,7 @@ "noLogs": "未找到日志", "unknownWorkflow": "未知工作流", "errorPrefix": "错误:", - "runningMs": "{{value}} 毫秒" + "runningMs": "{value} 毫秒" }, "details": { "title": "日志详情", @@ -1422,17 +1395,17 @@ "modelOutput": "模型输出:", "total": "总计:", "tokens": "令牌:", - "modelBreakdown": "模型明细({{count}})", + "modelBreakdown": "模型明细({count})", "input": "输入:", "output": "输出:", - "totalCostNote": "总费用包括基础执行费 {{amount}} 以及任何模型使用费用。", + "totalCostNote": "总费用包括基础执行费 {amount} 以及任何模型使用费用。", "notAvailable": "不可用", "unknownSize": "未知大小", "unknownType": "未知类型", "unknownWorkflow": "未知", "unknownLevel": "未知", "unknownValue": "未知", - "runningMs": "{{value}} 毫秒", + "runningMs": "{value} 毫秒", "traceSpans": { "workflowExecution": "工作流执行", "collapseAll": "全部折叠", @@ -1445,21 +1418,21 @@ "initialResponse": "初始响应", "modelResponse": "模型响应", "modelGeneration": "模型生成", - "tokens": "{{count}} 个令牌", + "tokens": "{count} 个令牌", "tokensUnavailable": "令牌不可用", - "tokensInOut": "输入 {{input}} / 输出 {{output}}", - "tokensTotal": "共 {{count}} 个令牌", - "tokensTotalSuffix": "(共 {{count}} 个)", + "tokensInOut": "输入 {input} / 输出 {output}", + "tokensTotal": "共 {count} 个令牌", + "tokensTotalSuffix": "(共 {count} 个)", "input": "输入", "output": "输出", "total": "总计", "start": "开始", - "plusMs": "+{{ms}} ms", - "betweenBlocks": "间隔 {{ms}} ms", + "plusMs": "+{ms} ms", + "betweenBlocks": "间隔 {ms} ms", "inputSection": "输入", "outputSection": "输出", "errorSection": "错误", - "segmentTimingTooltip": "{{type}}{{nameSuffix}} 耗时 {{duration}} ms" + "segmentTimingTooltip": "{type}{nameSuffix} 耗时 {duration} ms" }, "download": { "downloading": "正在下载...", @@ -1559,8 +1532,8 @@ } }, "searchEmpty": { - "workspace": "未找到匹配\"{{query}}\"的工作区环境变量。", - "personal": "未找到匹配\"{{query}}\"的个人环境变量。" + "workspace": "未找到匹配\"{query}\"的工作区环境变量。", + "personal": "未找到匹配\"{query}\"的个人环境变量。" }, "headers": { "createdAt": "创建时间", @@ -1583,7 +1556,7 @@ }, "apiKeys": { "title": "API 密钥", - "cardTitle": "{{scope}} API 密钥", + "cardTitle": "{scope} API 密钥", "searchPlaceholder": "搜索密钥...", "scope": { "workspace": "工作区", @@ -1605,7 +1578,7 @@ "button": "创建密钥" } }, - "searchEmpty": "未找到与\"{{query}}\"匹配的{{scope}} API 密钥。", + "searchEmpty": "未找到与\"{query}\"匹配的{scope} API 密钥。", "headers": { "createdAt": "创建时间", "name": "名称", @@ -1616,20 +1589,20 @@ "labels": { "never": "从未", "billingTier": "计费层级", - "lastUsed": "上次使用:{{date}}", + "lastUsed": "上次使用:{date}", "saveName": "保存 API 密钥名称", - "rename": "重命名{{scope}}API 密钥", - "reveal": "显示{{scope}}API 密钥", - "hide": "隐藏{{scope}}API 密钥", - "copy": "复制{{scope}}API 密钥", - "save": "保存{{scope}}API 密钥", + "rename": "重命名{scope}API 密钥", + "reveal": "显示{scope}API 密钥", + "hide": "隐藏{scope}API 密钥", + "copy": "复制{scope}API 密钥", + "save": "保存{scope}API 密钥", "cancelRename": "取消重命名", - "delete": "删除{{scope}}API 密钥", + "delete": "删除{scope}API 密钥", "nameRequired": "名称为必填项", - "duplicateName": "名为“{{name}}”的{{scope}}API 密钥已存在。", - "failedRename": "重命名{{scope}}API 密钥失败。", - "unableRename": "无法重命名{{scope}}API 密钥,请重试。", - "failedCreate": "创建{{scope}}API 密钥失败,请重试。", + "duplicateName": "名为“{name}”的{scope}API 密钥已存在。", + "failedRename": "重命名{scope}API 密钥失败。", + "unableRename": "无法重命名{scope}API 密钥,请重试。", + "failedCreate": "创建{scope}API 密钥失败,请重试。", "workspaceAccess": "此密钥授予对该工作区内所有工作流和文件的访问权限。请在创建后立即复制,因为之后将无法再次查看。", "personalAccess": "此密钥授予对个人工作流和文件的访问权限。请在创建后立即复制,因为之后将无法再次查看。", "onlyTimeYouWillSee": "这是您唯一一次看到完整密钥的机会,请复制并妥善保管。", @@ -1637,15 +1610,15 @@ "workspacePermissions": "您需要编辑或管理员权限才能管理工作区 API 密钥。" }, "dialogs": { - "createTitle": "创建{{scope}}API 密钥", + "createTitle": "创建{scope}API 密钥", "createNameLabel": "名称", "createNamePlaceholder": "例如:生产 MCP 服务器", "createButton": "创建密钥", - "newKeyTitle": "您的{{scope}}API 密钥", + "newKeyTitle": "您的{scope}API 密钥", "newKeyDescription": "这是您唯一一次看到完整密钥的机会,请复制并妥善保管。", - "deleteTitle": "删除{{scope}}API密钥?", + "deleteTitle": "删除{scope}API密钥?", "deleteDescription": "此操作将立即撤销所有使用此密钥的集成的访问权限。", - "deletePrompt": "输入{{name}}以确认。", + "deletePrompt": "输入{name}以确认。", "deletePlaceholder": "API密钥名称", "cancel": "取消", "deleteButton": "删除密钥", @@ -1684,7 +1657,7 @@ "searchPlaceholder": "搜索订单…", "filters": "筛选", "orderFilters": "订单筛选", - "showingOf": "显示 {{loadedCount}} / {{totalCount}}", + "showingOf": "显示 {loadedCount} / {totalCount}", "clear": "清除", "allProviders": "所有提供商", "allEnvironments": "所有环境", @@ -1761,7 +1734,7 @@ "disconnect": "断开连接", "emptyState": { "noConnectible": "未配置可连接的集成。", - "noSearchMatches": "未找到与\"{{query}}\"匹配的服务" + "noSearchMatches": "未找到与\"{query}\"匹配的服务" }, "errors": { "loadAvailability": "加载提供商可用性失败", @@ -1774,7 +1747,7 @@ "upload": { "idle": "上传文件", "uploading": "上传中...", - "uploadingWithCount": "上传中 {{completed}}/{{total}}...", + "uploadingWithCount": "上传中 {completed}/{total}...", "button": "上传文件" }, "headers": { @@ -1798,7 +1771,7 @@ }, "deleteDialog": { "title": "确认删除文件?", - "descriptionWithName": "删除 \"{{name}}\" 将会将其从此工作区中永久移除。", + "descriptionWithName": "删除 \"{name}\" 将会将其从此工作区中永久移除。", "description": "删除此文件将会将其从此工作区中永久移除。", "warning": "此操作无法撤销。", "cancel": "取消", @@ -1808,7 +1781,7 @@ "errors": { "billingTier": "计费层级", "uploadFailed": "上传失败", - "unsupportedFileType": "不支持的文件类型:{{files}}" + "unsupportedFileType": "不支持的文件类型:{files}" } }, "userMenu": { @@ -1824,7 +1797,7 @@ "loggingOut": "正在退出登录...", "billingPortalSelectOrganization": "请选择要管理计费的组织。", "billingPortalFailed": "打开计费门户失败", - "themeLabel": "主题:{{theme}}", + "themeLabel": "主题:{theme}", "languageLabel": "语言", "themeOptions": { "light": "浅色", @@ -1875,8 +1848,8 @@ "nameRequired": "请提供姓名。", "saveError": "无法保存个人资料设置。", "nameRequiredValidation": "姓名为必填项", - "profilePictureFileTooLarge": "文件 {{name}} 过大,最大允许 5MB。", - "profilePictureUnsupportedFormat": "文件 {{name}} 不是受支持的图片格式。请使用 PNG 或 JPEG。", + "profilePictureFileTooLarge": "文件 {name} 过大,最大允许 5MB。", + "profilePictureUnsupportedFormat": "文件 {name} 不是受支持的图片格式。请使用 PNG 或 JPEG。", "profilePictureUpdateError": "更新个人资料图片失败", "profilePictureRemoveError": "移除个人资料图片失败", "unableToUpdateProfilePicture": "无法更新个人资料图片。", @@ -1905,7 +1878,7 @@ "dropImagesBrowse": "拖放图片到这里,或点击浏览", "imageHint": "JPEG、PNG、WebP、GIF(每张最大20MB)", "uploadedImages": "已上传图片", - "previewAlt": "预览 {{index}}", + "previewAlt": "预览 {index}", "cancel": "取消", "submit": "提交", "submitting": "提交中...", @@ -1916,8 +1889,8 @@ "subjectRequired": "请填写主题", "messageRequired": "请填写消息内容", "requestTypeRequired": "请选择请求类型", - "fileTooLarge": "文件 {{name}} 过大,最大允许20MB。", - "unsupportedFormat": "文件 {{name}} 格式不支持,请使用 JPEG、PNG、WebP 或 GIF 格式。", + "fileTooLarge": "文件 {name} 过大,最大允许20MB。", + "unsupportedFormat": "文件 {name} 格式不支持,请使用 JPEG、PNG、WebP 或 GIF 格式。", "processing": "处理图片时出错,请重试。", "submitFailed": "提交帮助请求失败", "unknown": "发生未知错误" @@ -1955,7 +1928,7 @@ "custom": "自定义", "seats": "席位" }, - "seatsText": "{{count}} 个席位", + "seatsText": "{count} 个席位", "descriptions": { "manage": "打开 Stripe 结算门户以取消、恢复或更新您的订阅。", "usageNotifications": "当用量达到结算警告阈值时发送邮件通知我", @@ -1981,7 +1954,7 @@ "actions": { "manage": "管理", "contact": "联系", - "upgradeTo": "升级至{{name}}" + "upgradeTo": "升级至{name}" }, "badges": { "resolvePayment": "解决付款问题", @@ -2000,8 +1973,8 @@ }, "team": { "error": "错误", - "defaultTeamName": "{{name}} 的团队", - "billingHowWorksSeatCost": "{{seats}} 个席位每月花费 ${{amount}}。", + "defaultTeamName": "{name} 的团队", + "billingHowWorksSeatCost": "{seats} 个席位每月花费 ${amount}。", "billingHowWorksUsageTracked": "使用量在组织的所有工作空间中追踪。", "billingHowWorksIncreaseLimit": "当组织需要更多容量时,请提高限制。", "billingHowWorksOverage": "超额使用费根据当前活跃套餐计费。", @@ -2025,16 +1998,16 @@ "subscriptionMayNeedTransfer": "您的订阅可能需要转移到此组织。", "setUpTeamSubscription": "设置团队订阅", "seats": "席位", - "pricePerSeat": "(每席位每月{{price}})", - "used": "已使用 {{count}} 个", - "total": "总共 {{count}} 个", + "pricePerSeat": "(每席位每月{price})", + "used": "已使用 {count} 个", + "total": "总共 {count} 个", "removeSeat": "移除席位", "addSeat": "添加席位", "seat": "席位", "numberOfSeats": "席位数量", - "yourTeamWillHave": "您的团队将拥有 {{count}} 个{{seatWord}},每月共 ${{cost}} 推理积分。", - "minimumSeatsNoMax": "最少 {{minimum}} 个席位。此层级无最大席位上限。", - "chooseBetweenSeats": "请在此层级选择 {{minimum}} 到 {{maximum}} 个席位。", + "yourTeamWillHave": "您的团队将拥有 {count} 个{seatWord},每月共 ${cost} 推理积分。", + "minimumSeatsNoMax": "最少 {minimum} 个席位。此层级无最大席位上限。", + "chooseBetweenSeats": "请在此层级选择 {minimum} 到 {maximum} 个席位。", "currentSeats": "当前席位:", "newSeats": "新席位:", "monthlyCostChange": "月度费用变动:", @@ -2043,7 +2016,7 @@ "leaveOrganization": "退出组织", "removeTeamMember": "移除团队成员", "leaveOrganizationDescription": "确定要退出此组织吗?您将失去所有团队资源的访问权限。", - "removeMemberDescription": "确定要将 {{name}} 从团队中移除吗?", + "removeMemberDescription": "确定要将 {name} 从团队中移除吗?", "alsoReduceSeatCount": "同时减少订阅中的席位数量", "reduceSeatCountDescription": "如果选中,您的团队席位数量将减少 1,从而降低您的月度费用。", "thisActionCannotBeUndone": "此操作无法撤销。", @@ -2055,7 +2028,7 @@ "noPublicAdjustableTier": "未配置公开可调整的组织层级", "addSeats": { "title": "添加团队席位", - "description": "每个席位每月费用为 ${{price}},并提供 ${{price}} 的月度推理积分。调整团队许可席位数量。", + "description": "每个席位每月费用为 ${price},并提供 ${price} 的月度推理积分。调整团队许可席位数量。", "confirm": "更新席位" }, "billing": { @@ -2075,7 +2048,7 @@ "members": { "title": "团队成员", "empty": "暂无团队成员。", - "sharedUsage": "共享用量:${{amount}}", + "sharedUsage": "共享用量:${amount}", "pending": "待处理", "billing": "计费", "usage": "用量", @@ -2097,13 +2070,13 @@ "unavailable": "邀请不可用", "workspaceAccess": "工作区访问权限", "optional": "可选", - "selected": "已选择 {{count}} 个工作区", + "selected": "已选择 {count} 个工作区", "grantAccess": "授予特定工作区的访问权限并选择权限级别。", "noWorkspacesAvailable": "无可用工作区。", "needAdminAccess": "您需要管理员权限才能分配工作区。", "owner": "所有者", "sentSuccess": "邀请已发送。", - "sentSuccessWithAccess": "邀请已发送,授予 {{count}} 个工作区的访问权限。", + "sentSuccessWithAccess": "邀请已发送,授予 {count} 个工作区的访问权限。", "invalidEmail": "请输入有效的电子邮件地址。", "permissions": { "read": { @@ -2180,7 +2153,7 @@ "providerError": "配置 SSO 提供商失败", "reloadError": "重新加载 SSO 提供商失败", "validation": { - "fieldRequired": "{{field}} 为必填项。", + "fieldRequired": "{field} 为必填项。", "providerIdRequired": "提供商 ID 为必填项。", "providerIdPattern": "仅可使用字母、数字和短横线。", "issuerUrlRequired": "颁发者 URL 为必填项。", @@ -2235,11 +2208,11 @@ "webhook": { "common": { "configureButton": "配置 Webhook", - "connectedLabel": "{{provider}} 已连接", - "configureTitle": "配置 {{provider}} Webhook", - "editTitle": "编辑 {{provider}} Webhook", + "connectedLabel": "{provider} 已连接", + "configureTitle": "配置 {provider} Webhook", + "editTitle": "编辑 {provider} Webhook", "close": "关闭", - "learnMoreAbout": "了解更多关于{{topic}}", + "learnMoreAbout": "了解更多关于{topic}", "showSecret": "显示密钥", "hideSecret": "隐藏密钥", "copyValue": "复制值", @@ -2254,14 +2227,14 @@ "validConfiguration": "Webhook 配置有效", "testFailure": "Webhook 测试失败", "telegramSslError": "Telegram 要求使用具有有效 SSL 证书的公开可访问的 HTTPS URL。", - "telegramTestFailure": "Telegram Webhook 测试失败:{{error}}", + "telegramTestFailure": "Telegram Webhook 测试失败:{error}", "testError": "测试 Webhook 时发生意外错误", "testUrlLabel": "测试 Webhook URL", "testUrlHint": "使用此临时URL向你的工作流发送一个示例请求。", "generate": "生成", "generating": "正在生成...", "regenerate": "重新生成", - "expiresAt": "于{{timestamp}}过期", + "expiresAt": "于{timestamp}过期", "delete": "删除", "deleting": "正在删除...", "testWebhook": "测试webhook", @@ -2315,7 +2288,7 @@ "copyWebhookUrl": "复制Webhook URL。", "configureService": "将其粘贴到将要发送Webhook的服务中。", "includeBearerHeader": "在Authorization标头中携带生成的令牌。", - "includeNamedHeader": "在{{header}}标头中携带生成的令牌。" + "includeNamedHeader": "在{header}标头中携带生成的令牌。" } }, "github": { @@ -2347,7 +2320,7 @@ "addWebhook": "进入 Webhooks 页面,并点击添加 Webhook。", "pastePayloadUrlPrefix": "将", "pastePayloadUrlSuffix": "粘贴到“Payload URL”字段中。", - "selectContentType": "将内容类型设置为 {{contentType}}。", + "selectContentType": "将内容类型设置为 {contentType}。", "enterWebhookSecretPrefix": "将生成的密钥复制到", "enterWebhookSecretSuffix": "中。", "setSslVerification": "除非在受控环境中进行测试,否则请启用 SSL 验证。", @@ -2386,7 +2359,7 @@ "notice": { "payloadTitle": "Gmail 事件负载示例" }, - "fallbackLabels": { + "defaultLabels": { "inbox": "收件箱", "sent": "已发送", "important": "重要", @@ -2425,7 +2398,7 @@ "notice": { "payloadTitle": "Outlook 事件负载示例" }, - "fallbackFolders": { + "defaultFolders": { "inbox": "收件箱", "sentItems": "已发送邮件", "drafts": "草稿", @@ -2702,7 +2675,7 @@ "downloadConsoleCsv": "下载控制台 CSV", "downloadCsv": "下载 CSV", "clearConsole": "清除控制台", - "noResults": "未找到“{{query}}”的结果", + "noResults": "未找到“{query}”的结果", "showLess": "显示较少", "showMore": "显示更多", "copyValue": "复制值", @@ -2727,10 +2700,10 @@ "noConsoleEntries": "无控制台条目", "generatedImageAlt": "生成的图片", "downloadImageFailed": "下载图片失败,请稍后重试。", - "summaryItemsSingular": "{{count}} 项", - "summaryItemsPlural": "{{count}} 项", - "summaryKeysSingular": "{{count}} 个键", - "summaryKeysPlural": "{{count}} 个键" + "summaryItemsSingular": "{count} 项", + "summaryItemsPlural": "{count} 项", + "summaryKeysSingular": "{count} 个键", + "summaryKeysPlural": "{count} 个键" }, "workflowChat": { "selectWorkspace": "选择一个工作区以加载工作流。", @@ -2744,22 +2717,22 @@ "fileUploadError": "文件上传失败", "attachFiles": "附加文件", "attach": "附加", - "maximumFilesAllowed": "最多允许 {{maxFiles}} 个文件", - "selectedFiles": "{{count}}/{{maxFiles}} 个文件", + "maximumFilesAllowed": "最多允许 {maxFiles} 个文件", + "selectedFiles": "{count}/{maxFiles} 个文件", "removeFile": "删除文件", "dropFilesHere": "将文件拖拽到此处以附加", "typeMessage": "输入消息...", - "uploadedFiles": "已上传 {{count}} 个文件", - "fileTooLarge": "{{name}} 文件太大(最大 {{maxSize}}MB)", - "fileTypeNotSupported": "不支持 {{name}} 的文件类型", - "fileAlreadyAdded": "{{name}} 已添加", + "uploadedFiles": "已上传 {count} 个文件", + "fileTooLarge": "{name} 文件太大(最大 {maxSize}MB)", + "fileTypeNotSupported": "不支持 {name} 的文件类型", + "fileAlreadyAdded": "{name} 已添加", "workflowExecutionFailed": "工作流执行失败。", "errorPrefix": "错误:" }, "workflowOutputSelect": { "defaultPlaceholder": "选择输出源", - "fallbackBlockName": "块 {{id}}", - "selectedCount": "已选 {{count}} 项", + "defaultBlockName": "块 {id}", + "selectedCount": "已选 {count} 项", "searchPlaceholder": "搜索输出...", "noMatchingOutputs": "无匹配输出。", "noOutputsAvailable": "暂无可用输出。" @@ -2823,16 +2796,16 @@ "serverNameRequired": "服务器名称为必填项。", "failedToRefreshMcpServer": "无法刷新 MCP 服务器工具。", "failedToSaveMcpServer": "保存MCP服务器失败。", - "toolCount": "{{count}} 个工具", + "toolCount": "{count} 个工具", "loading": "加载中...", "connected": "已连接", "error": "错误", "draft": "草稿", "disconnected": "未连接", "unnamedServer": "未命名服务器", - "updated": "更新于 {{time}}", - "toolsRefreshed": "工具已于 {{time}} 刷新", - "lastConnected": "上次连接于 {{time}}", + "updated": "更新于 {time}", + "toolsRefreshed": "工具已于 {time} 刷新", + "lastConnected": "上次连接于 {time}", "lastError": "最后错误", "noSharedMcpServerSelected": "此颜色尚未选择共享 MCP 服务器。", "mcpServerNotFound": "未找到 MCP 服务器。", @@ -2840,12 +2813,12 @@ "serverNameIsRequired": "服务器名称为必填项。", "relativeTime": { "justNow": "刚刚", - "minutesAgo": "{{count}} 分钟前", - "hoursAgo": "{{count}} 小时前", - "daysAgo": "{{count}} 天前", - "weeksAgo": "{{count}} 周前", - "monthsAgo": "{{count}} 个月前", - "yearsAgo": "{{count}} 年前" + "minutesAgo": "{count} 分钟前", + "hoursAgo": "{count} 小时前", + "daysAgo": "{count} 天前", + "weeksAgo": "{count} 周前", + "monthsAgo": "{count} 个月前", + "yearsAgo": "{count} 年前" } }, "triggerList": { @@ -2854,7 +2827,7 @@ "searchPlaceholder": "搜索触发器", "openTriggerList": "点击添加触发器", "close": "关闭", - "noResults": "未找到与\"{{query}}\"匹配的结果" + "noResults": "未找到与\"{query}\"匹配的结果" }, "workflowToolbar": { "selectWorkspace": "选择工作区以浏览模块", @@ -2862,14 +2835,13 @@ "tools": "工具", "triggers": "触发器", "special": "特殊", - "browseLabel": "浏览{{label}}", - "searchPlaceholder": "搜索{{label}}…", - "noResults": "未找到{{label}}。" + "browseLabel": "浏览{label}", + "searchPlaceholder": "搜索{label}…", + "noResults": "未找到{label}。" }, "workflowLabels": { "systemPrompt": "系统提示词", "userPrompt": "用户提示词", - "model": "模型", "temperature": "温度", "Signal Briefing": "信号简报", "Indicator Monitor": "指标监控", @@ -2909,10 +2881,8 @@ "decisionRouter": "决策路由器", "increasePosition": "增加持仓", "reduceExposure": "降低敞口", - "apiKey": "API 密钥", "skills": "技能", "tools": "工具", - "url": "URL", "method": "方法", "queryParams": "查询参数", "headers": "标头", @@ -2928,7 +2898,6 @@ "reasoningEffort": "推理力度", "verbosity": "详细程度", "configured": "已配置", - "value": "值", "items": "项", "fields": "字段", "object": "对象", @@ -2949,41 +2918,22 @@ "nextStep": "下一步", "locked": "已锁定", "deployed": "已部署", - "deployedWithVersion": "已部署 (v{{version}})", + "deployedWithVersion": "已部署 (v{version})", "notDeployed": "未部署", "disabled": "已禁用", - "key": "键", "start": "开始", "end": "结束", - "removeSkill": "移除 {{name}}", + "removeSkill": "移除 {name}", "currentWorkflow": "当前工作流", "currentSkill": "当前技能", "currentTool": "当前工具", "currentIndicator": "当前指标", "currentMcpServer": "当前 MCP 服务器", - "task": "任务", - "Task": "任务", - "variables": "变量", - "Variables": "变量", - "startingUrl": "起始 URL", - "Starting URL": "起始 URL", - "outputSchema": "输出架构", - "Output Schema": "输出架构", - "anthropicApiKey": "Anthropic API 密钥", - "Anthropic API Key": "Anthropic API 密钥", "workflows": "工作流", "customTools": "自定义工具", "indicators": "指标", "mcpServers": "MCP 服务器", "allWorkflows": "所有工作流", - "contentToValidate": "待验证内容", - "enterContentToValidate": "输入待验证内容", - "validationType": "验证类型", - "validJson": "有效 JSON", - "regexMatch": "正则匹配", - "hallucinationCheck": "幻觉检查", - "piiDetection": "PII 检测", - "regexPattern": "正则模式", "operation": "操作", "id": "ID", "role": "角色", @@ -3001,12 +2951,6 @@ "enterMemoryIdentifierToDelete": "输入要删除的记忆标识符", "selectAgentRole": "选择智能体角色", "enterMessageContent": "输入消息内容", - "Enter the starting URL for the agent": "输入智能体的起始URL", - "enterTheStartingUrlForTheAgent": "输入智能体的起始URL", - "enterTheTaskOrGoalForTheAgentToAchieveReferenceVariablesUsingKeySyntax": "输入智能体要完成的任务或目标。使用 %key% 语法引用变量。", - "Enter your Anthropic API key": "输入您的 Anthropic API 密钥", - "enterYourAnthropicApiKey": "输入您的 Anthropic API 密钥", - "knowledgeBase": "知识库", "selectKnowledgeBase": "选择知识库", "selectKnowledgeBases": "选择知识库", "searchQuery": "搜索查询", @@ -3022,13 +2966,13 @@ "searchTeams": "搜索团队...", "searchKnowledgeBases": "搜索知识库...", "searchFiles": "搜索文件...", - "searchItems": "搜索 {{itemName}}...", - "loadingItems": "正在加载 {{itemName}}...", - "noItemsFound": "未找到{{itemName}}。", - "noItemsFoundInService": "在您的{{serviceName}}中未找到{{itemName}}。", - "connectProviderAccountToContinue": "关联 {{providerName}} 帐户以继续。", - "connectProviderAccount": "关联 {{providerName}} 帐户", - "openInProvider": "在 {{providerName}} 中打开", + "searchItems": "搜索 {itemName}...", + "loadingItems": "正在加载 {itemName}...", + "noItemsFound": "未找到{itemName}。", + "noItemsFoundInService": "在您的{serviceName}中未找到{itemName}。", + "connectProviderAccountToContinue": "关联 {providerName} 帐户以继续。", + "connectProviderAccount": "关联 {providerName} 帐户", + "openInProvider": "在 {providerName} 中打开", "openInDrive": "在 Drive 中打开", "openInConfluence": "在 Confluence 中打开", "openInJira": "在 Jira 中打开", @@ -3059,25 +3003,11 @@ "selectContact": "选择联系人", "selectItem": "选择项目", "selectATeamFirst": "请先选择团队", - "typeOrSelectModel": "输入或选择模型...", - "confidence": "置信度", "numberOfResults": "结果数量", - "numberOfChunksToRetrieve": "检索块数", - "enterYourApiKey": "输入您的 API 密钥", - "piiTypesToDetect": "要检测的 PII 类型", - "action": "操作", - "blockRequest": "阻止请求", - "maskPii": "遮盖 PII", - "language": "语言", - "english": "英语", - "spanish": "西班牙语", - "italian": "意大利语", - "polish": "波兰语", - "finnish": "芬兰语", "configurePiiTypes": "配置PII类型", "noneSelected": "未选择", "allSelected": "全选", - "selectedCount": "已选择 {{count}} 项", + "selectedCount": "已选择 {count} 项", "selectPiiTypesToDetect": "选择要检测的PII类型", "choosePiiTypesToDetect": "选择要检测和阻止的个人身份信息类型。", "selectAllEntities": "选择所有实体", @@ -3147,7 +3077,7 @@ "contacts": "联系人", "tagFilters": "标签过滤器", "noFilters": "无过滤器", - "filtersApplied": "已应用 {{count}} 个过滤器", + "filtersApplied": "已应用 {count} 个过滤器", "tagName": "标签名称", "selectTag": "选择标签", "tryDifferentSearchOrAccount": "请尝试其他搜索或账户。", @@ -3174,51 +3104,7 @@ "missingRequiredFields": "缺少必填字段", "saveConfigReturnedFalse": "保存配置返回 false", "anErrorOccurredWhileSaving": "保存时发生错误。", - "common": "常用", - "usa": "美国", - "uk": "英国", - "spain": "西班牙", - "italy": "意大利", - "poland": "波兰", - "singapore": "新加坡", - "australia": "澳大利亚", - "india": "印度", "other": "其他", - "personName": "姓名", - "emailAddress": "电子邮箱", - "phoneNumber": "电话号码", - "location": "位置", - "dateOrTime": "日期或时间", - "ipAddress": "IP 地址", - "creditCardNumber": "信用卡号", - "internationalBankAccountNumber": "国际银行账号 (IBAN)", - "cryptocurrencyWalletAddress": "加密货币钱包地址", - "medicalLicenseNumber": "医疗执照号码", - "nationalityReligionPoliticalGroup": "国籍/宗教/政治团体", - "usBankAccountNumber": "美国银行账号", - "usDriverLicenseNumber": "美国驾照号码", - "usIndividualTaxpayerIdentificationNumber": "美国个人纳税人识别号码 (ITIN)", - "usPassportNumber": "美国护照号码", - "usSocialSecurityNumber": "美国社会安全号码", - "ukNationalInsuranceNumber": "英国国民保险号码", - "ukNhsNumber": "英国NHS号码", - "spanishNifNumber": "西班牙NIF号码", - "spanishNieNumber": "西班牙NIE号码", - "italianFiscalCode": "意大利税号", - "italianDriverLicense": "意大利驾驶执照", - "italianIdentityCard": "意大利身份证", - "italianPassport": "意大利护照", - "polishPesel": "波兰PESEL", - "singaporeNricFin": "新加坡NRIC/FIN", - "australianBusinessNumber": "澳大利亚商业编号(ABN)", - "australianCompanyNumber": "澳大利亚公司编号(ACN)", - "australianTaxFileNumber": "澳大利亚税号(TFN)", - "australianMedicareNumber": "澳大利亚Medicare号码", - "indianAadhaar": "印度Aadhaar", - "indianPan": "印度PAN", - "indianVehicleRegistration": "印度车辆登记", - "indianVoterNumber": "印度选民号码", - "indianPassport": "印度护照", "Provider": "提供商", "Text": "文本", "Prompt": "提示", @@ -3268,7 +3154,6 @@ "Variable Assignments": "变量赋值", "Wait Amount": "等待时长", "Unit": "单位", - "enterJsonSchema": "输入 JSON 模式...", "describeTheAIAgentYouWantToCreate": "描述你想要创建的 AI 代理...", "enterSystemPrompt": "输入系统提示...", "enterContextOrUserMessage": "输入上下文或用户消息...", @@ -3309,7 +3194,15 @@ "eventPayloadExample": "事件负载示例", "webhookUrlWillBeGenerated": "Webhook URL 将自动生成", "setupInstructions": "设置说明", - "enabled": "已启用" + "enabled": "已启用", + "pleaseSelectSharePointCredentialsFirst": "请先选择 SharePoint 凭据", + "pleaseSelectMicrosoftPlannerCredentialsFirst": "请先选择 Microsoft Planner 凭据", + "pleaseEnterAPlanIdFirst": "请先输入计划 ID", + "pleaseSelectMicrosoftTeamsCredentialsFirst": "请先选择 Microsoft Teams 凭据", + "pleaseSelectWealthboxCredentialsFirst": "请先选择 Wealthbox 凭据", + "pleaseSelectGoogleDriveCredentialsFirst": "请先选择 Google Drive 凭据", + "url": "URL", + "value": "值" }, "blockEditor": { "blockNames": { @@ -3465,7 +3358,9 @@ "youtube": "YouTube", "zendesk": "Zendesk", "zep": "Zep", - "zoom": "Zoom" + "zoom": "Zoom", + "portfolio_detail": "投资组合详情", + "watchlist": "自选列表" }, "blockDescriptions": { "response": "发送结构化 API 响应", @@ -3618,7 +3513,9 @@ "youtube": "与 YouTube 视频、频道和播放列表交互", "zendesk": "在 Zendesk 中管理支持工单、用户和组织", "zep": "AI 智能体的长期记忆", - "zoom": "创建和管理 Zoom 会议及录制" + "zoom": "创建和管理 Zoom 会议及录制", + "portfolio_detail": "从所选券商账户获取完整的投资组合详情。", + "watchlist": "读取自选列表并添加或移除标的项目。" }, "blockLongDescriptions": { "response": "将响应集成到工作流中。可以构建或编辑结构化响应,并将其放入最终的工作流响应中。", @@ -3755,10 +3652,7 @@ }, "headers": { "title": "响应头", - "columns": [ - "键", - "值" - ], + "columns": ["键", "值"], "description": "要包含在响应中的其他 HTTP 头" } }, @@ -15031,11 +14925,6 @@ "title": "URL" } }, - "stagehand_agent": { - "variables": { - "title": "Variables" - } - }, "stripe": { "active": { "options": [ @@ -16998,7 +16887,7 @@ "title": "Featured 图片 ID" }, "file": { - "placeholder": "引用文件 from previous block (e.g., {{block_name.文件}})", + "placeholder": "引用前一个块的文件(例如 {{block_name.file}})", "title": "文件引用" }, "filename": { @@ -17997,6 +17886,299 @@ "waitingRoom": { "title": "等待 Room" } + }, + "stagehand_agent": { + "startUrl": { + "title": "起始 URL", + "placeholder": "输入智能体的起始URL" + }, + "task": { + "title": "任务", + "placeholder": "输入智能体要完成的任务或目标。使用 %key% 语法引用变量。" + }, + "variables": { + "title": "变量", + "columns": ["键", "值"] + }, + "apiKey": { + "title": "Anthropic API 密钥", + "placeholder": "输入您的 Anthropic API 密钥" + }, + "outputSchema": { + "title": "输出架构", + "placeholder": "输入 JSON 模式..." + } + }, + "guardrails": { + "input": { + "title": "待验证内容", + "placeholder": "输入待验证内容" + }, + "validationType": { + "title": "验证类型", + "options": [ + { + "id": "json", + "label": "有效 JSON" + }, + { + "id": "regex", + "label": "正则匹配" + }, + { + "id": "hallucination", + "label": "幻觉检查" + }, + { + "id": "pii", + "label": "PII 检测" + } + ] + }, + "regex": { + "title": "正则模式" + }, + "knowledgeBaseId": { + "title": "知识库", + "placeholder": "选择知识库" + }, + "model": { + "title": "模型", + "placeholder": "输入或选择模型..." + }, + "threshold": { + "title": "置信度" + }, + "topK": { + "title": "检索块数" + }, + "apiKey": { + "title": "API 密钥", + "placeholder": "输入您的 API 密钥" + }, + "piiEntityTypes": { + "title": "要检测的 PII 类型", + "options": [ + { + "id": "PERSON", + "label": "姓名", + "group": "常用" + }, + { + "id": "EMAIL_ADDRESS", + "label": "电子邮箱", + "group": "常用" + }, + { + "id": "PHONE_NUMBER", + "label": "电话号码", + "group": "常用" + }, + { + "id": "LOCATION", + "label": "位置", + "group": "常用" + }, + { + "id": "DATE_TIME", + "label": "日期或时间", + "group": "常用" + }, + { + "id": "IP_ADDRESS", + "label": "IP 地址", + "group": "常用" + }, + { + "id": "URL", + "label": "URL", + "group": "常用" + }, + { + "id": "CREDIT_CARD", + "label": "信用卡号", + "group": "常用" + }, + { + "id": "IBAN_CODE", + "label": "国际银行账号 (IBAN)", + "group": "常用" + }, + { + "id": "CRYPTO", + "label": "加密货币钱包地址", + "group": "常用" + }, + { + "id": "MEDICAL_LICENSE", + "label": "医疗执照号码", + "group": "常用" + }, + { + "id": "NRP", + "label": "国籍/宗教/政治团体", + "group": "常用" + }, + { + "id": "US_BANK_NUMBER", + "label": "美国银行账号", + "group": "美国" + }, + { + "id": "US_DRIVER_LICENSE", + "label": "美国驾照号码", + "group": "美国" + }, + { + "id": "US_ITIN", + "label": "美国个人纳税人识别号码 (ITIN)", + "group": "美国" + }, + { + "id": "US_PASSPORT", + "label": "美国护照号码", + "group": "美国" + }, + { + "id": "US_SSN", + "label": "美国社会安全号码", + "group": "美国" + }, + { + "id": "UK_NINO", + "label": "英国国民保险号码", + "group": "英国" + }, + { + "id": "UK_NHS", + "label": "英国NHS号码", + "group": "英国" + }, + { + "id": "ES_NIF", + "label": "西班牙NIF号码", + "group": "西班牙" + }, + { + "id": "ES_NIE", + "label": "西班牙NIE号码", + "group": "西班牙" + }, + { + "id": "IT_FISCAL_CODE", + "label": "意大利税号", + "group": "意大利" + }, + { + "id": "IT_DRIVER_LICENSE", + "label": "意大利驾驶执照", + "group": "意大利" + }, + { + "id": "IT_IDENTITY_CARD", + "label": "意大利身份证", + "group": "意大利" + }, + { + "id": "IT_PASSPORT", + "label": "意大利护照", + "group": "意大利" + }, + { + "id": "PL_PESEL", + "label": "波兰PESEL", + "group": "波兰" + }, + { + "id": "SG_NRIC_FIN", + "label": "新加坡NRIC/FIN", + "group": "新加坡" + }, + { + "id": "AU_ABN", + "label": "澳大利亚商业编号(ABN)", + "group": "澳大利亚" + }, + { + "id": "AU_ACN", + "label": "澳大利亚公司编号(ACN)", + "group": "澳大利亚" + }, + { + "id": "AU_TFN", + "label": "澳大利亚税号(TFN)", + "group": "澳大利亚" + }, + { + "id": "AU_MEDICARE", + "label": "澳大利亚Medicare号码", + "group": "澳大利亚" + }, + { + "id": "IN_AADHAAR", + "label": "印度Aadhaar", + "group": "印度" + }, + { + "id": "IN_PAN", + "label": "印度PAN", + "group": "印度" + }, + { + "id": "IN_VEHICLE_REGISTRATION", + "label": "印度车辆登记", + "group": "印度" + }, + { + "id": "IN_VOTER", + "label": "印度选民号码", + "group": "印度" + }, + { + "id": "IN_PASSPORT", + "label": "印度护照", + "group": "印度" + } + ] + }, + "piiMode": { + "title": "操作", + "options": [ + { + "id": "block", + "label": "阻止请求" + }, + { + "id": "mask", + "label": "遮盖 PII" + } + ] + }, + "piiLanguage": { + "title": "语言", + "options": [ + { + "id": "en", + "label": "英语" + }, + { + "id": "es", + "label": "西班牙语" + }, + { + "id": "it", + "label": "意大利语" + }, + { + "id": "pl", + "label": "波兰语" + }, + { + "id": "fi", + "label": "芬兰语" + } + ] + } } }, "variablesInput": { @@ -18008,7 +18190,7 @@ "noVariablesDefinedInWorkflow": "此工作流中未定义任何变量。", "addVariablesInPanel": "请在“变量”面板中添加。", "valueLabel": "值", - "typedValuePlaceholder": "{{type}} 值", + "typedValuePlaceholder": "{type} 值", "allVariablesAssignedButton": "所有变量均已分配", "addVariableAssignment": "添加变量赋值", "objectValuePlaceholder": "{\n \"key\": \"value\"\n}", @@ -18021,7 +18203,7 @@ "selectedWorkflowNeedsInputTrigger": "所选工作流需要一个已定义字段的输入触发器" }, "evalInput": { - "metricLabel": "指标 {{index}}", + "metricLabel": "指标 {index}", "addMetric": "添加指标", "deleteMetric": "删除指标", "name": "名称", @@ -18032,7 +18214,7 @@ "maxValue": "最大值" }, "inputFormat": { - "addTitle": "添加{{title}}", + "addTitle": "添加{title}", "deleteField": "删除字段", "name": "名称", "type": "类型", @@ -18112,12 +18294,12 @@ }, "oauthRequiredModal": { "additionalAccessRequired": "需要额外访问权限", - "toolRequiresAccess": "“{{toolName}}”工具需要访问你的 {{providerName}} 账户才能正常工作。", - "connectProvider": "连接 {{providerName}}", - "connectProviderDescription": "你需要连接 {{providerName}} 账户才能继续", + "toolRequiresAccess": "“{toolName}”工具需要访问你的 {providerName} 账户才能正常工作。", + "connectProvider": "连接 {providerName}", + "connectProviderDescription": "你需要连接 {providerName} 账户才能继续", "permissionsRequested": "请求的权限", "cancel": "取消", - "connectService": "连接 {{serviceName}}", + "connectService": "连接 {serviceName}", "connectNow": "立即连接" }, "dropdown": { @@ -18179,19 +18361,19 @@ "errors": { "failedToFetchDocuments": "无法获取文档" }, - "chunkCountSingular": "{{count}} 个分块", - "chunkCountPlural": "{{count}} 个分块" + "chunkCountSingular": "{count} 个分块", + "chunkCountPlural": "{count} 个分块" }, "knowledgeTagFilters": { "addFilter": "添加筛选条件", - "appliedCountSingular": "已应用 {{count}} 个筛选条件", - "appliedCountPlural": "已应用 {{count}} 个筛选条件" + "appliedCountSingular": "已应用 {count} 个筛选条件", + "appliedCountPlural": "已应用 {count} 个筛选条件" }, "documentTagEntry": { "typeText": "文本", "prefillExistingTags": "预填充现有标签", "addTag": "添加标签", - "tagSlotsUsed": "已使用 {{used}} / {{total}} 个标签槽位" + "tagSlotsUsed": "已使用 {used} / {total} 个标签槽位" }, "confluenceFileSelector": { "errors": { @@ -18274,9 +18456,9 @@ "addTool": "添加工具", "searchTools": "搜索工具...", "account": "账户", - "selectProviderAccount": "选择 {{provider}} 账户", - "selectParameter": "选择 {{label}}", - "enterParameter": "输入 {{label}}", + "selectProviderAccount": "选择 {provider} 账户", + "selectParameter": "选择 {label}", + "enterParameter": "输入 {label}", "enterJsonArrayOrCommaSeparatedValues": "输入 JSON 数组,例如 [\"item1\", \"item2\"] 或逗号分隔的值", "selectToolToConfigureParameters": "选择一个工具以配置其参数", "loadingToolSchema": "正在加载工具架构...", @@ -18308,8 +18490,8 @@ "unsupportedValue": "不支持的值" }, "triggerWarning": { - "duplicateTitle": "只允许一个 {{triggerName}} 触发器", - "duplicateDescription": "一个工作流只能有一个 {{triggerName}} 触发器块。请先删除已有的触发器,再添加新的。", + "duplicateTitle": "只允许一个 {triggerName} 触发器", + "duplicateDescription": "一个工作流只能有一个 {triggerName} 触发器块。请先删除已有的触发器,再添加新的。", "dismiss": "知道了" }, "templateModal": { @@ -18421,8 +18603,8 @@ "title": "Webhook 通知", "searchPlaceholder": "搜索 Webhook...", "emptyState": "点击下方“添加 Webhook”开始", - "webhookLabel": "Webhook {{index}}", - "noSearchMatches": "未找到匹配“{{query}}”的 Webhook", + "webhookLabel": "Webhook {index}", + "noSearchMatches": "未找到匹配“{query}”的 Webhook", "actions": { "copyUrl": "复制 Webhook URL", "test": "测试 Webhook", @@ -18527,7 +18709,7 @@ "validationFailed": "验证失败。请检查 Webhook 设置后重试。" }, "testStatus": { - "success": "测试 Webhook 发送成功({{status}})", + "success": "测试 Webhook 发送成功({status})", "failure": "测试 Webhook 失败。", "sendFailed": "无法发送测试 Webhook" }, @@ -20147,7 +20329,7 @@ "selectBlockToViewPreviewDetails": "选择一个块以查看其预览详情。", "nodeNotFound": "未找到节点", "selectedNodeUnavailable": "所选节点已不可用。", - "missingBlockConfiguration": "缺少 `{{type}}` 的块配置。", + "missingBlockConfiguration": "缺少 `{type}` 的块配置。", "controlsUnavailable": "控件不可用", "saveName": "保存名称", "renameNode": "重命名节点", @@ -20169,7 +20351,7 @@ "collectionItems": "集合项", "collectionItemsPlaceholder": "['item1', 'item2', 'item3']", "parallelItems": "并行项", - "enterValueBetween": "请输入 1 到 {{max}} 之间的值", + "enterValueBetween": "请输入 1 到 {max} 之间的值", "hideAdditionalFields": "隐藏附加字段", "showAdditionalFields": "显示附加字段", "additionalFields": "附加字段", @@ -20177,7 +20359,7 @@ "blockNoEditableFields": "此块没有可编辑字段。", "requiredField": "此字段为必填项", "invalidJson": "JSON 无效", - "unknownInputType": "未知输入类型:{{type}}", + "unknownInputType": "未知输入类型:{type}", "loop": "循环", "parallel": "并行", "start": "开始", @@ -20194,7 +20376,7 @@ "checkingWorkflowPermissions": "正在检查工作流权限", "writePermissionRequiredToRunWorkflows": "运行工作流需要写入权限", "usageLimitExceeded": "已超出使用限制", - "usageLimitExceededDescription": "你已使用 {{currentUsage}}$ / {{limit}}$。请升级方案以继续。", + "usageLimitExceededDescription": "你已使用 {currentUsage}$ / {limit}$。请升级方案以继续。", "run": "运行" }, "floatingControls": { @@ -20212,7 +20394,7 @@ }, "summary": { "objectItem": "[对象]", - "additionalCount": "+{{count}} 更多" + "additionalCount": "+{count} 更多" }, "selectWorkspaceToLoadWorkflows": "选择一个工作区以加载工作流。", "noWorkflowsAvailable": "此工作区没有可用的工作流。", @@ -20232,7 +20414,7 @@ "close": "关闭", "coreTriggers": "核心触发器", "integrationTriggers": "集成触发器", - "noResultsFound": "未找到“{{query}}”的结果" + "noResultsFound": "未找到“{query}”的结果" }, "connectionStatus": { "reconnected": "已重新连接", @@ -20241,8 +20423,8 @@ "refreshPageToContinueEditing": "刷新页面以继续编辑" }, "triggerWarning": { - "duplicateTitle": "仅允许一个 {{triggerName}} 触发器", - "duplicateDescription": "一个工作流只能有一个 {{triggerName}} 触发器块。请在添加新的之前移除现有的。", + "duplicateTitle": "仅允许一个 {triggerName} 触发器", + "duplicateDescription": "一个工作流只能有一个 {triggerName} 触发器块。请在添加新的之前移除现有的。", "dismiss": "知道了" } }, @@ -20299,7 +20481,7 @@ "deployApi": "部署 API", "needsRedeployment": "需要重新部署", "deployWorkflowTitle": "部署工作流", - "deployVersion": "部署 {{versionName}}", + "deployVersion": "部署 {versionName}", "active": "已激活", "inactive": "未激活", "close": "关闭", @@ -20337,7 +20519,7 @@ "reviewTriggerBeforeDeployment": "在部署前检查此触发器。无需额外配置。", "triggerConfigurationUnavailable": "触发器配置不可用。", "noAdditionalTriggerConfigurationRequired": "此触发器会随工作流一起部署,无需额外配置。", - "completeRequiredFieldsBeforeDeploying": "部署前请先完成必填字段:{{fields}}。", + "completeRequiredFieldsBeforeDeploying": "部署前请先完成必填字段:{fields}。", "saveTriggerConfigurationBeforeDeploying": "请先保存此触发器配置以在部署前创建其 Webhook。", "triggerSettingsChangedSinceLastSave": "触发器设置自上次保存后已更改。请在部署前先保存触发器。", "triggerConfigurationReady": "触发器配置看起来已准备就绪。请在部署前检查下方的值。", @@ -20391,7 +20573,7 @@ "failedToSavePassword": "无法保存聊天密码", "auth": { "accessControl": "访问控制", - "selectAccessAriaLabel": "选择{{type}}访问方式", + "selectAccessAriaLabel": "选择{type}访问方式", "publicAccess": "公开访问", "publicAccessDescription": "任何人都可以访问你的聊天", "passwordProtected": "密码保护", @@ -20440,8 +20622,8 @@ "tooltip": "选择市场数据提供商", "selectionUnavailable": "提供商选择不可用", "noProviders": "没有提供商", - "selectedLabel": "市场:{{providerName}}", - "fallbackProviderName": "市场" + "selectedLabel": "市场:{providerName}", + "defaultProviderName": "市场" }, "tradingSelector": { "placeholder": "选择交易提供商", @@ -20449,8 +20631,8 @@ "tooltip": "选择券商", "selectionUnavailable": "提供商选择不可用", "noProviders": "没有提供商", - "selectedLabel": "券商:{{providerName}}", - "fallbackProviderName": "券商" + "selectedLabel": "券商:{providerName}", + "defaultProviderName": "券商" }, "accountSelector": { "placeholder": "选择账户", @@ -20459,24 +20641,24 @@ "loadingAccount": "正在加载账户...", "loadingProviderConnection": "正在加载提供商连接...", "unableToLoadProviderConnection": "无法加载提供商连接。", - "selectConnection": "选择一个 {{providerName}} 连接。", - "noAccountConnected": "没有连接的 {{providerName}} 账户。", + "selectConnection": "选择一个 {providerName} 连接。", + "noAccountConnected": "没有连接的 {providerName} 账户。", "loadingBrokerAccounts": "正在加载券商账户...", "unableToLoadBrokerAccounts": "无法加载券商账户。", "noBrokerAccountsFound": "未找到券商账户。", - "reconnectAccount": "重新连接 {{providerName}} 账户", - "connectAccount": "连接 {{providerName}} 账户", - "fallbackProviderName": "券商" + "reconnectAccount": "重新连接 {providerName} 账户", + "connectAccount": "连接 {providerName} 账户", + "defaultProviderName": "券商" }, "settingsButton": { - "triggerLabel": "配置 {{providerName}} 提供商", + "triggerLabel": "配置 {providerName} 提供商", "triggerTooltip": "提供商设置", "title": "提供商设置", "description": "为此小组件保存凭据。", "select": "选择", "save": "保存", "cancel": "取消", - "fallbackProviderName": "市场" + "defaultProviderName": "市场" } }, "watchlist": { @@ -20502,10 +20684,10 @@ "selectLabel": "选择 watchlist", "searchPlaceholder": "搜索 watchlists...", "noWatchlistsFound": "未找到 watchlists。", - "renameAriaLabel": "重命名 {{name}}", - "deleteAriaLabel": "删除 {{name}}", + "renameAriaLabel": "重命名 {name}", + "deleteAriaLabel": "删除 {name}", "deleteDialogTitle": "删除 watchlist?", - "deleteDialogDescription": "此操作将永久删除“{{name}}”。", + "deleteDialogDescription": "此操作将永久删除“{name}”。", "deleteDialogDescriptionFallback": "此操作将永久删除此 watchlist。", "cancel": "取消", "delete": "删除" @@ -20529,7 +20711,7 @@ "collapseSection": "折叠分区", "expandSection": "展开分区", "deleteSymbolDialogTitle": "删除符号?", - "deleteSymbolDialogDescription": "移除 {{name}} 将把它从 watchlist 中删除。", + "deleteSymbolDialogDescription": "移除 {name} 将把它从 watchlist 中删除。", "deleteSymbolDialogDescriptionFallback": "移除此符号将把它从 watchlist 中删除。", "deleteSymbolDialogDescriptionHighlight": "此操作无法撤销。", "deleteSectionDialogTitle": "删除分区?", @@ -20583,10 +20765,10 @@ }, "validation": { "nameRequired": "Skill 名称是必填项。", - "nameTooLong": "Skill 名称必须少于 {{max}} 个字符。", + "nameTooLong": "Skill 名称必须少于 {max} 个字符。", "descriptionRequired": "Skill 描述是必填项。", "contentRequired": "Skill 内容是必填项。", - "duplicateName": "已存在名为“{{name}}”的 skill。", + "duplicateName": "已存在名为“{name}”的 skill。", "saveFailed": "无法保存 skill。" }, "form": { @@ -20746,7 +20928,7 @@ "schemaMustBeFunctionType": "Schema 必须有值为 \"function\" 的 \"type\" 字段", "schemaMustHaveFunctionName": "Schema 必须有一个带 \"name\" 字段的 \"function\" 对象", "failedToValidateSchema": "无法验证自定义工具 schema,请检查输入后重试。", - "duplicateName": "已存在名为 \"{{name}}\" 的工具", + "duplicateName": "已存在名为 \"{name}\" 的工具", "failedToSave": "无法保存自定义工具,请检查输入后重试。" }, "form": { @@ -20822,10 +21004,10 @@ "refreshingQuotes": "正在刷新报价", "noHoldingsWithMarketListings": "没有带市场列表的持仓", "noMarketProvider": "没有市场提供商", - "quoteMetricsUseFirst": "报价指标仅使用前 {{cap}} 个中的 {{total}} 个持仓", - "quotedPositionsSummary": "{{quoted}}/{{total}} 个已报价", + "quoteMetricsUseFirst": "报价指标仅使用前 {cap} 个中的 {total} 个持仓", + "quotedPositionsSummary": "{quoted}/{total} 个已报价", "performanceHistoryUnavailable": "所选账户没有可用的绩效历史。", - "asOf": "截至 {{date}}", + "asOf": "截至 {date}", "return": "回报", "start": "开始", "current": "当前", @@ -20847,7 +21029,7 @@ "orderSubmitted": "订单已提交", "submitting": "提交中...", "orderPrefix": "订单", - "submitOrder": "提交 {{side}} 订单", + "submitOrder": "提交 {side} 订单", "unknown": "未知" } }, @@ -20897,15 +21079,15 @@ "selectTimeInForce": "选择有效期限。", "selectOrderSize": "选择订单数量。", "notionalSizingIsNotSupportedForThisOrderType": "该订单类型不支持名义金额下单。", - "notionalSizingRequires": "名义金额下单需要 {{values}}。", - "fieldNotSupportedForThisOrderType": "{{field}} 不支持该订单类型。", - "enterValid": "请输入有效的 {{field}}。", - "oneOfFieldsRequired": "需要 {{fields}}。", + "notionalSizingRequires": "名义金额下单需要 {values}。", + "fieldNotSupportedForThisOrderType": "{field} 不支持该订单类型。", + "enterValid": "请输入有效的 {field}。", + "oneOfFieldsRequired": "需要 {fields}。", "marketPrice": "市价", "notional": "名义金额", "quantity": "数量", "orderType": "订单类型", - "chooseHowTo": "选择如何 {{side}}", + "chooseHowTo": "选择如何 {side}", "timeInForce": "有效期限", "limitPrice": "限价", "stopPrice": "止损价", @@ -20923,8 +21105,8 @@ "orderSubmitted": "订单已提交", "submitting": "提交中...", "orderPrefix": "订单", - "sideLabel": "{{side}}", - "submitOrder": "提交 {{side}} 订单" + "sideLabel": "{side}", + "submitOrder": "提交 {side} 订单" } }, "dataChart": { @@ -20964,7 +21146,7 @@ "timezone": { "exchange": "交易所", "utc": "UTC", - "tooltip": "时区:{{timezone}}", + "tooltip": "时区:{timezone}", "tooltipFallback": "交易所时区", "searchPlaceholder": "搜索时区...", "loading": "正在加载时区...", @@ -20972,7 +21154,7 @@ }, "range": { "allAvailableData": "全部可用数据", - "rangeIntervalTooltip": "{{range}},周期 {{interval}}", + "rangeIntervalTooltip": "{range},周期 {interval}", "presets": { "1d": "1日", "5d": "5日", @@ -21012,7 +21194,7 @@ }, "normalization": { "ariaLabel": "复权", - "tooltip": "复权:{{mode}}", + "tooltip": "复权:{mode}", "unavailable": "复权不可用", "noOptions": "没有复权选项。", "modes": { @@ -21030,7 +21212,7 @@ "freehand": "手绘工具", "shapes": "形状工具" }, - "unavailable": "{{tool}} 在本会话中不可用", + "unavailable": "{tool} 在本会话中不可用", "tools": { "TrendLine": "趋势线", "Ray": "射线", @@ -21075,14 +21257,14 @@ "removeIndicator": "移除指标", "remove": "移除", "errorTitle": "指标错误", - "compileFailed": "{{name}} 编译失败。", + "compileFailed": "{name} 编译失败。", "errorGuidance": "检查指标输入或脚本,然后重试。", "settingsSubtitle": "指标设置", "close": "关闭", "noConfigurableInputs": "没有可配置输入。", "cancel": "取消", "save": "保存", - "plotFallback": "绘图 {{index}}", + "plotFallback": "绘图 {index}", "executionErrorFallback": "指标执行失败", "metadataLabels": { "Length": "长度", @@ -21105,7 +21287,7 @@ "close": "收:" }, "listingOverlay": { - "flagAlt": "{{countryCode}} 国旗" + "flagAlt": "{countryCode} 国旗" }, "errors": { "failedToLoadSeriesData": "序列数据加载失败", @@ -21140,7 +21322,7 @@ "label": "标的", "listingFallback": "标的", "selectListing": "选择标的", - "flagAlt": "{{countryCode}} 国旗", + "flagAlt": "{countryCode} 国旗", "searching": "正在搜索...", "noListingsFound": "未找到标的。", "searchPlaceholder": "搜索标的..." @@ -21148,8 +21330,8 @@ }, "layoutTabs": { "createNewLayout": "创建新布局", - "renameAriaLabel": "重命名 {{name}}", - "deleteAriaLabel": "删除 {{name}}" + "renameAriaLabel": "重命名 {name}", + "deleteAriaLabel": "删除 {name}" }, "monitor": { "title": "监控", @@ -21180,7 +21362,7 @@ "errors": { "activateView": "无法激活视图", "configViewsUnavailable": "配置视图当前不可用。", - "createDefaultView": "无法创建默认的 {{name}} 视图。", + "createDefaultView": "无法创建默认的 {name} 视图。", "createMonitor": "无法创建监控", "createView": "无法创建视图", "deleteMonitor": "无法删除监控", @@ -21282,13 +21464,13 @@ "today": "今天", "boundaries": "边界", "todayAndBoundaries": "今天 + 边界", - "shownCount": "显示 {{count}} 项", - "itemsCount": "{{count}} 项", - "executionsCount": "{{count}} 次执行", - "setCount": "已设置 {{count}} 项", + "shownCount": "显示 {count} 项", + "itemsCount": "{count} 项", + "executionsCount": "{count} 次执行", + "setCount": "已设置 {count} 项", "all": "全部", "allExecutions": "全部执行", - "monitorsCount": "{{count}} 个监控", + "monitorsCount": "{count} 个监控", "monitorControls": "监控控件" }, "execution": { @@ -21302,7 +21484,7 @@ "closeInspector": "关闭检查器", "kanban": "看板", "timeline": "时间线", - "limitLabel": "限制 {{count}}", + "limitLabel": "限制 {count}", "running": "运行中", "unknown": "未知", "unknownListing": "未知标的", @@ -21322,7 +21504,7 @@ "loadingRecords": "正在加载监控记录...", "noExecutions": "无执行记录", "noOutcome": "无结果", - "addMonitorIn": "在 {{title}} 中添加监控" + "addMonitorIn": "在 {title} 中添加监控" }, "configSearch": { "activeMonitors": "启用的监控", @@ -21331,7 +21513,7 @@ "hasLastExecutionLog": "有最近执行日志", "hasLastOutcome": "有最近结果", "invalidTokensPrefix": "无效的配置查询标记", - "lastOutcome": "最近结果:{{outcome}}", + "lastOutcome": "最近结果:{outcome}", "noLastExecution": "无最近执行", "noLastExecutionLog": "无最近执行日志", "noLastOutcome": "无最近结果", @@ -21346,7 +21528,7 @@ "loading": "正在加载时区...", "placeholder": "UTC", "searchPlaceholder": "搜索时区...", - "triggerLabel": "时区:{{label}}" + "triggerLabel": "时区:{label}" }, "editor": { "createTitle": "创建监控", @@ -21394,7 +21576,7 @@ "scaleLabel": "缩放", "scaleAriaLabel": "时间线缩放", "searchZoomLevels": "搜索缩放级别...", - "zoomLabel": "缩放:{{zoom}}", + "zoomLabel": "缩放:{zoom}", "scrollPrevious": "滚动到上一个日期范围", "scrollNext": "滚动到下一个日期范围", "groupsTitle": "分组", @@ -21413,9 +21595,9 @@ "navigationAriaLabel": "聊天页眉", "titleFallback": "TradingGoose 聊天", "brandName": "TradingGoose", - "logoAlt": "{{title}} 徽标", - "githubRepositoryAriaLabel": "在 GitHub 上查看 TradingGoose 仓库,拥有 {{stars}} 颗星", - "homeAriaLabel": "前往 {{brand}} 首页" + "logoAlt": "{title} 徽标", + "githubRepositoryAriaLabel": "在 GitHub 上查看 TradingGoose 仓库,拥有 {stars} 颗星", + "homeAriaLabel": "前往 {brand} 首页" }, "error": { "title": "聊天不可用", @@ -21453,8 +21635,8 @@ "startVoiceConversation": "开始语音对话", "send": "发送", "stop": "停止", - "fileTooLarge": "{{name}} 过大。", - "fileAlreadyAdded": "{{name}} 已添加。" + "fileTooLarge": "{name} 过大。", + "fileAlreadyAdded": "{name} 已添加。" }, "auth": { "password": { @@ -21480,7 +21662,7 @@ "title": "输入你的邮箱", "verifyTitle": "验证你的邮箱", "description": "此聊天使用邮箱验证。", - "verifiedDescription": "我们已向 {{email}} 发送验证码。", + "verifiedDescription": "我们已向 {email} 发送验证码。", "label": "电子邮件", "placeholder": "输入你的邮箱", "submit": "发送验证码", @@ -21489,7 +21671,7 @@ "verifying": "验证中...", "instructions": "输入我们发送给你的 6 位验证码。", "resendPrompt": "没有收到验证码?", - "resendIn": "{{countdown}} 秒后重新发送", + "resendIn": "{countdown} 秒后重新发送", "resend": "重新发送验证码", "changeEmail": "更改邮箱", "validation": { @@ -21621,7 +21803,7 @@ "searchPlaceholder": "搜索候补名单条目...", "mode": "模式", "loading": "正在加载注册设置...", - "selectedCount": "已选择 {{count}} 项", + "selectedCount": "已选择 {count} 项", "submitted": "提交时间", "timeRanges": { "all": "全部时间", @@ -21651,7 +21833,7 @@ }, "emptyState": "没有候补名单条目匹配当前搜索。", "selectVisible": "选择可见的候补名单条目", - "selectEntry": "选择 {{email}}", + "selectEntry": "选择 {email}", "never": "从未", "error": "发生错误", "modes": { @@ -21683,8 +21865,8 @@ "title": "设置", "description": "为此服务配置运行时行为和端点默认值。", "none": "此服务不提供存储设置。", - "storedValue": "已存储值:{{value}}", - "defaultValue": "默认值:{{value}}", + "storedValue": "已存储值:{value}", + "defaultValue": "默认值:{value}", "notConfigured": "未配置", "notSet": "未设置", "enabled": "已启用", @@ -21696,21 +21878,21 @@ "optional": "可选" }, "placeholders": { - "enterValue": "输入 {{label}}", - "replaceValue": "输入新的 {{label}} 以替换已存储的值" + "enterValue": "输入 {label}", + "replaceValue": "输入新的 {label} 以替换已存储的值" }, "actions": { - "saveField": "保存 {{label}}", - "cancelEditingField": "取消编辑 {{label}}", - "editField": "编辑 {{label}}", - "clearField": "清除 {{label}}" + "saveField": "保存 {label}", + "cancelEditingField": "取消编辑 {label}", + "editField": "编辑 {label}", + "clearField": "清除 {label}" }, "summary": { - "requiredCredentialsSet": "已设置 {{configured}}/{{total}} 个必填凭据", + "requiredCredentialsSet": "已设置 {configured}/{total} 个必填凭据", "noRequiredCredentials": "没有必填凭据", - "requiredSettingsResolved": "已解决 {{configured}}/{{total}} 个必填设置", + "requiredSettingsResolved": "已解决 {configured}/{total} 个必填设置", "noRequiredSettings": "没有必填设置", - "missing": "缺少 {{labels}}。" + "missing": "缺少 {labels}。" }, "footer": { "saving": "正在保存更改...", @@ -21740,29 +21922,29 @@ "description": "为该系统管理的 OAuth 提供商设置密钥。", "none": "此提供商不需要存储凭据。", "noMatches": "没有凭据匹配当前搜索。", - "fallbackDescription": "提供商凭据" + "defaultDescription": "提供商凭据" }, "services": { "title": "OAuth 服务", "description": "启用或禁用继承此提供商凭据的服务。", "none": "此提供商不提供任何 OAuth 服务。", "noMatches": "没有服务匹配当前搜索。", - "inheritsFrom": "继承自 {{name}} 的凭据。" + "inheritsFrom": "继承自 {name} 的凭据。" }, "placeholders": { - "enterValue": "输入 {{label}}", - "replaceValue": "输入新的 {{label}} 以替换已存储的值" + "enterValue": "输入 {label}", + "replaceValue": "输入新的 {label} 以替换已存储的值" }, "summary": { - "serviceCount": "{{count}} 个服务{{plural}}", + "serviceCount": "{count} 个服务{plural}", "servicePlural": "s", - "requiredCredentialsSet": "已设置 {{configured}}/{{total}} 个必填凭据", - "credentialsSet": "已设置 {{count}} 个凭据{{plural}}", + "requiredCredentialsSet": "已设置 {configured}/{total} 个必填凭据", + "credentialsSet": "已设置 {count} 个凭据{plural}", "credentialPlural": "s", "noRequiredCredentials": "没有必填凭据", - "enabledCount": "已启用 {{count}} 个", + "enabledCount": "已启用 {count} 个", "noServicesAvailable": "没有可用服务", - "missing": "缺少 {{labels}}。" + "missing": "缺少 {labels}。" }, "error": "发生错误" }, @@ -21788,21 +21970,21 @@ "organization": "组织", "userOwner": "用户所有者", "organizationOwner": "组织所有者", - "ownerLabel": "{{owner}}所有者" + "ownerLabel": "{owner}所有者" }, "usageScopes": { "individual": "独立", "pooled": "共享", "individualUsage": "独立使用", "pooledUsage": "共享使用", - "usageLabel": "{{scope}}使用" + "usageLabel": "{scope}使用" }, "seatModes": { "fixed": "固定", "adjustable": "可调整", "fixedSeats": "固定席位", "adjustableSeats": "可调整席位", - "seatBillingLabel": "{{mode}}席位计费" + "seatBillingLabel": "{mode}席位计费" }, "commerce": { "custom": "自定义", @@ -21810,23 +21992,23 @@ "selfServe": "自助购买", "contactSales": "联系销售", "priceUnset": "价格未设置", - "monthlyPrice": "{{amount}} / 月", - "yearlyPrice": "{{amount}} / 年", - "stripeLinks": "{{count}}/3 个 Stripe 链接", - "usdIncluded": "含 {{value}} 美元", - "gbStorage": "{{value}} GB 存储", - "concurrent": "{{value}} 并发", - "includedUsageLabel": "含 {{amount}}", - "storageLimitLabel": "{{value}} GB 存储", - "concurrencyLabel": "{{value}} 并发", - "workflowExecutionLabel": "{{value}}x 工作流执行", - "workflowModelsLabel": "{{value}}x 工作流模型", - "functionRuntimeLabel": "{{value}}x 函数运行时", - "copilotLabel": "{{value}}x Copilot", + "monthlyPrice": "{amount} / 月", + "yearlyPrice": "{amount} / 年", + "stripeLinks": "{count}/3 个 Stripe 链接", + "usdIncluded": "含 {value} 美元", + "gbStorage": "{value} GB 存储", + "concurrent": "{value} 并发", + "includedUsageLabel": "含 {amount}", + "storageLimitLabel": "{value} GB 存储", + "concurrencyLabel": "{value} 并发", + "workflowExecutionLabel": "{value}x 工作流执行", + "workflowModelsLabel": "{value}x 工作流模型", + "functionRuntimeLabel": "{value}x 函数运行时", + "copilotLabel": "{value}x Copilot", "seatCountUnset": "席位数量未设置", - "fixedSeatsCount": "{{count}} 个固定席位", - "baseSeatsCount": "{{count}} 个基础席位", - "maxSeatsCount": "{{count}} 个最大席位", + "fixedSeatsCount": "{count} 个固定席位", + "baseSeatsCount": "{count} 个基础席位", + "maxSeatsCount": "{count} 个最大席位", "noSelfServeSeatChanges": "不支持自助调整席位", "unlimitedSeats": "无限席位" }, @@ -21834,7 +22016,7 @@ "searchPlaceholder": "搜索层级...", "createTier": "创建层级", "loadingInventory": "正在加载计费目录...", - "subscriptionCount": "{{count}} 个订阅", + "subscriptionCount": "{count} 个订阅", "currentTiersTitle": "当前层级", "currentTiersDescription": "打开层级以更新价格、可用性、客户限制和包含用量。", "emptyTitle": "创建你的第一个计费层级", @@ -21848,8 +22030,8 @@ "default": "默认", "notSet": "未设置", "rates": "费率", - "workflowRunRate": "工作流/次 ${{amount}}", - "functionSecondRate": "函数/秒 ${{amount}}" + "workflowRunRate": "工作流/次 ${amount}", + "functionSecondRate": "函数/秒 ${amount}" } }, "create": { @@ -22029,10 +22211,10 @@ "copilotCostMultiplierBlank": "默认 1x 可留空。" }, "summaries": { - "missing": "缺少:{{items}}", + "missing": "缺少:{items}", "untitledTier": "未命名层级", "noPricingBullets": "没有定价要点", - "pricingBullets": "{{count}} 个定价要点", + "pricingBullets": "{count} 个定价要点", "defaultTierMustBePublic": "默认层级必须是公开的", "defaultTierMustBePublicPlan": "默认层级必须是公开的用户计划,并使用独立用量和固定席位", "userTiersIndividualUsage": "用户层级必须使用独立用量", @@ -22060,7 +22242,7 @@ "userTiersNoOrgSeats": "用户层级不管理组织席位", "seatMaxAboveCount": "最大席位数必须大于席位数量", "noLimitsConfigured": "未配置包含用量、存储、并发、速率或保留限制", - "limitsConfigured": "{{count}}/7 项限制已配置", + "limitsConfigured": "{count}/7 项限制已配置", "usingBasePricingOnly": "仅使用平台基础定价" } }, @@ -22073,84 +22255,84 @@ "emails": { "shared": { "tagline": "LLM 技术交易分析工作流系统", - "team": "{{brandName}} 团队", - "openBrand": "打开 {{brandName}}", + "team": "{brandName} 团队", + "openBrand": "打开 {brandName}", "expires15": "此代码将在 15 分钟后过期。", "expires24": "此链接在接下来的 24 小时内有效。", "expires48": "出于安全考虑,邀请将在 48 小时后过期。", "expires7": "此邀请将在 7 天后过期。", "ignore": "如果这不是你发起的请求,可以忽略此邮件。", - "sentOn": "发送时间:{{date}}。", - "sentOnTo": "发送时间:{{date}},收件人:{{email}}。", - "submittedOnTo": "提交时间:{{date}},邮箱:{{email}}。", - "approvedOnFor": "批准时间:{{date}},邮箱:{{email}}。", - "sentOnFor": "发送时间:{{date}},关于你的{{typeLabel}}{{emailPart}}。", - "emailPartFrom": ",来自 {{email}}" + "sentOn": "发送时间:{date}。", + "sentOnTo": "发送时间:{date},收件人:{email}。", + "submittedOnTo": "提交时间:{date},邮箱:{email}。", + "approvedOnFor": "批准时间:{date},邮箱:{email}。", + "sentOnFor": "发送时间:{date},关于你的{typeLabel}{emailPart}。", + "emailPartFrom": ",来自 {email}" }, "footer": { - "copyright": "(c) {{year}} {{brandName}},保留所有权利", + "copyright": "(c) {year} {brandName},保留所有权利", "questions": "有问题?发送邮件至", "privacy": "隐私政策", "terms": "服务条款", "unsubscribe": "退订" }, "subjects": { - "sign-in": "登录 {{brandName}}", - "email-verification": "验证你的 {{brandName}} 邮箱", - "forget-password": "重置你的 {{brandName}} 密码", - "reset-password": "重置你的 {{brandName}} 密码", - "change-email": "验证你的新 {{brandName}} 邮箱", - "chat-access": "{{chatTitle}} 验证码", - "invitation": "你被邀请加入 {{brandName}} 上的 {{organizationName}}", - "batch-invitation": "你被邀请加入 {{brandName}} 上的 {{organizationName}} 和工作区", - "workspace-invitation": "你被邀请加入 {{brandName}} 上的 {{workspaceName}}", + "sign-in": "登录 {brandName}", + "email-verification": "验证你的 {brandName} 邮箱", + "forget-password": "重置你的 {brandName} 密码", + "reset-password": "重置你的 {brandName} 密码", + "change-email": "验证你的新 {brandName} 邮箱", + "chat-access": "{chatTitle} 验证码", + "invitation": "你被邀请加入 {brandName} 上的 {organizationName}", + "batch-invitation": "你被邀请加入 {brandName} 上的 {organizationName} 和工作区", + "workspace-invitation": "你被邀请加入 {brandName} 上的 {workspaceName}", "help-confirmation": "我们已收到你的请求", - "enterprise-subscription": "你的组织账单已在 {{brandName}} 激活", - "plan-welcome": "你的 {{planName}} 层级已在 {{brandName}} 激活", - "usage-threshold": "你接近 {{brandName}} 的月度预算", + "enterprise-subscription": "你的组织账单已在 {brandName} 激活", + "plan-welcome": "你的 {planName} 层级已在 {brandName} 激活", + "usage-threshold": "你接近 {brandName} 的月度预算", "free-tier-upgrade": "你当前层级接近包含用量上限", - "payment-failed": "{{brandName}} 付款失败 - 需要处理", - "waitlist-confirmation": "我们已收到你的 {{brandName}} 访问申请", - "waitlist-approved": "你的 {{brandName}} 访问申请已获批准", - "careers-confirmation": "你对 {{brandName}} 的申请 - {{position}}" + "payment-failed": "{brandName} 付款失败 - 需要处理", + "waitlist-confirmation": "我们已收到你的 {brandName} 访问申请", + "waitlist-approved": "你的 {brandName} 访问申请已获批准", + "careers-confirmation": "你对 {brandName} 的申请 - {position}" }, "otp": { "titles": { - "sign-in": "登录 {{brandName}}", - "email-verification": "验证你的 {{brandName}} 邮箱", - "forget-password": "重置你的 {{brandName}} 密码", - "change-email": "验证你的新 {{brandName}} 邮箱", - "chat-access": "访问 {{chatTitle}}" + "sign-in": "登录 {brandName}", + "email-verification": "验证你的 {brandName} 邮箱", + "forget-password": "重置你的 {brandName} 密码", + "change-email": "验证你的新 {brandName} 邮箱", + "chat-access": "访问 {chatTitle}" }, "body": "使用下面的代码继续。" }, "resetPassword": { "title": "重置你的密码", - "intro": "我们收到了重置你的 {{brandName}} 账户密码的请求。", + "intro": "我们收到了重置你的 {brandName} 账户密码的请求。", "action": "使用下面的按钮设置新密码。", "cta": "重置密码", "accountFallback": "你的账户邮箱", - "sentLine": "发送时间:{{date}},账户:{{account}}。" + "sentLine": "发送时间:{date},账户:{account}。" }, "invitation": { - "preview": "{{inviterName}} 邀请你加入 {{brandName}} 上的 {{organizationName}}", - "title": "你被邀请加入 {{organizationName}}。", - "intro": "{{inviterName}} 邀请你在 {{brandName}} 上协作。", + "preview": "{inviterName} 邀请你加入 {brandName} 上的 {organizationName}", + "title": "你被邀请加入 {organizationName}。", + "intro": "{inviterName} 邀请你在 {brandName} 上协作。", "body": "接受邀请即可访问共享项目和工作流。", "cta": "立即加入" }, "batchInvitation": { - "preview": "加入 {{brandName}} 上的 {{organizationName}}", - "title": "你被邀请加入 {{organizationName}}。", - "intro": "{{inviterName}} 将你添加为 {{brandName}} 上的 {{roleLabel}}。", + "preview": "加入 {brandName} 上的 {organizationName}", + "title": "你被邀请加入 {organizationName}。", + "intro": "{inviterName} 将你添加为 {brandName} 上的 {roleLabel}。", "adminDescription": "作为管理员,你可以管理组织的账单、成员和工作区访问权限。", "memberDescription": "作为成员,你可以参与共享账单协作并接受工作区邀请。", - "workspaceAccess": "工作区访问权限({{count}} 个{{workspaceWord}}):", - "workspaceLine": "- {{workspaceName}} - {{permissionLabel}}", + "workspaceAccess": "工作区访问权限({count} 个{workspaceWord}):", + "workspaceLine": "- {workspaceName} - {permissionLabel}", "workspaceSingular": "工作区", "workspacePlural": "工作区", - "closing": "接受后,你将加入 {{organizationName}}。", - "closingWithWorkspaces": "接受后,你将加入 {{organizationName}} 并获得 {{count}} 个{{workspaceWord}}的访问权限。", + "closing": "接受后,你将加入 {organizationName}。", + "closingWithWorkspaces": "接受后,你将加入 {organizationName} 并获得 {count} 个{workspaceWord}的访问权限。", "cta": "接受邀请", "roleLabels": { "admin": "管理员", @@ -22163,17 +22345,17 @@ } }, "workspaceInvitation": { - "preview": "加入 {{brandName}} 上的 {{workspaceName}} 工作区", - "title": "你被邀请加入 {{workspaceName}}", - "intro": "{{inviterName}} 邀请你在 {{brandName}} 的 {{workspaceName}} 工作区协作。接受后即可访问共享项目和数据。", + "preview": "加入 {brandName} 上的 {workspaceName} 工作区", + "title": "你被邀请加入 {workspaceName}", + "intro": "{inviterName} 邀请你在 {brandName} 的 {workspaceName} 工作区协作。接受后即可访问共享项目和数据。", "cta": "接受邀请" }, "help": { "title": "感谢你的反馈", - "preview": "{{brandName}}:我们已收到你的{{typeLabel}}", - "intro": "我们已收到你的{{typeLabel}},会尽快跟进。", - "attachments": "你附加了 {{count}} 个{{fileWord}}。我们会查看你分享的内容。", - "responseTime": "我们通常会在几小时内回复。如需即时帮助,请发送邮件至 {{supportEmail}}。", + "preview": "{brandName}:我们已收到你的{typeLabel}", + "intro": "我们已收到你的{typeLabel},会尽快跟进。", + "attachments": "你附加了 {count} 个{fileWord}。我们会查看你分享的内容。", + "responseTime": "我们通常会在几小时内回复。如需即时帮助,请发送邮件至 {supportEmail}。", "fileSingular": "文件", "filePlural": "文件", "typeLabels": { @@ -22187,13 +22369,13 @@ "confirmation": { "preview": "我们已收到你的访问申请", "title": "你已加入等待名单", - "intro": "我们已收到你对 {{brandName}} 的访问申请,并将 {{email}} 加入等待名单。", + "intro": "我们已收到你对 {brandName} 的访问申请,并将 {email} 加入等待名单。", "body": "我们会审核你的申请。如果此邮箱获准注册,我们会再次发送邮件。请在所有登录方式中使用同一个邮箱。" }, "approved": { "preview": "你的等待名单申请已获批准", "title": "你的访问权限已准备好", - "intro": "{{email}} 现在可以在 {{brandName}} 创建账户。", + "intro": "{email} 现在可以在 {brandName} 创建账户。", "body": "请使用同一个邮箱完成注册以激活访问权限。", "cta": "完成注册", "methodReminder": "请为邮箱/密码、Google、GitHub 或任何其他登录方式使用同一个已批准邮箱。" @@ -22202,74 +22384,70 @@ "billing": { "enterprise": { "title": "组织账单已激活", - "welcome": "欢迎,{{userName}}。", - "body": "你的组织账单层级已在 {{brandName}} 生效。现在你拥有更高容量、高级控制和组织范围访问权限。", + "welcome": "欢迎,{userName}。", + "body": "你的组织账单层级已在 {brandName} 生效。现在你拥有更高容量、高级控制和组织范围访问权限。", "cta": "访问你的账户", "nextStepsTitle": "后续步骤", - "nextSteps": [ - "- 邀请团队成员加入组织", - "- 配置工作区权限", - "- 使用新限制开始构建工作流" - ], + "nextSteps": ["- 邀请团队成员加入组织", "- 配置工作区权限", "- 使用新限制开始构建工作流"], "help": "需要入门帮助?回复此邮件,我们的团队会协助你。" }, "planWelcome": { - "preview": "{{brandName}}:你的 {{planName}} 层级已激活", - "title": "{{planName}} 层级已激活", - "namedWelcome": "欢迎,{{userName}}!", + "preview": "{brandName}:你的 {planName} 层级已激活", + "title": "{planName} 层级已激活", + "namedWelcome": "欢迎,{userName}!", "welcome": "欢迎!", - "body": "你已启用 {{brandName}} 的 {{planName}} 层级。探索新的限制,与团队更快交付。", + "body": "你已启用 {brandName} 的 {planName} 层级。探索新的限制,与团队更快交付。", "help": "想讨论你的层级或获取个性化入门帮助?可以与我们的团队预约 15 分钟通话。", "settings": "需要邀请队友、调整用量限制或管理账单?可随时访问设置 -> 订阅。" }, "usage": { - "preview": "{{brandName}}:你已使用 {{planName}} 月度预算的 {{percentUsed}}%", - "title": "你已使用预算的 {{percentUsed}}%", - "intro": "{{userNamePrefix}}你的 {{planName}} 层级用量接近月度限制。", + "preview": "{brandName}:你已使用 {planName} 月度预算的 {percentUsed}%", + "title": "你已使用预算的 {percentUsed}%", + "intro": "{userNamePrefix}你的 {planName} 层级用量接近月度限制。", "recommendation": "为避免中断,请考虑提高月度限制。", - "usageLine": "已使用 {{currentUsage}} / {{limit}}", - "percentLine": "本月预算的 {{percentUsed}}%", + "usageLine": "已使用 {currentUsage} / {limit}", + "percentLine": "本月预算的 {percentUsed}%", "cta": "查看限制", "reason": "当你的用量达到配置的账单警告阈值时,我们只发送一次此邮件,让你有时间调整层级或限制。" }, "freeTier": { "currentTierFallback": "你当前的层级", - "preview": "{{brandName}}:{{currentTierName}} 接近包含用量上限", + "preview": "{brandName}:{currentTierName} 接近包含用量上限", "title": "你的包含用量即将用完", - "greeting": "你好 {{userName}},", + "greeting": "你好 {userName},", "greetingFallback": "there", - "usage": "你已使用 {{currentUsage}},包含额度为 {{limit}},当前层级为 {{currentTierName}}({{percentUsed}}%)。", + "usage": "你已使用 {currentUsage},包含额度为 {limit},当前层级为 {currentTierName}({percentUsed}%)。", "body": "请查看可用账单层级,在本月限制影响新工作之前扩展你的用量。", "recommendedTitle": "推荐的下一层级", - "recommendedTier": "推荐的下一层级:{{tierName}}", - "recommendedPrice": "{{price}}/月起", - "recommendedUsage": "每月包含 {{usage}} 用量", + "recommendedTier": "推荐的下一层级:{tierName}", + "recommendedPrice": "{price}/月起", + "recommendedUsage": "每月包含 {usage} 用量", "cta": "查看账单层级", "oneTime": "这是默认层级超过升级阈值后的单次通知。" }, "paymentFailed": { "title": "我们无法处理你的付款。", - "greeting": "你好 {{userName}},", + "greeting": "你好 {userName},", "greetingFallback": "there", - "body": "你的 {{brandName}} 账户已暂时阻止,以避免服务中断和意外费用。请更新付款方式以立即恢复访问。", + "body": "你的 {brandName} 账户已暂时阻止,以避免服务中断和意外费用。请更新付款方式以立即恢复访问。", "detailsTitle": "付款详情", - "amountDue": "应付金额:{{amount}}", - "paymentMethod": "付款方式:**** {{lastFourDigits}}", - "reason": "原因:{{reason}}", + "amountDue": "应付金额:{amount}", + "paymentMethod": "付款方式:**** {lastFourDigits}", + "reason": "原因:{reason}", "cta": "更新付款方式", "nextSteps": "你的工作流和自动化当前已暂停。更新付款方式即可立即恢复服务。付款更新后,Stripe 会自动重试扣款。", "help": "常见失败原因包括卡片过期、余额不足或账单信息错误。如果问题持续,请联系支持。", - "sentLine": "发送时间:{{date}}。这是一条重要交易通知。" + "sentLine": "发送时间:{date}。这是一条重要交易通知。" } }, "careers": { - "preview": "我们已收到你对 {{brandName}} 的申请", + "preview": "我们已收到你对 {brandName} 的申请", "title": "我们已收到你的申请", - "greeting": "你好 {{name}},", - "body": "感谢你有兴趣加入 {{brandName}} 团队。我们已收到你对 {{position}} 职位的申请。", + "greeting": "你好 {name},", + "body": "感谢你有兴趣加入 {brandName} 团队。我们已收到你对 {position} 职位的申请。", "review": "我们的团队会认真审核每一份申请,并在接下来的几周内回复你。如果你的资历符合我们的需求,我们会联系你安排初次沟通。", - "explore": "与此同时,你可以查看我们的文档 {{docsUrl}},或在博客 {{blogUrl}} 阅读最新内容。", - "sentLine": "此确认邮件发送时间:{{dateTime}}。" + "explore": "与此同时,你可以查看我们的文档 {docsUrl},或在博客 {blogUrl} 阅读最新内容。", + "sentLine": "此确认邮件发送时间:{dateTime}。" } } } diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index 5319b3e72..999a648a6 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -24,12 +24,14 @@ function normalizeShape(value: unknown): unknown { return null } -const toolbarVisibleBlockTypes = [...new Set( - Object.values(registry) - .filter((block) => !block.hideFromToolbar) - .map((block) => block.type) - .filter((type): type is string => typeof type === 'string') -)].sort() +const toolbarVisibleBlockTypes = [ + ...new Set( + Object.values(registry) + .filter((block) => !block.hideFromToolbar) + .map((block) => block.type) + .filter((type): type is string => typeof type === 'string') + ), +].sort() describe('public copy', () => { it('loads translated locale files directly', () => { @@ -70,9 +72,9 @@ describe('public copy', () => { expect(getPublicCopy('es').landing.preview.workflow.demoCopy.investmentDebate.notionTitle).toBe( 'Memorando del comité de inversión' ) - expect( - getPublicCopy('zh').landing.preview.workflow.demoCopy.riskRouting.webhookNote - ).toContain('风险系统') + expect(getPublicCopy('zh').landing.preview.workflow.demoCopy.riskRouting.webhookNote).toContain( + '风险系统' + ) }) it('includes localized blog and not found copy', () => { @@ -129,7 +131,7 @@ describe('public copy', () => { it('includes localized verification screen copy', () => { expect(getPublicCopy('en').auth.verify.pendingTitle).toBe('Verify Your Email') - expect(getPublicCopy('en').auth.verify.resendIn).toBe('Resend in {{countdown}}s') + expect(getPublicCopy('en').auth.verify.resendIn).toBe('Resend in {countdown}s') expect(getPublicCopy('es').auth.verify.verifyButton).toBe('Verificar correo') expect(getPublicCopy('es').auth.verify.errors.resendFailed).toContain('reenviar') expect(getPublicCopy('zh').auth.verify.instructionsWithoutService).toBe( @@ -158,7 +160,7 @@ describe('public copy', () => { expect(getPublicCopy('en').workspace.widgets.workflowLabels.tools).toBe('Tools') expect(getPublicCopy('zh').workspace.widgets.workflowLabels.tools).toBe('工具') expect(getPublicCopy('en').workspace.widgets.workflowLabels.deployedWithVersion).toBe( - 'Deployed (v{{version}})' + 'Deployed (v{version})' ) const enWidgets = getPublicCopy('en').workspace.widgets const esWidgets = getPublicCopy('es').workspace.widgets @@ -182,7 +184,7 @@ describe('public copy', () => { expect(getPublicCopy('en').workspace.knowledge.title).toBe('Knowledge') expect(getPublicCopy('en').workspace.templates.title).toBe('Templates') expect(getPublicCopy('es').workspace.templates.sections.your).toBe('Tus plantillas') - expect(getPublicCopy('zh').workspace.layoutTabs.renameAriaLabel).toContain('{{name}}') + expect(getPublicCopy('zh').workspace.layoutTabs.renameAriaLabel).toContain('{name}') expect(getPublicCopy('zh').workspace.logs.title.logs).toBe('日志') expect(getPublicCopy('en').workspace.widgets.selector.selectWidget).toBe('Select widget') expect(getPublicCopy('en').workspace.widgets.selector.categories.trading).toBe('Trading') @@ -199,21 +201,19 @@ describe('public copy', () => { expect( getPublicCopy('es').workspace.widgets.webhook.providers.generic.sections.authentication ).toBe('Autenticación') - expect( - getPublicCopy('zh').workspace.widgets.webhook.providers.slack.notice.payloadTitle - ).toBe('Slack 事件负载示例') - expect(getPublicCopy('en').workspace.widgets.webhook.providers.gmail.fallbackLabels.inbox).toBe( + expect(getPublicCopy('zh').workspace.widgets.webhook.providers.slack.notice.payloadTitle).toBe( + 'Slack 事件负载示例' + ) + expect(getPublicCopy('en').workspace.widgets.webhook.providers.gmail.defaultLabels.inbox).toBe( 'Inbox' ) expect( - getPublicCopy('zh').workspace.widgets.webhook.providers.outlook.fallbackFolders.sentItems + getPublicCopy('zh').workspace.widgets.webhook.providers.outlook.defaultFolders.sentItems ).toBe('已发送邮件') expect(getPublicCopy('es').workspace.widgets.workflowCreateMenu.createWorkflow).toBe( 'Nuevo flujo' ) - expect(getPublicCopy('zh').workspace.widgets.workflowEditor.previewInspector).toBe( - '预览检查器' - ) + expect(getPublicCopy('zh').workspace.widgets.workflowEditor.previewInspector).toBe('预览检查器') expect(getPublicCopy('en').workspace.widgets.pairColor.selectWidgetColor).toBe( 'Select widget color' ) @@ -222,7 +222,7 @@ describe('public copy', () => { expect(getPublicCopy('es').workspace.widgets.workflowChat.attachFiles).toBe('Adjuntar archivos') expect(getPublicCopy('es').workspace.widgets.workflowChat.attach).toBe('Adjuntar') expect(getPublicCopy('en').workspace.widgets.workflowChat.maximumFilesAllowed).toContain( - '{{maxFiles}}' + '{maxFiles}' ) expect(getPublicCopy('en').workspace.widgets.workflowVariables.unableToLoadWorkflows).toBe( 'Unable to load workflows' @@ -233,20 +233,20 @@ describe('public copy', () => { expect(getPublicCopy('zh').workspace.widgets.workflowEditor.whileConditionPlaceholder).toBe( ' < 10' ) - expect( - getPublicCopy('en').workspace.widgets.workflowEditor.collectionItemsPlaceholder - ).toBe("['item1', 'item2', 'item3']") + expect(getPublicCopy('en').workspace.widgets.workflowEditor.collectionItemsPlaceholder).toBe( + "['item1', 'item2', 'item3']" + ) expect( getPublicCopy('en').workspace.widgets.blockEditor.googleCalendarSelector.accessLabel ).toBe('Access:') - expect( - getPublicCopy('es').workspace.widgets.blockEditor.knowledgeBaseSelector.groupLabel - ).toBe('Bases de conocimiento') + expect(getPublicCopy('es').workspace.widgets.blockEditor.knowledgeBaseSelector.groupLabel).toBe( + 'Bases de conocimiento' + ) expect( getPublicCopy('zh').workspace.widgets.blockEditor.documentSelector.chunkCountPlural - ).toBe('{{count}} 个分块') + ).toBe('{count} 个分块') expect(getPublicCopy('en').workspace.widgets.blockEditor.documentTagEntry.tagSlotsUsed).toBe( - '{{used}} of {{total}} tag slots used' + '{used} of {total} tag slots used' ) expect(getPublicCopy('zh').workspace.widgets.workflowVariables.addVariable).toBe('添加变量') expect(getPublicCopy('en').workspace.widgets.blockEditor.templateModal.title.publish).toBe( @@ -268,9 +268,9 @@ describe('public copy', () => { expect( getPublicCopy('en').workspace.widgets.blockEditor.webhookSettings.errors.validationFailed ).toBe('Validation failed. Review the webhook settings and try again.') - expect(getPublicCopy('zh').workspace.widgets.blockEditor.webhookSettings.testStatus.failure).toBe( - '测试 Webhook 失败。' - ) + expect( + getPublicCopy('zh').workspace.widgets.blockEditor.webhookSettings.testStatus.failure + ).toBe('测试 Webhook 失败。') expect(getPublicCopy('es').workspace.widgets.blockEditor.webhookSettings.actions.add).toBe( 'Agregar webhook' ) @@ -342,30 +342,30 @@ describe('public copy', () => { expect(getPublicCopy('zh').workspace.widgets.workflowLabels.numberOfResults).toBe('结果数量') }) - it('includes localized guardrails workflow labels', () => { - expect(getPublicCopy('en').workspace.widgets.workflowLabels.contentToValidate).toBe( - 'Content to Validate' - ) - expect(getPublicCopy('en').workspace.widgets.workflowLabels.validationType).toBe( - 'Validation Type' - ) + it('includes localized guardrails block editor copy', () => { + const enGuardrails = getPublicCopy('en').workspace.widgets.blockEditor.subBlocks.guardrails + const esGuardrails = getPublicCopy('es').workspace.widgets.blockEditor.subBlocks.guardrails + const zhGuardrails = getPublicCopy('zh').workspace.widgets.blockEditor.subBlocks.guardrails + + expect(enGuardrails.input.title).toBe('Content to Validate') + expect(enGuardrails.validationType.title).toBe('Validation Type') expect(getPublicCopy('en').workspace.widgets.workflowLabels.configurePiiTypes).toBe( 'Configure PII Types' ) - expect(getPublicCopy('es').workspace.widgets.workflowLabels.contentToValidate).toBe( - 'Contenido a validar' - ) - expect(getPublicCopy('es').workspace.widgets.workflowLabels.blockRequest).toBe( + expect(esGuardrails.input.title).toBe('Contenido a validar') + expect(esGuardrails.piiMode.options.find((option) => option.id === 'block')?.label).toBe( 'Bloquear solicitud' ) - expect(getPublicCopy('es').workspace.widgets.workflowLabels.common).toBe('Comunes') - expect(getPublicCopy('zh').workspace.widgets.workflowLabels.validationType).toBe('验证类型') - expect(getPublicCopy('zh').workspace.widgets.workflowLabels.maskPii).not.toBe( - getPublicCopy('en').workspace.widgets.workflowLabels.maskPii - ) - expect(getPublicCopy('zh').workspace.widgets.workflowLabels.personName).not.toBe( - getPublicCopy('en').workspace.widgets.workflowLabels.personName + expect( + esGuardrails.piiEntityTypes.options.find((option) => option.id === 'PERSON')?.group + ).toBe('Comunes') + expect(zhGuardrails.validationType.title).toBe('验证类型') + expect(zhGuardrails.piiMode.options.find((option) => option.id === 'mask')?.label).not.toBe( + enGuardrails.piiMode.options.find((option) => option.id === 'mask')?.label ) + expect( + zhGuardrails.piiEntityTypes.options.find((option) => option.id === 'PERSON')?.label + ).not.toBe(enGuardrails.piiEntityTypes.options.find((option) => option.id === 'PERSON')?.label) }) it('includes localized SSO callback helper copy', () => { @@ -388,7 +388,7 @@ describe('public copy', () => { expect( getPublicCopy('es').workspace.settingsModal.account.status.profilePictureUnsupportedFormat ).toContain('PNG') - expect(getPublicCopy('zh').workspace.settingsModal.help.previewAlt).toBe('预览 {{index}}') + expect(getPublicCopy('zh').workspace.settingsModal.help.previewAlt).toBe('预览 {index}') }) it('includes localized deployment and block-editor copy for workflow editor surfaces', () => { @@ -413,8 +413,12 @@ describe('public copy', () => { const esWidgets = getPublicCopy('es').workspace.widgets const zhWidgets = getPublicCopy('zh').workspace.widgets - expect(normalizeShape(esWidgets.workflowLabels)).toEqual(normalizeShape(enWidgets.workflowLabels)) - expect(normalizeShape(zhWidgets.workflowLabels)).toEqual(normalizeShape(enWidgets.workflowLabels)) + expect(normalizeShape(esWidgets.workflowLabels)).toEqual( + normalizeShape(enWidgets.workflowLabels) + ) + expect(normalizeShape(zhWidgets.workflowLabels)).toEqual( + normalizeShape(enWidgets.workflowLabels) + ) expect(normalizeShape(esWidgets.blockEditor)).toEqual(normalizeShape(enWidgets.blockEditor)) expect(normalizeShape(zhWidgets.blockEditor)).toEqual(normalizeShape(enWidgets.blockEditor)) }) diff --git a/apps/tradinggoose/i18n/route-boundary.test.ts b/apps/tradinggoose/i18n/route-boundary.test.ts deleted file mode 100644 index 3745ebdc8..000000000 --- a/apps/tradinggoose/i18n/route-boundary.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRouteBoundaryHref, getRouteBoundaryUrl } from './route-boundary' - -describe('route boundary URLs', () => { - it('localizes canonical internal hrefs without double-prefixing locale segments', () => { - expect(getRouteBoundaryHref('zh', '/workspace/ws-1/dashboard?layoutId=layout-1')).toBe( - '/zh/workspace/ws-1/dashboard?layoutId=layout-1' - ) - expect(getRouteBoundaryHref('zh', '/zh/login?reauth=1')).toBe('/zh/login?reauth=1') - expect(getRouteBoundaryHref('en', '/zh/workspace')).toBe('/workspace') - }) - - it('leaves external hrefs untouched', () => { - expect(getRouteBoundaryHref('es', 'https://example.com/path')).toBe('https://example.com/path') - expect(getRouteBoundaryHref('es', '//example.com/path')).toBe('//example.com/path') - }) - - it('builds absolute route-boundary URLs', () => { - expect(getRouteBoundaryUrl('https://tradinggoose.ai', 'es', '/reset-password')).toBe( - 'https://tradinggoose.ai/es/reset-password' - ) - }) -}) diff --git a/apps/tradinggoose/i18n/route-boundary.ts b/apps/tradinggoose/i18n/route-boundary.ts deleted file mode 100644 index 7f2c4e79d..000000000 --- a/apps/tradinggoose/i18n/route-boundary.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defaultLocale, type LocaleCode, stripLocaleFromPathname } from './utils' - -function prefixLocalePathname(locale: LocaleCode, pathname: string) { - const normalized = pathname === '/' ? '/' : pathname.replace(/\/+$/, '') - - if (locale === defaultLocale) { - return normalized - } - - return normalized === '/' ? `/${locale}` : `/${locale}${normalized}` -} - -export function getRouteBoundaryHref(locale: LocaleCode, href: string) { - if (!href.startsWith('/') || href.startsWith('//')) { - return href - } - - const parsedUrl = new URL(href, 'http://tradinggoose.local') - const { pathname } = stripLocaleFromPathname(parsedUrl.pathname) - - return `${prefixLocalePathname(locale, pathname)}${parsedUrl.search}${parsedUrl.hash}` -} - -export function getRouteBoundaryUrl(baseUrl: string, locale: LocaleCode, href: string) { - return new URL(getRouteBoundaryHref(locale, href), baseUrl).toString() -} diff --git a/apps/tradinggoose/i18n/utils.test.ts b/apps/tradinggoose/i18n/utils.test.ts index c4395a010..2c7c5a144 100644 --- a/apps/tradinggoose/i18n/utils.test.ts +++ b/apps/tradinggoose/i18n/utils.test.ts @@ -4,6 +4,7 @@ import { getLocaleDisplayName, getOpenGraphLocale, localizeSiteUrl, + localizeUrl, normalizeCallbackUrl, stripLocaleFromPathname, } from './utils' @@ -51,12 +52,33 @@ describe('i18n utils', () => { languages: { en: 'https://tradinggoose.ai/blog', es: 'https://tradinggoose.ai/es/blog', - 'zh': 'https://tradinggoose.ai/zh/blog', + zh: 'https://tradinggoose.ai/zh/blog', 'x-default': 'https://tradinggoose.ai/blog', }, }) }) + it('builds absolute localized app URLs from canonical internal paths', () => { + expect(localizeUrl('https://tradinggoose.ai/', 'es', '/reset-password?token=abc')).toBe( + 'https://tradinggoose.ai/es/reset-password?token=abc' + ) + expect(localizeUrl('https://tradinggoose.ai', 'en', '/workspace')).toBe( + 'https://tradinggoose.ai/workspace' + ) + expect(localizeUrl('https://tradinggoose.ai', 'invalid', '/login')).toBe( + 'https://tradinggoose.ai/login' + ) + }) + + it('rejects non-canonical app URL inputs', () => { + expect(() => localizeUrl('https://tradinggoose.ai', 'es', '/zh/login')).toThrow( + 'Expected an unlocalized internal pathname' + ) + expect(() => localizeUrl('https://tradinggoose.ai', 'es', 'https://example.com')).toThrow( + 'Expected a canonical internal pathname' + ) + }) + it('maps Open Graph locales using canonical regional codes', () => { expect(getOpenGraphLocale('es')).toBe('es_ES') expect(getOpenGraphLocale('zh')).toBe('zh_CN') diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts index ce53ad1f6..637064f54 100644 --- a/apps/tradinggoose/i18n/utils.ts +++ b/apps/tradinggoose/i18n/utils.ts @@ -1,4 +1,7 @@ +import { createTranslator } from 'next-intl' + export type LocaleCode = 'en' | 'es' | 'zh' +export type LocaleInput = LocaleCode | string | null | undefined export const locales = ['en', 'es', 'zh'] as const export const defaultLocale: LocaleCode = 'en' @@ -20,11 +23,18 @@ export function isLocaleCode(value: string): value is LocaleCode { return (locales as readonly string[]).includes(value) } +export function normalizeLocaleCode(locale: LocaleInput): LocaleCode { + return locale && isLocaleCode(locale) ? locale : defaultLocale +} + export function getLocaleDisplayName(locale: LocaleCode) { return LOCALE_DISPLAY_NAMES[locale] } -export function stripLocaleFromPathname(pathname: string): { locale: LocaleCode; pathname: string } { +export function stripLocaleFromPathname(pathname: string): { + locale: LocaleCode + pathname: string +} { const segments = pathname.split('/').filter(Boolean) const firstSegment = segments[0] @@ -52,6 +62,17 @@ function prefixLocalePathname(locale: LocaleCode, pathname: string) { return normalized === '/' ? `/${locale}` : `/${locale}${normalized}` } +function assertCanonicalInternalPathname(pathname: string) { + if (!pathname.startsWith('/') || pathname.startsWith('//')) { + throw new Error(`Expected a canonical internal pathname, received "${pathname}"`) + } + + const firstSegment = pathname.split(/[?#]/, 1)[0].split('/').filter(Boolean)[0] + if (firstSegment && isLocaleCode(firstSegment)) { + throw new Error(`Expected an unlocalized internal pathname, received "${pathname}"`) + } +} + export function normalizeCallbackUrl( href: string | null | undefined, currentOrigin?: string @@ -90,8 +111,9 @@ export function normalizeCallbackUrl( } } -export function localizeUrl(baseUrl: string, locale: LocaleCode, pathname: string) { - return `${baseUrl}${prefixLocalePathname(locale, pathname)}` +export function localizeUrl(baseUrl: string, locale: LocaleInput, pathname: string) { + assertCanonicalInternalPathname(pathname) + return `${baseUrl.replace(/\/+$/, '')}${prefixLocalePathname(normalizeLocaleCode(locale), pathname)}` } export function localizeSiteUrl(locale: LocaleCode, pathname: string) { @@ -118,9 +140,24 @@ export function buildLocalizedAlternates(locale: LocaleCode, pathname: string) { } } -export function formatTemplate(template: string, values: Record) { - return Object.entries(values).reduce( - (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)), - template - ) +export function formatTemplate( + template: string, + values: Record, + locale: LocaleCode = defaultLocale +) { + let formatError: unknown + const translator = createTranslator({ + locale, + messages: { value: template }, + onError(error) { + formatError = error + }, + }) + const formatted = translator('value', values) + + if (formatError) { + throw formatError + } + + return formatted } diff --git a/apps/tradinggoose/i18n/workflow-inspector-core.ts b/apps/tradinggoose/i18n/workflow-inspector-core.ts index 9186da2bd..285ffdaf4 100644 --- a/apps/tradinggoose/i18n/workflow-inspector-core.ts +++ b/apps/tradinggoose/i18n/workflow-inspector-core.ts @@ -35,7 +35,12 @@ type LocalizedTriggerMetadata = { type BlockEditorOptionOverride = { id: string label: string + group?: string + searchLabel?: string + rightLabel?: string } +type BlockEditorOptionOverrides = BlockEditorOptionOverride[] +type BlockEditorOptionOverrideMap = Record> type BlockEditorSubBlockOverride = Partial< Pick< @@ -49,7 +54,10 @@ type BlockEditorSubBlockOverride = Partial< | 'defaultValue' > > & { - options?: BlockEditorOptionOverride[] + options?: BlockEditorOptionOverrides +} +type MergedBlockEditorSubBlockOverride = Omit & { + options?: BlockEditorOptionOverrideMap } type BlockEditorTriggerSubBlockOverride = BlockEditorSubBlockOverride & { @@ -72,51 +80,12 @@ type BlockEditorTriggerOverrides = Record< type TriggerOverride = BlockEditorTriggerOverrides[string] -const TRAILING_COLON_PATTERN = /:\s*$/ -const NON_ALPHANUMERIC_PATTERN = /[^A-Za-z0-9]+/g -const LEADING_NON_ALPHA_PATTERN = /^[^A-Za-z]+/ const GENERATED_NAME_SUFFIX_PATTERN = /(\s+\d+)$/ - -function normalizeWorkflowLabel(label: string) { - return label.replace(TRAILING_COLON_PATTERN, '').trim() -} - -function toStableMessageKey(label: string) { - const normalized = normalizeWorkflowLabel(label) - - if (!normalized) { - return normalized - } - - if (/^[a-z][A-Za-z0-9]*$/.test(normalized)) { - return normalized - } - - const tokens = normalized - .replaceAll('&', ' and ') - .replaceAll('/', ' ') - .replaceAll('.', ' ') - .replaceAll('{{', ' ') - .replaceAll('}}', ' ') - .replace(LEADING_NON_ALPHA_PATTERN, '') - .split(NON_ALPHANUMERIC_PATTERN) - .filter(Boolean) - - if (tokens.length === 0) { - return normalized - } - - return tokens - .map((token, index) => { - const lower = token.toLowerCase() - return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}` - }) - .join('') -} +const WORKFLOW_INSPECTOR_KEY_PREFIX = 'workflowInspector.' function resolveInspectorPath(copy: WorkflowInspectorCopy, label: string) { - if (!label.startsWith('workflowInspector.')) { - return null + if (!label.startsWith(WORKFLOW_INSPECTOR_KEY_PREFIX)) { + return undefined } const path = label.split('.').slice(1) @@ -128,39 +97,29 @@ function resolveInspectorPath(copy: WorkflowInspectorCopy, label: string) { return (current as Record)[segment] }, copy) - return typeof resolvedValue === 'string' ? resolvedValue : null -} - -function resolveWorkflowLabelKey(copy: WorkflowLabelCopy, label: string) { - const copyRecord = copy as Record - const normalizedLabel = normalizeWorkflowLabel(label) - - if (typeof copyRecord[normalizedLabel] === 'string') { - return normalizedLabel + if (typeof resolvedValue !== 'string') { + throw new Error(`Missing workflow inspector translation for key "${label}".`) } - const stableKey = toStableMessageKey(normalizedLabel) - if (stableKey && typeof copyRecord[stableKey] === 'string') { - return stableKey - } - - return null + return resolvedValue } -function resolveWorkflowToolbarKey(copy: WorkflowToolbarCopy, label: string) { - const copyRecord = copy as Record - const normalizedLabel = normalizeWorkflowLabel(label) - - if (typeof copyRecord[normalizedLabel] === 'string') { - return normalizedLabel +function requireWorkflowLabel(copy: WorkflowLabelCopy, key: string) { + const value = (copy as Record)[key] + if (typeof value !== 'string') { + throw new Error(`Missing workflow label translation for key "${key}".`) } - const stableKey = toStableMessageKey(normalizedLabel) - if (stableKey && typeof copyRecord[stableKey] === 'string') { - return stableKey + return value +} + +function requireWorkflowToolbarLabel(copy: WorkflowToolbarCopy, key: string) { + const value = (copy as Record)[key] + if (typeof value !== 'string') { + throw new Error(`Missing workflow toolbar translation for key "${key}".`) } - return null + return value } function getBlockNameOverrides(copy: WorkflowInspectorCopy): Record { @@ -175,12 +134,31 @@ function getBlockLongDescriptionOverrides(copy: WorkflowInspectorCopy): Record } +function requireLocalizedBlockText( + overrides: Record, + blockType: string, + field: 'name' | 'description' +) { + const value = overrides[blockType] + if (typeof value === 'string') { + return value + } + + if (getBlock(blockType)) { + throw new Error(`Missing localized block ${field} for block type "${blockType}".`) + } + + return undefined +} + function getCanonicalDefaultBlockName(blockType: string) { return ( - (getPublicCopy(defaultLocale).workspace.widgets.blockEditor.blockNames as Record< - string, - string | undefined - >)[blockType] ?? + ( + getPublicCopy(defaultLocale).workspace.widgets.blockEditor.blockNames as Record< + string, + string | undefined + > + )[blockType] ?? getBlock(blockType)?.name ?? blockType ) @@ -190,7 +168,10 @@ function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function getCanonicalGeneratedNameSuffix(defaultBlockName: string, blockName: string): string | null { +function getCanonicalGeneratedNameSuffix( + defaultBlockName: string, + blockName: string +): string | null { const normalizedDefaultName = defaultBlockName.trim() const normalizedName = blockName.trim() if (!normalizedDefaultName || !normalizedName) { @@ -226,61 +207,39 @@ function getTriggerOverrides(copy: WorkflowInspectorCopy): BlockEditorTriggerOve } function mergeOptionOverrides( - fallbackOptions: BlockEditorOptionOverride[] | undefined, - localeOptions: BlockEditorOptionOverride[] | undefined -): BlockEditorOptionOverride[] | undefined { - if (!fallbackOptions && !localeOptions) { + baseOptions: BlockEditorOptionOverrides | undefined, + overrideOptions: BlockEditorOptionOverrides | undefined +): BlockEditorOptionOverrideMap | undefined { + if (!baseOptions && !overrideOptions) { return undefined } - const merged = new Map() + const merged: BlockEditorOptionOverrideMap = {} - for (const option of fallbackOptions ?? []) { - if (typeof option?.id !== 'string' || typeof option?.label !== 'string') { + for (const option of [...(baseOptions ?? []), ...(overrideOptions ?? [])]) { + if (typeof option?.id !== 'string' || typeof option.label !== 'string') { continue } - merged.set(option.id, option) + const { id, ...override } = option + merged[id] = override } - - for (const option of localeOptions ?? []) { - if (typeof option?.id !== 'string' || typeof option?.label !== 'string') { - continue - } - - merged.set(option.id, option) - } - - return [...merged.values()] -} - -function getOptionOverrideMap( - options: BlockEditorOptionOverride[] | undefined -): Map | undefined { - if (!options || options.length === 0) { - return undefined - } - - return new Map( - options - .filter((option) => typeof option?.id === 'string' && typeof option?.label === 'string') - .map((option) => [option.id, option.label]) - ) + return Object.keys(merged).length > 0 ? merged : undefined } -function mergeSubBlockOverrides( - fallbackOverride: T | undefined, - localeOverride: T | undefined -): T | undefined { - if (!fallbackOverride && !localeOverride) { +function mergeSubBlockOverrides( + baseOverride: BlockEditorSubBlockOverride | undefined, + override: BlockEditorSubBlockOverride | undefined +): MergedBlockEditorSubBlockOverride | undefined { + if (!baseOverride && !override) { return undefined } return { - ...fallbackOverride, - ...localeOverride, - options: mergeOptionOverrides(fallbackOverride?.options, localeOverride?.options), - } as T + ...baseOverride, + ...override, + options: mergeOptionOverrides(baseOverride?.options, override?.options), + } } function getSubBlockOverride( @@ -347,29 +306,21 @@ export function getWorkflowLabelCopyFromInspector(copy: WorkflowInspectorCopy): export function translateWorkflowToolbarLabelWithCopy( copy: WorkflowToolbarCopy, - label: string + key: string ): string { - const key = resolveWorkflowToolbarKey(copy, label) - return key ? (copy as Record)[key] : label + return requireWorkflowToolbarLabel(copy, key) } export function translateWorkflowLabelWithCopy( inspectorCopy: WorkflowInspectorCopy, - label: string + key: string ): string { - const resolvedPathValue = resolveInspectorPath(inspectorCopy, label) + const resolvedPathValue = resolveInspectorPath(inspectorCopy, key) if (resolvedPathValue) { return resolvedPathValue } - const workflowLabels = getWorkflowLabelCopyFromInspector(inspectorCopy) - const key = resolveWorkflowLabelKey(workflowLabels, label) - - if (key) { - return (workflowLabels as Record)[key] - } - - return label + return requireWorkflowLabel(getWorkflowLabelCopyFromInspector(inspectorCopy), key) } export function getToolInputCopyFromInspector(copy: WorkflowInspectorCopy) { @@ -435,25 +386,13 @@ function formatTriggerInstructionSteps(steps: string[]): string { .join('') } -function resolveWorkflowText( - inspectorCopy: WorkflowInspectorCopy, - value: string | undefined, - explicitKey?: string -) { - if (explicitKey) { - return translateWorkflowLabelWithCopy(inspectorCopy, explicitKey) - } - - return value ? translateWorkflowLabelWithCopy(inspectorCopy, value) : value -} - function localizeWorkflowOption( inspectorCopy: WorkflowInspectorCopy, option: T, - optionOverrides?: Map, + optionOverrides?: BlockEditorOptionOverrideMap, subBlockId?: string ): T { - const optionOverrideLabel = optionOverrides?.get(option.id) + const optionOverride = optionOverrides?.[option.id] const triggerOptionLabel = subBlockId === 'selectedTriggerId' ? getLocalizedTriggerMetadataWithCopy(inspectorCopy, { @@ -465,21 +404,10 @@ function localizeWorkflowOption( return { ...option, - label: - triggerOptionLabel ?? - resolveWorkflowText( - inspectorCopy, - optionOverrideLabel ?? option.label, - option.i18n?.labelKey - ) ?? - option.label, - group: resolveWorkflowText(inspectorCopy, option.group, option.i18n?.groupKey) ?? option.group, - searchLabel: - resolveWorkflowText(inspectorCopy, option.searchLabel, option.i18n?.searchLabelKey) ?? - option.searchLabel, - rightLabel: - resolveWorkflowText(inspectorCopy, option.rightLabel, option.i18n?.rightLabelKey) ?? - option.rightLabel, + label: triggerOptionLabel ?? optionOverride?.label ?? option.label, + group: optionOverride?.group ?? option.group, + searchLabel: optionOverride?.searchLabel ?? option.searchLabel, + rightLabel: optionOverride?.rightLabel ?? option.rightLabel, } } @@ -494,21 +422,24 @@ export function localizeWorkflowOptionsWithCopy( return options } - const optionOverrides = mergeSubBlockOverrides( - blockType && subBlockId ? getSubBlockOverride(inspectorCopy, blockType, subBlockId) : undefined, - getTriggerSubBlockCopyFromInspector(inspectorCopy, triggerId, subBlockId ?? '') - )?.options - const optionOverrideMap = getOptionOverrideMap(optionOverrides) + const blockOverride = + blockType && subBlockId ? getSubBlockOverride(inspectorCopy, blockType, subBlockId) : undefined + const triggerOverride = getTriggerSubBlockCopyFromInspector( + inspectorCopy, + triggerId, + subBlockId ?? '' + ) + const optionOverrides = mergeOptionOverrides(blockOverride?.options, triggerOverride?.options) if (typeof options === 'function') { return () => options().map((option) => - localizeWorkflowOption(inspectorCopy, option, optionOverrideMap, subBlockId) + localizeWorkflowOption(inspectorCopy, option, optionOverrides, subBlockId) ) } return options.map((option) => - localizeWorkflowOption(inspectorCopy, option, optionOverrideMap, subBlockId) + localizeWorkflowOption(inspectorCopy, option, optionOverrides, subBlockId) ) } @@ -524,61 +455,75 @@ function getLocalizedWorkflowOptionsWithCopy( } const resolvedOptions = typeof options === 'function' ? options() : options - const optionOverrides = mergeSubBlockOverrides( - blockType && subBlockId ? getSubBlockOverride(inspectorCopy, blockType, subBlockId) : undefined, - getTriggerSubBlockCopyFromInspector(inspectorCopy, triggerId, subBlockId ?? '') - )?.options - const optionOverrideMap = getOptionOverrideMap(optionOverrides) + const blockOverride = + blockType && subBlockId ? getSubBlockOverride(inspectorCopy, blockType, subBlockId) : undefined + const triggerOverride = getTriggerSubBlockCopyFromInspector( + inspectorCopy, + triggerId, + subBlockId ?? '' + ) + const optionOverrides = mergeOptionOverrides(blockOverride?.options, triggerOverride?.options) return resolvedOptions.map((option) => - localizeWorkflowOption(inspectorCopy, option, optionOverrideMap, subBlockId) + localizeWorkflowOption(inspectorCopy, option, optionOverrides, subBlockId) ) } export function getLocalizedBlockNameWithCopy( inspectorCopy: WorkflowInspectorCopy, blockOrType: Pick | string, - fallbackName?: string + providedName?: string ): string { const blockType = typeof blockOrType === 'string' ? blockOrType : blockOrType.type + const localizedName = requireLocalizedBlockText( + getBlockNameOverrides(inspectorCopy), + blockType, + 'name' + ) const blockName = typeof blockOrType === 'string' - ? (fallbackName ?? getBlock(blockOrType)?.name ?? blockOrType) - : (blockOrType.name ?? fallbackName ?? getBlock(blockType)?.name ?? blockType) + ? (providedName ?? getBlock(blockOrType)?.name ?? blockOrType) + : (blockOrType.name ?? providedName ?? getBlock(blockType)?.name ?? blockType) - return getBlockNameOverrides(inspectorCopy)[blockType] ?? blockName + return localizedName ?? blockName } export function getLocalizedBlockDescriptionWithCopy( inspectorCopy: WorkflowInspectorCopy, blockOrType: Pick | string, - fallbackDescription?: string + providedDescription?: string ): string { const blockType = typeof blockOrType === 'string' ? blockOrType : blockOrType.type + const localizedDescription = requireLocalizedBlockText( + getBlockDescriptionOverrides(inspectorCopy), + blockType, + 'description' + ) const blockDescription = typeof blockOrType === 'string' - ? (fallbackDescription ?? getBlock(blockType)?.description ?? '') - : (blockOrType.description ?? fallbackDescription ?? getBlock(blockType)?.description ?? '') + ? (providedDescription ?? getBlock(blockType)?.description ?? '') + : (blockOrType.description ?? providedDescription ?? getBlock(blockType)?.description ?? '') - return getBlockDescriptionOverrides(inspectorCopy)[blockType] ?? blockDescription + return localizedDescription ?? blockDescription } export function getLocalizedBlockLongDescriptionWithCopy( inspectorCopy: WorkflowInspectorCopy, block: Pick | string, - fallbackLongDescription?: string + providedLongDescription?: string ): string | undefined { const blockType = typeof block === 'string' ? block : block.type + const localizedLongDescription = getBlockLongDescriptionOverrides(inspectorCopy)[blockType] + if (typeof localizedLongDescription === 'string') { + return localizedLongDescription + } + const longDescription = typeof block === 'string' - ? (fallbackLongDescription ?? getBlock(blockType)?.longDescription) - : (block.longDescription ?? fallbackLongDescription ?? getBlock(blockType)?.longDescription) + ? (providedLongDescription ?? getBlock(blockType)?.longDescription) + : (block.longDescription ?? providedLongDescription ?? getBlock(blockType)?.longDescription) - if (!longDescription) { - return undefined - } - - return getBlockLongDescriptionOverrides(inspectorCopy)[blockType] ?? longDescription + return getBlock(blockType) ? undefined : longDescription } export function getLocalizedBlockMetadataWithCopy( @@ -597,13 +542,15 @@ export function getLocalizedTriggerMetadataWithCopy( trigger: Pick | string ): LocalizedTriggerMetadata { const triggerId = typeof trigger === 'string' ? trigger : trigger.id - const fallbackName = typeof trigger === 'string' ? triggerId : trigger.name - const fallbackDescription = typeof trigger === 'string' ? '' : trigger.description const override = getTriggerOverride(inspectorCopy, triggerId) + if (typeof override?.name !== 'string' || typeof override?.description !== 'string') { + throw new Error(`Missing localized trigger metadata for trigger "${triggerId}".`) + } + return { - name: override?.name ?? fallbackName, - description: override?.description ?? fallbackDescription, + name: override.name, + description: override.description, } } @@ -633,11 +580,11 @@ export function getLocalizedDefaultBlockNameWithCopy( } export function getLocalizedToolParameterLabelWithCopy( - inspectorCopy: WorkflowInspectorCopy, + _inspectorCopy: WorkflowInspectorCopy, paramId: string, label?: string ): string { - return translateWorkflowLabelWithCopy(inspectorCopy, label ?? formatParameterLabel(paramId)) + return label ?? formatParameterLabel(paramId) } function localizeToolUiComponentOptionsWithCopy( @@ -656,60 +603,18 @@ function localizeToolUiComponentOptionsWithCopy( ? getToolParameterOverride(inspectorCopy, blockType, toolId, param.id) : undefined const override = mergeSubBlockOverrides(subBlockOverride, toolParameterOverride) - const optionOverrideMap = getOptionOverrideMap(override?.options) - const configI18n = param.uiComponent.i18n return { ...param.uiComponent, - title: override?.title - ? translateWorkflowLabelWithCopy(inspectorCopy, override.title) - : param.uiComponent.title - ? resolveWorkflowText(inspectorCopy, param.uiComponent.title, configI18n?.titleKey) - : param.uiComponent.title, - placeholder: override?.placeholder - ? translateWorkflowLabelWithCopy(inspectorCopy, override.placeholder) - : param.uiComponent.placeholder - ? resolveWorkflowText( - inspectorCopy, - param.uiComponent.placeholder, - configI18n?.placeholderKey - ) - : param.uiComponent.placeholder, - searchPlaceholder: override?.searchPlaceholder - ? translateWorkflowLabelWithCopy(inspectorCopy, override.searchPlaceholder) - : param.uiComponent.searchPlaceholder - ? resolveWorkflowText( - inspectorCopy, - param.uiComponent.searchPlaceholder, - configI18n?.searchPlaceholderKey - ) - : param.uiComponent.searchPlaceholder, - description: override?.description - ? translateWorkflowLabelWithCopy(inspectorCopy, override.description) - : param.uiComponent.description - ? resolveWorkflowText( - inspectorCopy, - param.uiComponent.description, - configI18n?.descriptionKey - ) - : param.uiComponent.description, - tooltip: override?.tooltip - ? translateWorkflowLabelWithCopy(inspectorCopy, override.tooltip) - : param.uiComponent.tooltip - ? resolveWorkflowText(inspectorCopy, param.uiComponent.tooltip, configI18n?.tooltipKey) - : param.uiComponent.tooltip, + title: override?.title ?? param.uiComponent.title, + placeholder: override?.placeholder ?? param.uiComponent.placeholder, + searchPlaceholder: override?.searchPlaceholder ?? param.uiComponent.searchPlaceholder, + description: override?.description ?? param.uiComponent.description, + tooltip: override?.tooltip ?? param.uiComponent.tooltip, options: param.uiComponent.options?.map((option) => - localizeWorkflowOption(inspectorCopy, option as WorkflowOption, optionOverrideMap) + localizeWorkflowOption(inspectorCopy, option as WorkflowOption, override?.options) ), - columns: override?.columns - ? override.columns.map((column) => translateWorkflowLabelWithCopy(inspectorCopy, column)) - : configI18n?.columnKeys - ? configI18n.columnKeys.map((columnKey) => - translateWorkflowLabelWithCopy(inspectorCopy, columnKey) - ) - : param.uiComponent.columns?.map((column) => - translateWorkflowLabelWithCopy(inspectorCopy, column) - ), + columns: override?.columns ?? param.uiComponent.columns, } } @@ -725,15 +630,10 @@ export function localizeToolParameterWithCopy( ? getToolParameterOverride(inspectorCopy, blockType, toolId, param.id) : undefined const override = mergeSubBlockOverrides(subBlockOverride, toolParameterOverride) - const configI18n = param.i18n return { ...param, - description: override?.description - ? translateWorkflowLabelWithCopy(inspectorCopy, override.description) - : param.description - ? resolveWorkflowText(inspectorCopy, param.description, configI18n?.descriptionKey) - : undefined, + description: override?.description ?? param.description, uiComponent: localizeToolUiComponentOptionsWithCopy(inspectorCopy, param, blockType, toolId), } } @@ -786,42 +686,12 @@ export function localizeWorkflowSubBlockConfigWithCopy( return { ...config, - title: override?.title - ? translateWorkflowLabelWithCopy(inspectorCopy, override.title) - : config.title - ? resolveWorkflowText(inspectorCopy, config.title, config.i18n?.titleKey) - : undefined, - placeholder: override?.placeholder - ? translateWorkflowLabelWithCopy(inspectorCopy, override.placeholder) - : config.placeholder - ? resolveWorkflowText(inspectorCopy, config.placeholder, config.i18n?.placeholderKey) - : undefined, - searchPlaceholder: override?.searchPlaceholder - ? translateWorkflowLabelWithCopy(inspectorCopy, override.searchPlaceholder) - : config.searchPlaceholder - ? resolveWorkflowText( - inspectorCopy, - config.searchPlaceholder, - config.i18n?.searchPlaceholderKey - ) - : undefined, - description: override?.description - ? translateWorkflowLabelWithCopy(inspectorCopy, override.description) - : config.description - ? resolveWorkflowText(inspectorCopy, config.description, config.i18n?.descriptionKey) - : config.description, - tooltip: override?.tooltip - ? translateWorkflowLabelWithCopy(inspectorCopy, override.tooltip) - : config.tooltip - ? resolveWorkflowText(inspectorCopy, config.tooltip, config.i18n?.tooltipKey) - : config.tooltip, - columns: override?.columns - ? override.columns.map((column) => translateWorkflowLabelWithCopy(inspectorCopy, column)) - : config.i18n?.columnKeys - ? config.i18n.columnKeys.map((columnKey) => - translateWorkflowLabelWithCopy(inspectorCopy, columnKey) - ) - : config.columns?.map((column) => translateWorkflowLabelWithCopy(inspectorCopy, column)), + title: override?.title ?? config.title, + placeholder: override?.placeholder ?? config.placeholder, + searchPlaceholder: override?.searchPlaceholder ?? config.searchPlaceholder, + description: override?.description ?? config.description, + tooltip: override?.tooltip ?? config.tooltip, + columns: override?.columns ?? config.columns, defaultValue, options: localizeWorkflowOptionsWithCopy( inspectorCopy, diff --git a/apps/tradinggoose/lib/auth/auth-error-handler.ts b/apps/tradinggoose/lib/auth/auth-error-handler.ts index 2262e10a5..11279fffd 100644 --- a/apps/tradinggoose/lib/auth/auth-error-handler.ts +++ b/apps/tradinggoose/lib/auth/auth-error-handler.ts @@ -1,8 +1,7 @@ 'use client' import { createLogger } from '@/lib/logs/console/logger' -import { getRouteBoundaryHref } from '@/i18n/route-boundary' -import { stripLocaleFromPathname } from '@/i18n/utils' +import { localizeUrl, stripLocaleFromPathname } from '@/i18n/utils' const logger = createLogger('AuthErrorHandler') let isHandlingAuthError = false @@ -85,7 +84,11 @@ export async function handleAuthError(reason?: string) { const callbackUrl = `${pathname}${window.location.search}` logger.warn('Handling authentication error', { reason, callbackUrl }) window.location.replace( - getRouteBoundaryHref(locale, `/login?reauth=1&callbackUrl=${encodeURIComponent(callbackUrl)}`) + localizeUrl( + window.location.origin, + locale, + `/login?reauth=1&callbackUrl=${encodeURIComponent(callbackUrl)}` + ) ) } diff --git a/apps/tradinggoose/lib/email/locale.ts b/apps/tradinggoose/lib/email/locale.ts index a2e672972..6b4837c7b 100644 --- a/apps/tradinggoose/lib/email/locale.ts +++ b/apps/tradinggoose/lib/email/locale.ts @@ -1,10 +1,10 @@ import { db } from '@tradinggoose/db' import { settings, user, waitlist } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' -import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' +import { isLocaleCode, type LocaleCode, normalizeLocaleCode } from '@/i18n/utils' export function normalizeEmailLocale(locale: string | null | undefined): LocaleCode { - return locale && isLocaleCode(locale) ? locale : defaultLocale + return normalizeLocaleCode(locale) } function normalizeEmail(email: string) { diff --git a/apps/tradinggoose/lib/email/mailer.ts b/apps/tradinggoose/lib/email/mailer.ts index 1d7559033..ef1f4df55 100644 --- a/apps/tradinggoose/lib/email/mailer.ts +++ b/apps/tradinggoose/lib/email/mailer.ts @@ -1,5 +1,6 @@ import { EmailClient, type EmailMessage } from '@azure/communication-email' import { Resend } from 'resend' +import type { EmailLocale } from '@/components/emails/email-copy' import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/email/unsubscribe' import { getFromEmailAddress } from '@/lib/email/utils' import { createLogger } from '@/lib/logs/console/logger' @@ -8,6 +9,7 @@ import { resolveResendServiceConfig, } from '@/lib/system-services/runtime' import { getBaseUrl } from '@/lib/urls/utils' +import { localizeUrl } from '@/i18n/utils' const logger = createLogger('Mailer') @@ -30,6 +32,7 @@ export interface EmailOptions { includeUnsubscribe?: boolean attachments?: EmailAttachment[] replyTo?: string + locale?: EmailLocale } export interface BatchEmailOptions { @@ -207,6 +210,7 @@ async function processEmailData(options: EmailOptions): Promise` headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click' diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index e739232f6..2aed57ea2 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -1,6 +1,6 @@ import { getSessionCookie } from 'better-auth/cookies' -import createMiddleware from 'next-intl/middleware' import { type NextRequest, NextResponse } from 'next/server' +import createMiddleware from 'next-intl/middleware' import { appendHomepageDiscoveryLinks } from '@/lib/discovery/link-headers' import { appendVaryHeader, @@ -9,13 +9,14 @@ import { MARKDOWN_RENDER_ROUTE, requestAcceptsMarkdown, } from '@/lib/markdown/negotiation' +import { routing } from '@/i18n/routing' import { defaultLocale, isLocaleCode, type LocaleCode, + localizeUrl, + stripLocaleFromPathname, } from '@/i18n/utils' -import { getRouteBoundaryHref } from '@/i18n/route-boundary' -import { routing } from '@/i18n/routing' import { createLogger } from './lib/logs/console/logger' import { generateRuntimeCSP } from './lib/security/csp' @@ -58,22 +59,12 @@ interface LocaleRoute { } function resolveLocaleRoute(pathname: string): LocaleRoute { - const segments = pathname.split('/').filter(Boolean) - const firstSegment = segments[0] - - if (firstSegment && isLocaleCode(firstSegment)) { - const stripped = `/${segments.slice(1).join('/')}`.replace(/\/+$/, '') - return { - locale: firstSegment, - pathname: stripped || '/', - hasLocalePrefix: true, - } - } - + const firstSegment = pathname.split('/').filter(Boolean)[0] + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(pathname) return { - locale: defaultLocale, - pathname: pathname || '/', - hasLocalePrefix: false, + locale, + pathname: normalizedPathname, + hasLocalePrefix: Boolean(firstSegment && isLocaleCode(firstSegment)), } } @@ -103,7 +94,7 @@ function isCanonicalRouteHandlerPath(pathname: string) { function buildLoginRedirect(request: NextRequest, callback?: string) { const { locale } = resolveLocaleRoute(request.nextUrl.pathname) - const loginUrl = new URL(getRouteBoundaryHref(locale, '/login'), request.url) + const loginUrl = new URL(localizeUrl(request.nextUrl.origin, locale, '/login')) if (callback) { loginUrl.searchParams.set('callbackUrl', callback) @@ -233,7 +224,7 @@ export async function proxy(request: NextRequest) { } if (hasActiveSession) { - return NextResponse.redirect(new URL(getRouteBoundaryHref(locale, '/workspace'), request.url)) + return NextResponse.redirect(new URL(localizeUrl(url.origin, locale, '/workspace'))) } } diff --git a/apps/tradinggoose/tools/params.ts b/apps/tradinggoose/tools/params.ts index 02972e87d..1d9395fcb 100644 --- a/apps/tradinggoose/tools/params.ts +++ b/apps/tradinggoose/tools/params.ts @@ -53,9 +53,6 @@ export interface ToolParameterConfig { userProvided?: boolean // User filled this parameter description?: string default?: unknown - i18n?: { - descriptionKey?: string - } // UI component information from block config uiComponent?: UIComponentConfig } @@ -236,14 +233,7 @@ export function getToolParametersConfig( autoSelectFirstOption: subBlock.autoSelectFirstOption, dependsOn: resolveDependsOn(subBlock.dependsOn), fetchOptions: subBlock.fetchOptions, - i18n: subBlock.i18n, } - toolParam.i18n = - subBlock.i18n?.descriptionKey || toolParam.description - ? { - descriptionKey: subBlock.i18n?.descriptionKey, - } - : undefined } } diff --git a/apps/tradinggoose/vitest.setup.ts b/apps/tradinggoose/vitest.setup.ts index 1b203fc1e..a1070fdc8 100644 --- a/apps/tradinggoose/vitest.setup.ts +++ b/apps/tradinggoose/vitest.setup.ts @@ -23,6 +23,7 @@ global.localStorage = storageMock as any global.sessionStorage = storageMock as any vi.mock('next-intl', async () => { + const actual = await vi.importActual('next-intl') const React = await import('react') const LocaleContext = React.createContext('en') const MessagesContext = React.createContext(enMessages) @@ -36,20 +37,29 @@ vi.mock('next-intl', async () => { }, messages) const formatMessage = ( + locale: string, template: string, values?: Record ) => { - if (!values) { - return template + let formatError: unknown + const translator = actual.createTranslator({ + locale, + messages: { value: template }, + onError(error) { + formatError = error + }, + }) + const formatted = translator('value', values as Record) + + if (formatError) { + throw formatError } - return Object.entries(values).reduce( - (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value ?? '')), - template - ) + return formatted } return { + ...actual, NextIntlClientProvider: ({ children, locale, messages }: any) => React.createElement( LocaleContext.Provider, @@ -60,6 +70,7 @@ vi.mock('next-intl', async () => { useMessages: () => React.useContext(MessagesContext), useTranslations: (namespace?: string) => { const messages = React.useContext(MessagesContext) + const locale = React.useContext(LocaleContext) return ( key: string, @@ -72,7 +83,7 @@ vi.mock('next-intl', async () => { return fullKey } - return formatMessage(resolved, values) + return formatMessage(locale, resolved, values) } }, } @@ -180,9 +191,7 @@ vi.mock('@/blocks/registry', () => { Object.values(registry).filter((block) => block.category === category) ) const getAllBlockTypes = vi.fn(() => Object.keys(registry)) - const isValidBlockType = vi.fn((type: string) => - Object.prototype.hasOwnProperty.call(registry, type) - ) + const isValidBlockType = vi.fn((type: string) => Object.hasOwn(registry, type)) return { registry, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx index f1b19b9c4..fb1673a90 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react' import { useLocale } from 'next-intl' import type { SubBlockConfig } from '@/blocks/types' +import { translateWorkflowLabel } from '@/i18n/block-editor' +import type { LocaleCode } from '@/i18n/utils' import { type SlackChannelInfo, SlackChannelSelector, @@ -10,9 +12,7 @@ import { import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' -import { translateWorkflowLabel } from '@/i18n/block-editor' import { useWorkflowId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import type { LocaleCode } from '@/i18n/utils' interface ChannelSelectorInputProps { blockId: string @@ -86,7 +86,7 @@ export function ChannelSelectorInput({ handleChannelChange(channelId, channelInfo) }} credential={credential} - label={subBlock.placeholder || translateWorkflowLabel(locale, 'Select Slack channel')} + label={subBlock.placeholder || translateWorkflowLabel(locale, 'selectSlackChannel')} disabled={finalDisabled} workflowId={workflowIdFromUrl} isForeignCredential={isForeignCredential} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx index e881ecc43..98bfce756 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { SlackIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -44,16 +44,16 @@ export function SlackChannelSelector({ const locale = useLocale() as LocaleCode const selectorCopy = useWorkspaceBlockEditorMessages().slackChannelSelector const copy = { - selectSlackChannel: translateWorkflowLabel(locale, 'Select Slack channel'), - searchChannels: translateWorkflowLabel(locale, 'Search channels...'), - loadingChannels: translateWorkflowLabel(locale, 'Loading channels...'), - missingCredentials: translateWorkflowLabel(locale, 'Missing credentials'), - configureSlackCredentials: translateWorkflowLabel(locale, 'Please configure Slack credentials.'), - noChannelsFound: translateWorkflowLabel(locale, 'No channels found'), - noChannelsAvailable: translateWorkflowLabel(locale, 'No channels available for this Slack workspace.'), - channels: translateWorkflowLabel(locale, 'Channels'), - private: translateWorkflowLabel(locale, 'Private'), - usingSharedAccount: translateWorkflowLabel(locale, 'Using a shared account'), + selectSlackChannel: translateWorkflowLabel(locale, 'selectSlackChannel'), + searchChannels: translateWorkflowLabel(locale, 'searchChannels'), + loadingChannels: translateWorkflowLabel(locale, 'loadingChannels'), + missingCredentials: translateWorkflowLabel(locale, 'missingCredentials'), + configureSlackCredentials: translateWorkflowLabel(locale, 'configureSlackCredentials'), + noChannelsFound: translateWorkflowLabel(locale, 'noChannelsFound'), + noChannelsAvailable: translateWorkflowLabel(locale, 'noChannelsAvailable'), + channels: translateWorkflowLabel(locale, 'channels'), + private: translateWorkflowLabel(locale, 'private'), + usingSharedAccount: translateWorkflowLabel(locale, 'usingSharedAccount'), } type SlackChannelSelectorErrorCode = keyof typeof selectorCopy.errors const labelText = label ?? copy.selectSlackChannel @@ -225,7 +225,9 @@ export function SlackChannelSelector({ {getChannelIcon(channel)} {formatChannelName(channel)} {channel.isPrivate && ( - {copy.private} + + {copy.private} + )} {channel.id === value && } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx index b7c493a3b..1e3b55c93 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx @@ -1,28 +1,28 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useLocale } from 'next-intl' -import { Check, ChevronDown } from 'lucide-react' import { useReactFlow } from '@xyflow/react' +import { Check, ChevronDown } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console/logger' -import { translateWorkflowLabel } from '@/i18n/block-editor' import { cn } from '@/lib/utils' +import type { SubBlockConfig } from '@/blocks/types' +import { useTagSelection } from '@/hooks/use-tag-selection' +import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' +import { translateWorkflowLabel } from '@/i18n/block-editor' import type { LocaleCode } from '@/i18n/utils' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' -import type { SubBlockConfig } from '@/blocks/types' -import { useTagSelection } from '@/hooks/use-tag-selection' const logger = createLogger('ComboBox') interface ComboBoxProps { options: - | Array - | (() => Array) + | Array + | (() => Array) defaultValue?: string blockId: string subBlockId: string @@ -45,7 +45,7 @@ export function ComboBox({ config, }: ComboBoxProps) { const locale = useLocale() as LocaleCode - const placeholderText = placeholder ?? translateWorkflowLabel(locale, 'Type or select an option...') + const placeholderText = placeholder ?? translateWorkflowLabel(locale, 'typeOrSelectAnOption') const workspaceId = useWorkspaceId() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const [storeInitialized, setStoreInitialized] = useState(false) @@ -417,8 +417,8 @@ export function ComboBox({ className={cn( 'allow-scroll w-full overflow-auto pr-10 text-transparent caret-foreground placeholder:text-muted-foreground/50', isConnecting && - config?.connectionDroppable !== false && - 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500', + config?.connectionDroppable !== false && + 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500', SelectedIcon ? 'pl-8' : '' )} placeholder={placeholderText} @@ -472,9 +472,7 @@ export function ComboBox({ {/* Dropdown */} {open && ( -
+
{filteredOptions.length === 0 ? (
- {translateWorkflowLabel(locale, 'No matching options found.')} + {translateWorkflowLabel(locale, 'noMatchingOptionsFound')}
) : ( filteredOptions.map((option, index) => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/condition-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/condition-input.tsx index 201a19b28..321d2f7e0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/condition-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/condition-input.tsx @@ -70,6 +70,19 @@ const getConditionBranchTitle = (index: number, total: number): ConditionBranchT const isElseBranchTitle = (title: string): boolean => title === CONDITION_BRANCH_TITLES.else +const getLocalizedConditionBranchTitle = (locale: LocaleCode, title: string) => { + switch (title) { + case CONDITION_BRANCH_TITLES.if: + return translateWorkflowLabel(locale, 'if') + case CONDITION_BRANCH_TITLES.elseIf: + return translateWorkflowLabel(locale, 'elseIf') + case CONDITION_BRANCH_TITLES.else: + return translateWorkflowLabel(locale, 'else') + default: + return title + } +} + export const applyConditionBlockTitles = (blocks: T[]): T[] => { return blocks.map((block, index) => ({ ...block, @@ -507,7 +520,7 @@ export function ConditionInput({ )} > - {translateWorkflowLabel(locale, block.title)} + {getLocalizedConditionBranchTitle(locale, block.title)}
diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index fe9cae9a0..db1955cd5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { @@ -49,10 +49,10 @@ export function CredentialSelector({ const locale = useLocale() as LocaleCode const copy = useAppMessages().workspace.widgets.blockEditor.toolInput const labelCopy = { - searchCredentials: translateWorkflowLabel(locale, 'Search credentials...'), - loadingCredentials: translateWorkflowLabel(locale, 'Loading credentials...'), - noCredentialsFound: translateWorkflowLabel(locale, 'No credentials found.'), - connectNewAccountToContinue: translateWorkflowLabel(locale, 'Connect a new account to continue.'), + searchCredentials: translateWorkflowLabel(locale, 'searchCredentials'), + loadingCredentials: translateWorkflowLabel(locale, 'loadingCredentials'), + noCredentialsFound: translateWorkflowLabel(locale, 'noCredentialsFound'), + connectNewAccountToContinue: translateWorkflowLabel(locale, 'connectNewAccountToContinue'), } const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -67,7 +67,7 @@ export function CredentialSelector({ // Extract values from subBlock config const provider = subBlock.provider as OAuthProvider const requiredScopes = subBlock.requiredScopes || [] - const label = subBlock.placeholder || translateWorkflowLabel(locale, 'Select credential') + const label = subBlock.placeholder || translateWorkflowLabel(locale, 'selectCredential') const serviceId = subBlock.serviceId const serviceIds = subBlock.serviceIds @@ -168,7 +168,7 @@ export function CredentialSelector({ const isForeign = !!(selectedId && selectedCredential?.isOwner === false) const displayName = isForeign - ? translateWorkflowLabel(locale, 'Saved by collaborator') + ? translateWorkflowLabel(locale, 'savedByCollaborator') : selectedCredential ? selectedCredential.name : undefined @@ -281,9 +281,9 @@ export function CredentialSelector({
{getProviderIcon(cred.provider)} - {cred.isOwner === false - ? translateWorkflowLabel(locale, 'Saved by collaborator') - : cred.name} + {cred.isOwner === false + ? translateWorkflowLabel(locale, 'savedByCollaborator') + : cred.name} {showServiceNames ? ( diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx index 06a77775a..065abbdc5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { useLocale } from 'next-intl' import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -13,12 +13,12 @@ import { CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { SubBlockConfig } from '@/blocks/types' import { translateWorkflowLabel } from '@/i18n/block-editor' import { formatTemplate, useAppMessages } from '@/i18n/client-messages' import type { LocaleCode } from '@/i18n/utils' import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' -import type { SubBlockConfig } from '@/blocks/types' interface DocumentData { id: string @@ -53,9 +53,7 @@ export function DocumentSelector({ }: DocumentSelectorProps) { const locale = useLocale() as LocaleCode const selectorCopy = useAppMessages().workspace.widgets.blockEditor.documentSelector - type DocumentSelectorErrorCode = - | keyof typeof selectorCopy.errors - | 'noKnowledgeBaseSelected' + type DocumentSelectorErrorCode = keyof typeof selectorCopy.errors | 'noKnowledgeBaseSelected' const [documents, setDocuments] = useState([]) const [error, setError] = useState(null) const [open, setOpen] = useState(false) @@ -164,23 +162,21 @@ export function DocumentSelector({ const getDocumentDescription = (document: DocumentData) => { const statusMap: Record = { - pending: translateWorkflowLabel(locale, 'Processing pending'), - processing: translateWorkflowLabel(locale, 'Processing...'), - completed: translateWorkflowLabel(locale, 'Ready'), - failed: translateWorkflowLabel(locale, 'Processing failed'), + pending: translateWorkflowLabel(locale, 'processingPending'), + processing: translateWorkflowLabel(locale, 'processing'), + completed: translateWorkflowLabel(locale, 'ready'), + failed: translateWorkflowLabel(locale, 'processingFailed'), } const status = statusMap[document.processingStatus] || document.processingStatus const chunkTemplate = - document.chunkCount === 1 - ? selectorCopy.chunkCountSingular - : selectorCopy.chunkCountPlural + document.chunkCount === 1 ? selectorCopy.chunkCountSingular : selectorCopy.chunkCountPlural const chunkText = formatTemplate(chunkTemplate, { count: document.chunkCount }) return `${status} • ${chunkText}` } - const label = subBlock.placeholder || translateWorkflowLabel(locale, 'Select document') + const label = subBlock.placeholder || translateWorkflowLabel(locale, 'selectDocument') return (
@@ -206,14 +202,14 @@ export function DocumentSelector({ - + {loading ? (
- {translateWorkflowLabel(locale, 'Loading documents...')} + {translateWorkflowLabel(locale, 'loadingDocuments')}
) : error && error !== 'noKnowledgeBaseSelected' ? ( @@ -223,21 +219,21 @@ export function DocumentSelector({ ) : !knowledgeBaseId || error === 'noKnowledgeBaseSelected' ? (

- {translateWorkflowLabel(locale, 'No knowledge base selected')} + {translateWorkflowLabel(locale, 'noKnowledgeBaseSelected')}

- {translateWorkflowLabel(locale, 'Please select a knowledge base first.')} + {translateWorkflowLabel(locale, 'pleaseSelectAKnowledgeBaseFirst')}

) : (

- {translateWorkflowLabel(locale, 'No documents found')} + {translateWorkflowLabel(locale, 'noDocumentsFound')}

{translateWorkflowLabel( locale, - 'Upload documents to this knowledge base to get started.' + 'uploadDocumentsToThisKnowledgeBaseToGetStarted' )}

@@ -247,7 +243,7 @@ export function DocumentSelector({ {documents.length > 0 && (
- {translateWorkflowLabel(locale, 'Documents')} + {translateWorkflowLabel(locale, 'documents')}
{documents.map((document) => ( translateWorkflowLabel(locale, label) + const t = (key: string) => translateWorkflowLabel(locale, key) const copy = useAppMessages().workspace.widgets.blockEditor.documentTagEntry const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) @@ -252,15 +252,15 @@ export function DocumentTagEntry({ } if (isLoading) { - return
{t('Loading tag definitions...')}
+ return
{t('loadingTagDefinitions')}
} const renderHeader = () => (
{t('Tag Name')}{t('Type')}{t('Value')}{t('tagName')}{t('type')}{t('value')}
{t('Tag Name')}{t('Value')}{t('tagName')}{t('value')}
translateWorkflowLabel(column))} + columns={config.columns ?? []} disabled={isDisabled} /> ) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts index 9d6de5d5b..7cdaf76ca 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts @@ -30,11 +30,11 @@ import { useBlockEditorMessages, useDeploymentMessages, useMcpDropdownMessages, - useWorkspaceBlockEditorMessages, useWorkflowApiKeyMessages, useWorkflowInspectorMessages, useWorkflowOutputSelectMessages, useWorkflowToolbarMessages, + useWorkspaceBlockEditorMessages, } from '@/i18n/workspace-widget-hooks' export function useWorkflowEditorCopy() { @@ -84,28 +84,28 @@ export function useWorkflowI18n() { toolInputCopy: getToolInputCopyFromInspector(inspectorCopy), mcpToolSelectorCopy: getMcpToolSelectorCopyFromInspector(inspectorCopy), readOnlyPreviewCopy: getReadOnlyPreviewCopyFromInspector(inspectorCopy), - translateWorkflowLabel: (label: string) => translateWorkflowLabelWithCopy(inspectorCopy, label), - translateWorkflowToolbarLabel: (label: string) => - translateWorkflowToolbarLabelWithCopy(toolbarCopy, label), + translateWorkflowLabel: (key: string) => translateWorkflowLabelWithCopy(inspectorCopy, key), + translateWorkflowToolbarLabel: (key: string) => + translateWorkflowToolbarLabelWithCopy(toolbarCopy, key), getToolbarDisabledReason: (isOfflineMode: boolean) => getToolbarDisabledReasonFromInspector(inspectorCopy, isOfflineMode), getTriggerWarningCopy: (triggerName: string) => getTriggerWarningCopyFromInspector(inspectorCopy, triggerName), getLocalizedBlockName: ( blockOrType: Parameters[1], - fallbackName?: string - ) => getLocalizedBlockNameWithCopy(inspectorCopy, blockOrType, fallbackName), + providedName?: string + ) => getLocalizedBlockNameWithCopy(inspectorCopy, blockOrType, providedName), getLocalizedBlockDescription: ( blockOrType: Parameters[1], - fallbackDescription?: string - ) => getLocalizedBlockDescriptionWithCopy(inspectorCopy, blockOrType, fallbackDescription), + providedDescription?: string + ) => getLocalizedBlockDescriptionWithCopy(inspectorCopy, blockOrType, providedDescription), getLocalizedBlockLongDescription: ( blockOrType: Parameters[1], - fallbackDescription?: string - ) => getLocalizedBlockLongDescriptionWithCopy(inspectorCopy, blockOrType, fallbackDescription), - getLocalizedBlockMetadata: ( - block: Parameters[1] - ) => getLocalizedBlockMetadataWithCopy(inspectorCopy, block), + providedDescription?: string + ) => + getLocalizedBlockLongDescriptionWithCopy(inspectorCopy, blockOrType, providedDescription), + getLocalizedBlockMetadata: (block: Parameters[1]) => + getLocalizedBlockMetadataWithCopy(inspectorCopy, block), getLocalizedDefaultBlockName: (blockType: string, blockName?: string) => getLocalizedDefaultBlockNameWithCopy(inspectorCopy, blockType, blockName), getLocalizedToolParameterLabel: (paramId: string, label?: string) => @@ -119,13 +119,15 @@ export function useWorkflowI18n() { toolId: string, blockConfig?: Parameters[2], contextValues?: Parameters[3] - ) => getLocalizedToolParametersConfigWithCopy(inspectorCopy, toolId, blockConfig, contextValues), + ) => + getLocalizedToolParametersConfigWithCopy(inspectorCopy, toolId, blockConfig, contextValues), localizeWorkflowOptions: ( options: Parameters[1], blockType?: string, subBlockId?: string, triggerId?: string - ) => localizeWorkflowOptionsWithCopy(inspectorCopy, options, blockType, subBlockId, triggerId), + ) => + localizeWorkflowOptionsWithCopy(inspectorCopy, options, blockType, subBlockId, triggerId), localizeWorkflowSubBlockConfig: ( config: Parameters[1], blockType?: string, diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx index cb0d1c780..a066fcae8 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx @@ -5,8 +5,8 @@ import { useLocale } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useAppMessages } from '@/i18n/client-messages' import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' +import { formatTemplate, useAppMessages } from '@/i18n/client-messages' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useSkillsStore } from '@/stores/skills/store' import type { PairColor } from '@/widgets/pair-colors' @@ -145,7 +145,9 @@ export function SkillList({ } if (normalizedName.length > SKILL_NAME_MAX_LENGTH) { - throw new Error(skillValidationCopy.nameTooLong.replace('{{max}}', String(SKILL_NAME_MAX_LENGTH))) + throw new Error( + formatTemplate(skillValidationCopy.nameTooLong, { max: SKILL_NAME_MAX_LENGTH }) + ) } await updateMutation.mutateAsync({ diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx index e97db8ec0..d98ad4b27 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/components/output-select/output-select.tsx @@ -4,12 +4,12 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { Check, ChevronDown, Search } from 'lucide-react' import { createPortal } from 'react-dom' import { Input } from '@/components/ui/input' -import { formatTemplate } from '@/i18n/utils' -import { useWorkflowOutputSelectMessages } from '@/i18n/workspace-widget-hooks' import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' import { useWorkflowBlocks, useWorkflowEdges } from '@/lib/yjs/use-workflow-doc' import { getBlock } from '@/blocks' +import { formatTemplate } from '@/i18n/utils' +import { useWorkflowOutputSelectMessages } from '@/i18n/workspace-widget-hooks' interface OutputSelectProps { workflowId: string | null @@ -121,7 +121,7 @@ export function OutputSelect({ if (Object.keys(outputsToProcess).length > 0) { const blockDisplayName = block.name || - formatTemplate(copy.fallbackBlockName, { + formatTemplate(copy.defaultBlockName, { id: block.id, }) @@ -182,7 +182,7 @@ export function OutputSelect({ }) return outputs - }, [copy.fallbackBlockName, workflowBlocks, workflowId, blocks]) + }, [copy.defaultBlockName, workflowBlocks, workflowId, blocks]) const isSelectedValue = (o: { id: string }) => selectedOutputs.includes(o.id) @@ -193,9 +193,7 @@ export function OutputSelect({ } // Ensure all selected outputs exist in the workflowOutputs array by canonical id - const validOutputs = selectedOutputs.filter((val) => - workflowOutputs.some((o) => o.id === val) - ) + const validOutputs = selectedOutputs.filter((val) => workflowOutputs.some((o) => o.id === val)) if (validOutputs.length === 0) { return { selectedOutputsDisplayText: resolvedPlaceholder, hasSelectedOutputs: false } @@ -221,9 +219,7 @@ export function OutputSelect({ const selectedOutputInfo = useMemo(() => { if (!selectedOutputs || selectedOutputs.length === 0) return null - const validOutputs = selectedOutputs.filter((val) => - workflowOutputs.some((o) => o.id === val) - ) + const validOutputs = selectedOutputs.filter((val) => workflowOutputs.some((o) => o.id === val)) if (validOutputs.length === 0) return null const output = workflowOutputs.find((o) => o.id === validOutputs[0]) @@ -243,12 +239,12 @@ export function OutputSelect({ const filteredOutputs = !normalizedQuery ? workflowOutputs : workflowOutputs.filter((output) => { - return ( - output.label.toLowerCase().includes(normalizedQuery) || - output.blockName.toLowerCase().includes(normalizedQuery) || - output.path.toLowerCase().includes(normalizedQuery) - ) - }) + return ( + output.label.toLowerCase().includes(normalizedQuery) || + output.blockName.toLowerCase().includes(normalizedQuery) || + output.path.toLowerCase().includes(normalizedQuery) + ) + }) const groups: Record = {} const blockDistances: Record = {} @@ -359,15 +355,15 @@ export function OutputSelect({ const triggerButtonClassName = triggerClassName ? cn(triggerClassName, 'justify-between') : cn( - 'flex h-9 w-full items-center justify-between rounded-sm px-3 py-1.5 font-normal text-sm shadow-xs transition-colors', - isOutputDropdownOpen - ? 'bg-background text-muted-foreground' - : 'bg-background text-muted-foreground hover:text-muted-foreground' - ) + 'flex h-9 w-full items-center justify-between rounded-sm px-3 py-1.5 font-normal text-sm shadow-xs transition-colors', + isOutputDropdownOpen + ? 'bg-background text-muted-foreground' + : 'bg-background text-muted-foreground hover:text-muted-foreground' + ) const colorBadge = selectedOutputInfo ? (
+ {selectedOutputsDisplayText} ) : ( - + {selectedOutputsDisplayText} ) @@ -505,7 +501,7 @@ export function OutputSelect({ data-rs-scroll-lock-ignore >
@@ -539,7 +535,7 @@ export function OutputSelect({ Object.entries(groupedOutputs).map(([blockName, outputs]) => { return (
-
+
{blockName}
From 75197a32f2cd374d9b1772571753beffd5a0a60b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 2 Jun 2026 20:31:02 -0600 Subject: [PATCH 25/49] refactor(i18n): replace custom message hooks with next-intl Co-authored-by: Codex --- .../(auth)/components/auth-waitlist-note.tsx | 4 +- .../components/social-login-buttons.tsx | 5 +- .../(auth)/components/sso-login-button.tsx | 4 +- .../app/(auth)/login/login-form.tsx | 4 +- .../reset-password/reset-password-form.tsx | 6 +- .../app/(auth)/signup/signup-form.tsx | 6 +- apps/tradinggoose/app/(auth)/sso/sso-form.tsx | 4 +- .../app/(auth)/verify/use-verification.ts | 4 +- .../app/(auth)/verify/verify-content.tsx | 5 +- .../app/(auth)/waitlist/waitlist-form.tsx | 4 +- .../blog/components/ai-summarize.tsx | 5 +- .../blog/components/breadcrumb-nav.tsx | 4 +- .../blog/components/filtered-posts.tsx | 5 +- .../(landing)/blog/components/post-card.tsx | 4 +- .../blog/components/social-share.tsx | 5 +- .../blog/components/table-of-contents.tsx | 4 +- .../app/(landing)/careers/careers-form.tsx | 4 +- .../app/(landing)/components/cta/cta.tsx | 4 +- .../layout-preview/layout-preview.tsx | 5 +- .../landing-indicator-dropdown.tsx | 4 +- .../market-preview/landing-widget-shell.tsx | 4 +- .../workflow-preview-canvas.tsx | 4 +- .../(landing)/components/feature/feature.tsx | 4 +- .../(landing)/components/footer/footer.tsx | 3 +- .../app/(landing)/components/hero/hero.tsx | 4 +- .../components/integrations/integrations.tsx | 30 +----- .../landing-pricing/landing-pricing.tsx | 4 +- .../app/(landing)/components/legal-layout.tsx | 4 +- .../monitor-preview/monitor-preview.tsx | 4 +- .../app/(landing)/components/nav/nav.test.tsx | 23 ++++- .../app/(landing)/components/nav/nav.tsx | 21 +++-- .../(landing)/components/structured-data.tsx | 73 ++------------- .../[locale]/(auth)/reset-password/page.tsx | 6 +- .../app/[locale]/(landing)/blog/page.tsx | 3 +- .../app/[locale]/(landing)/privacy/page.tsx | 3 +- .../app/[locale]/(landing)/terms/page.tsx | 3 +- .../app/[locale]/changelog/page.tsx | 4 +- .../app/admin/billing/billing-admin.tsx | 7 +- .../app/admin/billing/tier-detail.tsx | 6 +- .../app/admin/billing/tier-editor.tsx | 6 +- apps/tradinggoose/app/admin/errors.ts | 4 +- .../admin/integrations/integrations-admin.tsx | 12 ++- .../admin/registration/registration-admin.tsx | 18 ++-- .../app/admin/services/services-admin.tsx | 12 ++- .../app/admin/system-settings-section.tsx | 4 +- .../app/api/users/me/settings/route.ts | 23 ++++- .../changelog/components/timeline-list.tsx | 5 +- .../app/chat/[identifier]/chat.tsx | 4 +- .../chat/components/auth/email/email-auth.tsx | 6 +- .../auth/password/password-auth.tsx | 4 +- .../app/chat/components/auth/sso/sso-auth.tsx | 4 +- .../components/error-state/error-state.tsx | 4 +- .../app/chat/components/header/header.tsx | 6 +- .../app/chat/components/input/input.tsx | 6 +- .../message-container/message-container.tsx | 4 +- .../app/chat/components/message/message.tsx | 4 +- .../voice-interface/voice-interface.tsx | 4 +- apps/tradinggoose/app/chat/errors.ts | 4 +- .../app/chat/hooks/use-chat-streaming.ts | 4 +- .../app/invite/[id]/invite.test.tsx | 48 ---------- apps/tradinggoose/app/invite/[id]/invite.tsx | 5 +- .../app/invite/components/status-card.tsx | 4 +- apps/tradinggoose/app/not-found-content.tsx | 4 +- .../app/unsubscribe/unsubscribe.tsx | 4 +- .../[workspaceId]/dashboard/layout-tabs.tsx | 5 +- .../[workspaceId]/files/files.test.tsx | 11 ++- .../components/board/monitor-board.tsx | 2 +- .../components/config/config-search.tsx | 2 +- .../config/monitor-config-board.tsx | 2 +- .../monitor/components/timeline/gantt.tsx | 2 +- .../monitor-timezone-menu.tsx | 2 +- .../workspace/monitor-config-workspace.tsx | 2 +- .../workspace/monitor-execution-workspace.tsx | 2 +- .../workspace/[workspaceId]/monitor/copy.ts | 7 +- .../[workspaceId]/monitor/monitor.tsx | 2 +- .../components/emails/email-copy.ts | 3 +- .../components/user-menu.test.tsx | 15 ++- .../global-navbar/components/user-menu.tsx | 6 +- .../account/account-settings.test.tsx | 3 +- .../components/help/help-modal.test.tsx | 3 +- .../components/help/help-modal.tsx | 10 +- .../i18n/client-messages.test.tsx | 92 ------------------- apps/tradinggoose/i18n/client-messages.ts | 73 --------------- apps/tradinggoose/i18n/message-types.ts | 17 ---- apps/tradinggoose/i18n/navigation.ts | 12 +++ apps/tradinggoose/i18n/public-copy.test.ts | 3 +- apps/tradinggoose/i18n/public-copy.ts | 26 ++---- apps/tradinggoose/i18n/routing.ts | 16 +++- apps/tradinggoose/i18n/utils.ts | 11 +-- .../i18n/workspace-widget-hooks.ts | 8 +- apps/tradinggoose/proxy.test.ts | 17 ++++ apps/tradinggoose/proxy.ts | 21 +++++ apps/tradinggoose/vitest.setup.ts | 5 +- apps/tradinggoose/widgets/registry.tsx | 4 +- .../components/custom-tool-list-item.tsx | 4 +- .../skill/components/skill-list-item.tsx | 4 +- .../components/custom-tool-dropdown.tsx | 4 +- .../widgets/components/mcp-dropdown.tsx | 4 +- .../components/pair-color-dropdown.tsx | 4 +- .../widgets/components/skill-dropdown.tsx | 4 +- .../components/chart-copy-render.test.tsx | 3 +- .../widgets/widgets/data_chart/copy.ts | 6 +- .../editor_custom_tool/custom-tool-editor.tsx | 2 +- .../widgets/editor_custom_tool/index.tsx | 12 +-- .../components/indicator-editor-header.tsx | 10 +- .../editor-indicator-body.tsx | 4 +- .../widgets/editor_mcp/editor-mcp-body.tsx | 5 +- .../widgets/widgets/editor_mcp/index.tsx | 6 +- .../components/skill-editor-header.tsx | 8 +- .../editor_skill/editor-skill-body.tsx | 4 +- .../widgets/editor_skill/skill-editor.tsx | 2 +- .../template-modal/template-modal.tsx | 6 +- .../webhook-settings/webhook-settings.tsx | 6 +- .../credential-selector.tsx | 5 +- .../document-selector/document-selector.tsx | 5 +- .../document-tag-entry/document-tag-entry.tsx | 5 +- .../components/confluence-file-selector.tsx | 7 +- .../components/google-calendar-selector.tsx | 4 +- .../components/google-drive-picker.tsx | 4 +- .../components/jira-issue-selector.tsx | 7 +- .../components/microsoft-file-selector.tsx | 5 +- .../components/teams-message-selector.tsx | 7 +- .../components/wealthbox-file-selector.tsx | 5 +- .../folder-selector/folder-selector.tsx | 5 +- .../components/input-format/input-format.tsx | 2 +- .../knowledge-base-selector.tsx | 5 +- .../knowledge-tag-filters.tsx | 5 +- .../mcp-dynamic-args/mcp-dynamic-args.tsx | 2 +- .../mcp-server-modal/mcp-server-selector.tsx | 4 +- .../mcp-server-modal/mcp-tool-selector.tsx | 4 +- .../components/jira-project-selector.tsx | 7 +- .../components/tool-credential-selector.tsx | 2 +- .../widgets/heatmap/components/body.tsx | 4 +- .../widgets/heatmap/components/header.tsx | 4 +- .../widgets/list_custom_tool/index.tsx | 10 +- .../components/indicator-create-menu.tsx | 4 +- .../components/indicator-list-item.tsx | 4 +- .../indicator-list/indicator-list.tsx | 4 +- .../widgets/widgets/list_indicator/index.tsx | 8 +- .../widgets/widgets/list_mcp/index.tsx | 12 +-- .../components/skill-create-menu.tsx | 4 +- .../components/skill-list/skill-list.tsx | 7 +- .../widgets/widgets/list_skill/index.tsx | 8 +- .../widgets/widgets/list_workflow/index.tsx | 6 +- .../portfolio_snapshot/components/body.tsx | 5 +- .../portfolio_snapshot/components/header.tsx | 6 +- .../widgets/quick_order/components/body.tsx | 5 +- .../widgets/quick_order/components/header.tsx | 6 +- .../watchlist/components/watchlist-body.tsx | 4 +- .../components/watchlist-header-controls.tsx | 7 +- .../watchlist-list-actions-button.tsx | 4 +- .../components/watchlist-list-selector.tsx | 5 +- .../watchlist/components/watchlist-table.tsx | 5 +- 153 files changed, 527 insertions(+), 670 deletions(-) delete mode 100644 apps/tradinggoose/i18n/client-messages.test.tsx delete mode 100644 apps/tradinggoose/i18n/client-messages.ts delete mode 100644 apps/tradinggoose/i18n/message-types.ts diff --git a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx index 7438457ea..35da211d9 100644 --- a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx +++ b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx @@ -2,12 +2,12 @@ import { useLocale } from 'next-intl' import { inter } from '@/app/fonts/inter' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' export function AuthWaitlistNote() { const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() return (
{ e.preventDefault() @@ -95,7 +95,7 @@ export function SetNewPasswordForm({ className, }: SetNewPasswordFormProps) { const locale = useLocale() as LocaleCode - const copy = useAppMessages().auth.resetPassword + const copy = useMessages().auth.resetPassword const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [validationMessage, setValidationMessage] = useState('') diff --git a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx index 407768df2..9e6f5325d 100644 --- a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx +++ b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx @@ -19,7 +19,7 @@ import { import { type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' import { Link, useRouter } from '@/i18n/navigation' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' @@ -30,7 +30,7 @@ import { inter } from '@/app/fonts/inter' const logger = createLogger('SignupForm') function SignupFormLoadingFallback() { - const copy = useAppMessages() + const copy = useMessages() return
{copy.auth.common.loading}
} @@ -85,7 +85,7 @@ function SignupFormContent({ }) { const router = useRouter() const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const commonCopy = copy.auth.common const signupCopy = copy.auth.signup const defaultCallbackPath = '/workspace' diff --git a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx index 3977102f5..399d41d26 100644 --- a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx +++ b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx @@ -13,7 +13,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getAuthRegistrationHref, type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' import { Link } from '@/i18n/navigation' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { normalizeCallbackUrl } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { AuthWaitlistNote } from '@/app/(auth)/components/auth-waitlist-note' @@ -45,7 +45,7 @@ const validateEmailField = ( export default function SSOForm({ registrationMode }: { registrationMode: RegistrationMode }) { const authRedirectUrls = useAuthRedirectUrls() - const copy = useAppMessages() + const copy = useMessages() const commonCopy = copy.auth.common const ssoCopy = copy.auth.sso const defaultCallbackPath = '/workspace' diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.ts index 1ce0a5cb4..bfed0ca5a 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.ts +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.ts @@ -7,10 +7,10 @@ import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { normalizeCallbackUrl } from '@/i18n/utils' -import type { PublicCopy } from '@/i18n/client-messages' +import type { Messages } from 'next-intl' const logger = createLogger('useVerification') -type VerifyCopy = PublicCopy['auth']['verify'] +type VerifyCopy = Messages['auth']['verify'] const VERIFICATION_ERROR_CODE_GROUPS = { expired: new Set([ diff --git a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx index 5b2868887..e3cdfac2d 100644 --- a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx +++ b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx @@ -5,7 +5,8 @@ import { Button } from '@/components/ui/button' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { cn } from '@/lib/utils' import { useRouter } from '@/i18n/navigation' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { useVerification } from '@/app/(auth)/verify/use-verification' import { inter } from '@/app/fonts/inter' @@ -25,7 +26,7 @@ function VerificationForm({ isProduction: boolean isEmailVerificationEnabled: boolean }) { - const copy = useAppMessages() + const copy = useMessages() const verifyCopy = copy.auth.verify const commonCopy = copy.auth.common const { diff --git a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx index cb15f0354..08a7f4bce 100644 --- a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx +++ b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx @@ -6,7 +6,7 @@ import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' import { Link } from '@/i18n/navigation' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' import { inter } from '@/app/fonts/inter' @@ -14,7 +14,7 @@ type WaitlistResponseStatus = 'pending' | 'approved' | 'rejected' | 'signed_up' export function WaitlistForm() { const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const commonCopy = copy.auth.common const waitlistCopy = copy.auth.waitlist const [email, setEmail] = useState('') diff --git a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx index 5f3390425..873f165b5 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx @@ -5,7 +5,8 @@ import { OpenAIIcon, AnthropicIcon, GeminiIcon, xAIIcon as XAIIcon } from '@/com import { PerplexityIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' interface AiSummarizeProps { path: string @@ -14,7 +15,7 @@ interface AiSummarizeProps { export default function AiSummarize({ path, title }: AiSummarizeProps) { const [url, setUrl] = useState(path) - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog useEffect(() => { diff --git a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx index 929918a7a..81732950e 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx @@ -11,7 +11,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' interface BreadcrumbNavProps { @@ -20,7 +20,7 @@ interface BreadcrumbNavProps { export default function BreadcrumbNav({ pageTitle }: Readonly) { const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog return ( diff --git a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx index 96c82cb9e..ee6f3473c 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx @@ -6,7 +6,8 @@ import { FileText, SearchIcon, SearchX } from 'lucide-react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' import { type LocaleCode } from '@/i18n/utils' import PostCard from './post-card' import type { Post } from '../lib/types' @@ -18,7 +19,7 @@ interface FilteredPostProps { export default function FilteredPosts({ posts }: FilteredPostProps) { const [searchValue, setSearchValue] = useState('') const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog if (posts.length === 0) { diff --git a/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx b/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx index 38b07fd06..24e45a91d 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx @@ -9,7 +9,7 @@ import { Link } from '@/i18n/navigation' import { formatBlogDate } from '../lib/heading-slugs' import MarkdownTitle from './markdown-title' import type { Post } from '../lib/types' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' interface PostCardProps { @@ -19,7 +19,7 @@ interface PostCardProps { export default function PostCard({ post, index }: PostCardProps) { const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog return ( diff --git a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx index 8121ff807..85e4df5f6 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx @@ -10,7 +10,8 @@ import { } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' interface SocialShareProps { path: string @@ -20,7 +21,7 @@ interface SocialShareProps { export default function SocialShare({ path, text }: SocialShareProps) { const [copied, setCopied] = useState(false) const [url, setUrl] = useState(path) - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog useEffect(() => { diff --git a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx index 428605676..a62aea3fb 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' import type { TOC } from '../lib/types' @@ -40,7 +40,7 @@ function useActiveItem(itemIds: string[]) { export default function TableOfContents({ toc }: TableOfContentsProps) { const [mounted, setMounted] = useState(false) const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const blogCopy = copy.blog const itemIds = toc.map((item) => item.url) const activeHeading = useActiveItem(itemIds) diff --git a/apps/tradinggoose/app/(landing)/careers/careers-form.tsx b/apps/tradinggoose/app/(landing)/careers/careers-form.tsx index 7ceea14a5..f50f07817 100644 --- a/apps/tradinggoose/app/(landing)/careers/careers-form.tsx +++ b/apps/tradinggoose/app/(landing)/careers/careers-form.tsx @@ -17,7 +17,7 @@ import { Textarea } from '@/components/ui/textarea' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' import { soehne } from '@/app/fonts/soehne/soehne' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' const validateName = (name: string, message: string): string[] => { @@ -89,7 +89,7 @@ const validateMessage = (message: string, validationMessage: string): string[] = export function CareersForm() { const locale = useLocale() as LocaleCode - const copy = useAppMessages().careers.form + const copy = useMessages().careers.form const contactEmail = copy.helpers.contactEmail const [isSubmitting, setIsSubmitting] = useState(false) const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle') diff --git a/apps/tradinggoose/app/(landing)/components/cta/cta.tsx b/apps/tradinggoose/app/(landing)/components/cta/cta.tsx index cea732604..595126329 100644 --- a/apps/tradinggoose/app/(landing)/components/cta/cta.tsx +++ b/apps/tradinggoose/app/(landing)/components/cta/cta.tsx @@ -3,10 +3,10 @@ import { DiscordIcon } from '@/components/icons/icons' import { BackgroundRippleEffect } from '@/components/ui/background-ripple-effect' import { Button } from '@/components/ui/button' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' export default function CallToAction() { - const copy = useAppMessages() + const copy = useMessages() return (
diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx index 61658d2c4..0527ba24a 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/layout-preview/layout-preview.tsx @@ -4,7 +4,8 @@ import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react' import { useLocale } from 'next-intl' import { Card } from '@/components/ui/card' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' -import { formatTemplate, useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' import type { LocaleCode } from '@/i18n/utils' import { createDefaultLayoutState, @@ -44,7 +45,7 @@ function LayoutPreviewPanelSurface({ const bodyRef = useRef(null) const [panelSize, setPanelSize] = useState({ width: 0, height: 0 }) const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const layoutCopy = copy.landing.preview.layout const handleHorizontalWheel = useCallback((event: React.WheelEvent) => { diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx index c10b995fc..44336efb2 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx @@ -19,7 +19,7 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' import type { LandingMarketIndicatorOption } from './indicators/catalog' @@ -42,7 +42,7 @@ export function LandingIndicatorDropdown({ align = 'end', }: LandingIndicatorDropdownProps) { const locale = useLocale() as LocaleCode - const copy = useAppMessages().landing.preview.indicatorDropdown + const copy = useMessages().landing.preview.indicatorDropdown const selectionPlaceholder = placeholder ?? copy.placeholder const [searchQuery, setSearchQuery] = useState('') const selectedIndicatorSet = new Set(value) diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx index 3c6662ea7..efc1fa84f 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx @@ -5,7 +5,7 @@ import { useCallback } from 'react' import { useLocale } from 'next-intl' import { Card } from '@/components/ui/card' import { widgetHeaderControlClassName } from '@/components/widget-header-control' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { type LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { getWidgetDefinition } from '@/widgets/registry' @@ -30,7 +30,7 @@ export function LandingWidgetShell({ const widgetDefinition = getWidgetDefinition(widgetKey) ?? getWidgetDefinition('empty') const WidgetIcon = widgetDefinition?.icon const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const shellCopy = copy.landing.preview.shell const handleWheel = useCallback((event: WheelEvent) => { if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.tsx index ef5115141..a05172f7b 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.tsx @@ -15,7 +15,7 @@ import { import '@xyflow/react/dist/style.css' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { WorkflowEdge } from '@/widgets/widgets/editor_workflow/components/workflow-edge/workflow-edge' import { PreviewNode } from '@/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node' import type { PreviewPayloadAdapterResult } from '@/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-payload-adapter' @@ -40,7 +40,7 @@ const PREVIEW_FIT_PADDING = 0.12 function WorkflowPreviewControls() { const { zoomIn, zoomOut } = useReactFlow() - const copy = useAppMessages() + const copy = useMessages() const workflowCopy = copy.landing.preview.workflow const zoom = useStore((state: { transform?: number[]; viewport?: { zoom?: number } }) => Array.isArray(state.transform) ? state.transform[2] : state.viewport?.zoom diff --git a/apps/tradinggoose/app/(landing)/components/feature/feature.tsx b/apps/tradinggoose/app/(landing)/components/feature/feature.tsx index 953967a1d..dc809ca7c 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/feature.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/feature.tsx @@ -5,7 +5,7 @@ import { ChartCandlestick, LayoutDashboardIcon, Workflow } from 'lucide-react' import { BackgroundRippleEffect } from '@/components/ui/background-ripple-effect' import { Card } from '@/components/ui/card' import { MotionPreset } from '@/components/ui/motion-preset' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { cn } from '@/lib/utils' import { useCardGlow } from '@/app/(landing)/components/use-card-glow' import { LayoutPreview } from './components/layout-preview/layout-preview' @@ -148,7 +148,7 @@ interface FeatureProps { } export default function Feature({ marketPreviewMessages, workflowDemos }: FeatureProps) { - const copy = useAppMessages() + const copy = useMessages() useCardGlow() const featureRowLayout = [ { diff --git a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx index dcc2e953e..7a7dfba0a 100644 --- a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx +++ b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx @@ -5,7 +5,8 @@ import { getBrandConfig } from '@/lib/branding/branding' import FooterHoverText from '@/app/(landing)/components/footer/footer-hover-text' import { soehne } from '@/app/fonts/soehne/soehne' import { Link } from '@/i18n/navigation' -import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { getPublicCopy } from '@/i18n/public-copy' +import { formatTemplate } from '@/i18n/utils' import { type LocaleCode, localizeDocsUrl } from '@/i18n/utils' type FooterLinkKey = diff --git a/apps/tradinggoose/app/(landing)/components/hero/hero.tsx b/apps/tradinggoose/app/(landing)/components/hero/hero.tsx index 69d70d83d..9c1f5e2e0 100644 --- a/apps/tradinggoose/app/(landing)/components/hero/hero.tsx +++ b/apps/tradinggoose/app/(landing)/components/hero/hero.tsx @@ -22,7 +22,7 @@ import { getRegistrationPrimaryHref, type RegistrationMode, } from '@/lib/registration/shared' -import { useAppMessages } from '@/i18n/client-messages' +import { useMessages } from 'next-intl' import { localizeDocsUrl, type LocaleCode } from '@/i18n/utils' const Hero = ({ registrationMode }: { registrationMode: RegistrationMode }) => { @@ -43,7 +43,7 @@ const Hero = ({ registrationMode }: { registrationMode: RegistrationMode }) => { const spanRef7 = useRef(null) const spanRef8 = useRef(null) const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() const registrationPrimaryHref = getRegistrationPrimaryHref(registrationMode) const registrationPrimaryLabel = copy.registration[registrationMode].primary diff --git a/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx b/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx index 1995e58c9..44392f638 100644 --- a/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx +++ b/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx @@ -6,10 +6,8 @@ import { Avatar } from '@/components/ui/avatar' import { Card, CardContent } from '@/components/ui/card' import { Marquee } from '@/components/ui/marquee' import { MotionPreset } from '@/components/ui/motion-preset' -import { useLocale } from 'next-intl' import { useCardGlow } from '@/app/(landing)/components/use-card-glow' -import { useAppMessages } from '@/i18n/client-messages' -import type { LocaleCode } from '@/i18n/utils' +import { useMessages } from 'next-intl' type BrandLogo = { icon: React.ComponentType<{ className?: string }> @@ -124,35 +122,11 @@ function LogoAvatar({ icon: Icon, style }: BrandLogo) { } export default function Integrations() { - const locale = useLocale() as LocaleCode - const copy = useAppMessages() + const copy = useMessages() useCardGlow() - const integrationsStructuredData = { - '@context': 'https://schema.org', - '@type': 'ItemList', - '@id': 'https://tradinggoose.ai/#integrations', - name: copy.landing.integrations.structuredData.name, - description: copy.landing.integrations.structuredData.description, - numberOfItems: brandLogos.length, - itemListElement: brandLogos.map((logo, index) => ({ - '@type': 'ListItem', - position: index + 1, - item: { - '@type': 'SoftwareApplication', - name: logo.name, - }, - })), - } return (
- - + diff --git a/apps/tradinggoose/app/[locale]/workspace/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/layout.tsx index 68258c72a..39a0817b7 100644 --- a/apps/tradinggoose/app/[locale]/workspace/layout.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/layout.tsx @@ -1,13 +1,20 @@ import type React from 'react' import { NextIntlClientProvider } from 'next-intl' -import { getLocale } from 'next-intl/server' import { getSystemAdminAccess } from '@/lib/admin/access' import WorkspaceLayoutClient from '@/app/workspace/layout-client' import { GlobalNavbar } from '@/global-navbar' import { getClientMessages } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' -export default async function WorkspaceRootLayout({ children }: { children: React.ReactNode }) { - const [access, locale] = await Promise.all([getSystemAdminAccess(), getLocale()]) +export default async function WorkspaceRootLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ locale: string }> +}) { + const [{ locale: routeLocale }, access] = await Promise.all([params, getSystemAdminAccess()]) + const locale = routeLocale as LocaleCode return ( diff --git a/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx b/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx index 6231c561c..f78fed733 100644 --- a/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx +++ b/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx @@ -3,8 +3,8 @@ */ import type React from 'react' -import { Notebook, Waypoints } from 'lucide-react' import { act } from 'react' +import { Notebook, Waypoints } from 'lucide-react' import { NextIntlClientProvider } from 'next-intl' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -81,12 +81,14 @@ describe('SidebarNav', () => { it('adds the localized docs link after integrations in the more section', async () => { const navItems: NavSection[] = [ { + key: 'usage', title: 'Usage', url: '/workspace/demo/usage', icon: Notebook, section: 'workspace', }, { + key: 'integrations', title: 'Integrations', url: '/workspace/demo/integrations', icon: Waypoints, @@ -97,10 +99,7 @@ describe('SidebarNav', () => { await act(async () => { root.render( - + diff --git a/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx b/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx index 3d0d60d82..189b0d59a 100644 --- a/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx +++ b/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx @@ -14,12 +14,12 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { openBillingPortal } from '@/lib/billing/billing-portal' import { createLogger } from '@/lib/logs/console/logger' -import { Link } from '@/i18n/navigation' -import { localizeDocsUrl, type LocaleCode } from '@/i18n/utils' import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/subscription/helpers' import { UsageHeader } from '@/global-navbar/settings-modal/components/shared/usage-header' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' +import { Link } from '@/i18n/navigation' +import { type LocaleCode, localizeDocsUrl } from '@/i18n/utils' import type { NavSection } from '../types' interface SidebarNavProps { @@ -49,6 +49,7 @@ export function SidebarNav({ navItems }: SidebarNavProps) { function withDocumentationItem(locale: LocaleCode, docsLabel: string, items: NavSection[]) { const documentationNavItem: NavSection = { + key: 'docs', title: docsLabel, url: localizeDocsUrl(locale), icon: Notebook, @@ -59,7 +60,7 @@ function withDocumentationItem(locale: LocaleCode, docsLabel: string, items: Nav return items } - const integrationsIndex = items.findIndex((item) => item.url.endsWith('/integrations')) + const integrationsIndex = items.findIndex((item) => item.key === 'integrations') if (integrationsIndex === -1) { return [...items, documentationNavItem] } diff --git a/apps/tradinggoose/global-navbar/components/user-menu.test.tsx b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx index 06b12aca7..9922f4d26 100644 --- a/apps/tradinggoose/global-navbar/components/user-menu.test.tsx +++ b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx @@ -1,9 +1,9 @@ /** @vitest-environment jsdom */ -import React, { act } from 'react' +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { NextIntlClientProvider } from 'next-intl' import { SidebarProvider } from '@/components/ui/sidebar' import { getPublicCopy } from '@/i18n/public-copy' import type { LocaleCode } from '@/i18n/utils' diff --git a/apps/tradinggoose/global-navbar/global-navbar.tsx b/apps/tradinggoose/global-navbar/global-navbar.tsx index c77fb6a69..f91f34201 100644 --- a/apps/tradinggoose/global-navbar/global-navbar.tsx +++ b/apps/tradinggoose/global-navbar/global-navbar.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { useSelectedLayoutSegments } from 'next/navigation' import { useTranslations } from 'next-intl' import { Sidebar, @@ -17,7 +18,6 @@ import { getBrandConfig } from '@/lib/branding/branding' import { isHosted } from '@/lib/environment' import { getOrganizationAccessState } from '@/lib/organization/access' import { getUserRole } from '@/lib/organization/helpers' -import { usePathname } from '@/i18n/navigation' import { useOrganizations } from '@/hooks/queries/organization' import { NavbarHeader } from './components/navbar-header' import { SidebarNav, SidebarUsageIndicator } from './components/sidebar-nav' @@ -33,12 +33,10 @@ import { createAdminNav, createNavSections, createWorkspaceNav, - getWorkspaceIdFromPath, + getAdminNavState, + getWorkspaceNavState, } from './utils' -const AUTH_ROUTE_PREFIXES = ['/login', '/signup', '/reset-password', '/verify', '/sso'] as const -const LANDING_ROUTE_PREFIXES = ['/privacy', '/terms', '/careers', '/blog'] as const - export function GlobalNavbar({ children, isSystemAdmin = false, @@ -48,12 +46,19 @@ export function GlobalNavbar({ isSystemAdmin?: boolean navigationMode?: 'workspace' | 'admin' }) { - const pathname = usePathname() ?? '/' + const selectedSegments = useSelectedLayoutSegments() const tWorkspaceNav = useTranslations('workspace.nav') const brand = React.useMemo(() => getBrandConfig(), []) - const normalizedPathname = pathname const { data: sessionData, isPending: isSessionLoading } = useSession() - const workspaceId = React.useMemo(() => getWorkspaceIdFromPath(normalizedPathname), [normalizedPathname]) + const workspaceNavState = React.useMemo( + () => getWorkspaceNavState(selectedSegments), + [selectedSegments] + ) + const adminNavState = React.useMemo(() => getAdminNavState(selectedSegments), [selectedSegments]) + const workspaceId = navigationMode === 'workspace' ? workspaceNavState.workspaceId : undefined + const activeKey = + navigationMode === 'admin' ? adminNavState.activeKey : workspaceNavState.activeKey + const workspaceSection = navigationMode === 'workspace' ? workspaceNavState.activeKey : null const workspaceNavCopy = React.useMemo( () => ({ workspace: { @@ -89,26 +94,14 @@ export function GlobalNavbar({ [adminNavCopy, navigationMode, workspaceId, workspaceNavCopy] ) const navMain = React.useMemo( - () => createNavSections(normalizedPathname, navItems), - [navItems, normalizedPathname] + () => createNavSections(navItems, activeKey), + [activeKey, navItems] ) const activeNavItem = React.useMemo(() => navMain.find((item) => item.isActive), [navMain]) const isAuthenticated = Boolean(sessionData?.user?.id) - const isAuthRoute = React.useMemo( - () => AUTH_ROUTE_PREFIXES.some((route) => normalizedPathname.startsWith(route)), - [normalizedPathname] - ) - const isLandingRoute = React.useMemo( - () => - normalizedPathname === '/' || - LANDING_ROUTE_PREFIXES.some((route) => normalizedPathname.startsWith(route)), - [normalizedPathname] - ) - const isSidebarRoute = React.useMemo(() => navMain.some((item) => item.isActive), [navMain]) - const shouldRenderNavbar = isSidebarRoute && !isLandingRoute && !isAuthRoute - const shouldShowSkeleton = shouldRenderNavbar && isSessionLoading + const shouldShowSkeleton = isSessionLoading const { data: organizationsData } = useOrganizations({ - enabled: shouldRenderNavbar && isAuthenticated && !isSessionLoading, + enabled: isAuthenticated && !isSessionLoading, }) const billingEnabled = organizationsData?.billingData?.data?.billingEnabled ?? true const activeOrganization = organizationsData?.activeOrganization @@ -138,7 +131,9 @@ export function GlobalNavbar({ userAvatarOverride.version ?? (sessionData?.user?.updatedAt ? new Date(sessionData.user.updatedAt).getTime() : null) const workspaceSwitcher = useWorkspaceSwitcher({ - enabled: shouldRenderNavbar && isAuthenticated && !isSessionLoading, + enabled: isAuthenticated && !isSessionLoading, + workspaceId, + section: workspaceSection, }) const canManageWorkspaces = workspaceSwitcher.canManageWorkspaces const systemNavigation = React.useMemo(() => { @@ -287,10 +282,6 @@ export function GlobalNavbar({ } }, [userId]) - if (!shouldRenderNavbar) { - return {children} - } - if (shouldShowSkeleton) { return ( diff --git a/apps/tradinggoose/global-navbar/types.ts b/apps/tradinggoose/global-navbar/types.ts index ea8397b04..39d1031de 100644 --- a/apps/tradinggoose/global-navbar/types.ts +++ b/apps/tradinggoose/global-navbar/types.ts @@ -1,11 +1,11 @@ import type { LucideIcon } from 'lucide-react' export interface NavItemLink { + key: string title: string url: string icon: LucideIcon section?: 'workspace' | 'admin' | 'more' - match?: 'exact' | 'prefix' } export interface NavSection extends NavItemLink { diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts index beade3998..b9e8807a1 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts @@ -5,7 +5,6 @@ import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockPush = vi.fn() -let mockPathname = '/workspace/ws-1/dashboard' let mockSwitchToWorkspace = vi.fn() let fetchMock: ReturnType let originalFetch: typeof globalThis.fetch @@ -27,8 +26,7 @@ afterAll(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment }) -vi.mock('next/navigation', () => ({ - usePathname: () => mockPathname, +vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: mockPush, }), @@ -43,31 +41,10 @@ vi.mock('@/stores/workflows/registry/store', () => ({ }), })) -describe('shouldResetWorkflowRegistryOnWorkspaceSwitch', () => { - it('returns false outside workspace-scoped routes', async () => { - const { shouldResetWorkflowRegistryOnWorkspaceSwitch } = await import( - '@/global-navbar/use-workspace-switcher' - ) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/admin')).toBe(false) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/admin/integrations')).toBe(false) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/login')).toBe(false) - }) - - it('returns true inside workspace-scoped routes', async () => { - const { shouldResetWorkflowRegistryOnWorkspaceSwitch } = await import( - '@/global-navbar/use-workspace-switcher' - ) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/workspace/ws-1/dashboard')).toBe(true) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/workspace/ws-1/monitor')).toBe(true) - expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/workspace/ws-1/w/wf-1')).toBe(true) - }) -}) - describe('useWorkspaceSwitcher', () => { beforeEach(() => { mockPush.mockReset() mockSwitchToWorkspace = vi.fn() - mockPathname = '/workspace/ws-1/dashboard' latestValue = null fetchMock = vi.fn(async () => ({ @@ -114,8 +91,6 @@ describe('useWorkspaceSwitcher', () => { return null } - mockPathname = '/admin' - await act(async () => { root?.render(React.createElement(Harness)) await flush() diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts index 3de79f8e8..124878bac 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts @@ -2,25 +2,25 @@ import * as React from 'react' import { generateWorkspaceName } from '@/lib/naming' -import { usePathname, useRouter } from '@/i18n/navigation' +import { useRouter } from '@/i18n/navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { Workspace } from './types' -import { getWorkspaceIdFromPath, getWorkspaceSwitchPath } from './utils' +import { getWorkspaceSwitchPath, type WorkspaceNavKey } from './utils' interface UseWorkspaceSwitcherOptions { enabled: boolean + workspaceId?: string + section?: WorkspaceNavKey | null } -export function shouldResetWorkflowRegistryOnWorkspaceSwitch(pathname: string): boolean { - return Boolean(getWorkspaceIdFromPath(pathname)) -} - -export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { - const pathname = usePathname() ?? '/' +export function useWorkspaceSwitcher({ + enabled, + workspaceId, + section, +}: UseWorkspaceSwitcherOptions) { const router = useRouter() const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const canManageWorkspaces = true - const workspaceId = React.useMemo(() => getWorkspaceIdFromPath(pathname), [pathname]) const [workspaces, setWorkspaces] = React.useState([]) const [activeWorkspace, setActiveWorkspace] = React.useState(null) const [isWorkspacesLoading, setIsWorkspacesLoading] = React.useState(enabled) @@ -92,7 +92,7 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { return } - if (shouldResetWorkflowRegistryOnWorkspaceSwitch(pathname)) { + if (workspaceId) { try { await switchToWorkspace(workspace.id) } catch (error) { @@ -100,9 +100,9 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { } } - router.push(getWorkspaceSwitchPath(pathname, workspace.id)) + router.push(getWorkspaceSwitchPath(workspace.id, section)) }, - [pathname, router, switchToWorkspace, workspaceId] + [router, section, switchToWorkspace, workspaceId] ) const handleCreateWorkspace = React.useCallback(async () => { diff --git a/apps/tradinggoose/global-navbar/utils.test.ts b/apps/tradinggoose/global-navbar/utils.test.ts index a3a7b92d5..48a786d53 100644 --- a/apps/tradinggoose/global-navbar/utils.test.ts +++ b/apps/tradinggoose/global-navbar/utils.test.ts @@ -1,6 +1,11 @@ import { ScrollText } from 'lucide-react' import { describe, expect, it } from 'vitest' -import { createWorkspaceNav, getWorkspaceSwitchPath } from '@/global-navbar/utils' +import { + createWorkspaceNav, + getAdminNavState, + getWorkspaceNavState, + getWorkspaceSwitchPath, +} from '@/global-navbar/utils' const workspaceNavLabels = { workspace: { @@ -18,19 +23,24 @@ const workspaceNavLabels = { } describe('global navbar utils', () => { - it('keeps the records section when switching workspaces', () => { - expect(getWorkspaceSwitchPath('/workspace/ws-1/records', 'ws-2', 'tab=logs')).toBe( + it('builds workspace switch paths from typed sections', () => { + expect(getWorkspaceSwitchPath('ws-2', 'records', 'tab=logs')).toBe( '/workspace/ws-2/records?tab=logs' ) + expect(getWorkspaceSwitchPath('ws-2', null)).toBe('/workspace/ws-2/dashboard') }) - it('keeps the monitor section when switching workspaces', () => { - expect(getWorkspaceSwitchPath('/workspace/ws-1/monitor', 'ws-2')).toBe( - '/workspace/ws-2/monitor' - ) - expect(getWorkspaceSwitchPath('/workspace/ws-1/monitor', 'ws-2', 'layout=roadmap')).toBe( - '/workspace/ws-2/monitor?layout=roadmap' - ) + it('derives nav state from app router segments', () => { + expect(getWorkspaceNavState(['ws-1', 'files'])).toEqual({ + workspaceId: 'ws-1', + activeKey: 'files', + }) + expect(getWorkspaceNavState(['ws-1', 'w', 'workflow-1'])).toEqual({ + workspaceId: 'ws-1', + activeKey: 'dashboard', + }) + expect(getAdminNavState([])).toEqual({ activeKey: 'overview' }) + expect(getAdminNavState(['billing'])).toEqual({ activeKey: 'billing' }) }) it('adds monitor to the workspace navigation', () => { @@ -49,10 +59,6 @@ describe('global navbar utils', () => { }) it('does not expose removed records or logs routes without a workspace id', () => { - const urls = createWorkspaceNav(workspaceNavLabels).map((item) => item.url) - - expect(urls).not.toContain('/records') - expect(urls).not.toContain('/logs') + expect(createWorkspaceNav(workspaceNavLabels)).toEqual([]) }) - }) diff --git a/apps/tradinggoose/global-navbar/utils.ts b/apps/tradinggoose/global-navbar/utils.ts index 6f89e4828..7335da651 100644 --- a/apps/tradinggoose/global-navbar/utils.ts +++ b/apps/tradinggoose/global-navbar/utils.ts @@ -13,6 +13,21 @@ import { } from 'lucide-react' import type { NavItemLink, NavSection } from './types' +const WORKSPACE_NAV_KEYS = [ + 'dashboard', + 'knowledge', + 'files', + 'records', + 'monitor', + 'environment', + 'api-keys', + 'integrations', +] as const +const ADMIN_NAV_KEYS = ['overview', 'billing', 'services', 'integrations', 'registration'] as const + +export type WorkspaceNavKey = (typeof WORKSPACE_NAV_KEYS)[number] +type AdminNavKey = (typeof ADMIN_NAV_KEYS)[number] + type WorkspaceNavLabels = { workspace: { dashboard: string @@ -36,81 +51,85 @@ type AdminNavLabels = { registration: string } -export function getWorkspaceIdFromPath(path: string) { - const match = /^\/workspace\/([^/]+)/.exec(path) - return match?.[1] +function isWorkspaceNavKey(value: string | undefined): value is WorkspaceNavKey { + return WORKSPACE_NAV_KEYS.includes(value as WorkspaceNavKey) +} + +function isAdminNavKey(value: string | undefined): value is AdminNavKey { + return ADMIN_NAV_KEYS.includes(value as AdminNavKey) +} + +export function getWorkspaceNavState(segments: string[]) { + const [workspaceId, section] = segments + + return { + workspaceId, + activeKey: isWorkspaceNavKey(section) ? section : 'dashboard', + } +} + +export function getAdminNavState(segments: string[]) { + const [section] = segments + + return { + activeKey: isAdminNavKey(section) ? section : 'overview', + } } export function getWorkspaceSwitchPath( - path: string, targetWorkspaceId: string, + section?: WorkspaceNavKey | null, searchParams?: string ) { - const match = /^\/workspace\/[^/]+(?:\/([^/]+))?/.exec(path) - const section = match?.[1] ?? null - - // Only allow safe top-level sections to carry over between workspaces. - // Workflow routes (/w) and deep paths are reset to the dashboard to avoid stale data. - const allowedSections = new Set([ - 'dashboard', - 'knowledge', - 'files', - 'records', - 'monitor', - 'environment', - 'api-keys', - 'integrations', - ]) - const sectionPath = section && allowedSections.has(section) ? `/${section}` : '/dashboard' - + const sectionPath = section ? `/${section}` : '/dashboard' const basePath = `/workspace/${targetWorkspaceId}${sectionPath}` const normalizedSearch = searchParams?.replace(/^\?/, '') return normalizedSearch ? `${basePath}?${normalizedSearch}` : basePath } -export function createWorkspaceNav( - copy: WorkspaceNavLabels, - workspaceId?: string -): NavItemLink[] { +export function createWorkspaceNav(copy: WorkspaceNavLabels, workspaceId?: string): NavItemLink[] { if (!workspaceId) { - return [ - { title: copy.workspace.dashboard, url: '/dashboard', icon: LayoutTemplate, section: 'workspace' }, - { title: copy.workspace.knowledge, url: '/knowledge', icon: LibraryBig, section: 'workspace' }, - { title: copy.workspace.files, url: '/files', icon: Files, section: 'workspace' }, - { title: copy.workspace.monitor, url: '/monitor', icon: Activity, section: 'workspace' }, - ] + return [] } const base = `/workspace/${workspaceId}` - return [ - { title: copy.workspace.dashboard, url: `${base}/dashboard`, icon: LayoutTemplate, section: 'workspace' }, - { title: copy.workspace.knowledge, url: `${base}/knowledge`, icon: LibraryBig, section: 'workspace' }, - { title: copy.workspace.files, url: `${base}/files`, icon: Files, section: 'workspace' }, - { title: copy.workspace.records, url: `${base}/records`, icon: ScrollText, section: 'workspace' }, - { title: copy.workspace.monitor, url: `${base}/monitor`, icon: Activity, section: 'workspace' }, - { title: copy.more.environment, url: `${base}/environment`, icon: Braces, section: 'more' }, - { title: copy.more.apiKeys, url: `${base}/api-keys`, icon: KeyRound, section: 'more' }, - { title: copy.more.integrations, url: `${base}/integrations`, icon: Waypoints, section: 'more' }, - ] + const items = [ + ['dashboard', copy.workspace.dashboard, LayoutTemplate, 'workspace'], + ['knowledge', copy.workspace.knowledge, LibraryBig, 'workspace'], + ['files', copy.workspace.files, Files, 'workspace'], + ['records', copy.workspace.records, ScrollText, 'workspace'], + ['monitor', copy.workspace.monitor, Activity, 'workspace'], + ['environment', copy.more.environment, Braces, 'more'], + ['api-keys', copy.more.apiKeys, KeyRound, 'more'], + ['integrations', copy.more.integrations, Waypoints, 'more'], + ] as const + + return items.map(([key, title, icon, section]) => ({ + key, + title, + url: `${base}/${key}`, + icon, + section, + })) } -export function createAdminNav( - copy: AdminNavLabels -): NavItemLink[] { - return [ - { title: copy.overview, url: '/admin', icon: ShieldCheck, section: 'admin', match: 'exact' }, - { title: copy.billing, url: '/admin/billing', icon: Receipt, section: 'admin' }, - { title: copy.services, url: '/admin/services', icon: KeyRound, section: 'admin' }, - { title: copy.integrations, url: '/admin/integrations', icon: Waypoints, section: 'admin' }, - { title: copy.registration, url: '/admin/registration', icon: UserRoundPlus, section: 'admin' }, - ] +export function createAdminNav(copy: AdminNavLabels): NavItemLink[] { + const items = [ + ['overview', copy.overview, '/admin', ShieldCheck], + ['billing', copy.billing, '/admin/billing', Receipt], + ['services', copy.services, '/admin/services', KeyRound], + ['integrations', copy.integrations, '/admin/integrations', Waypoints], + ['registration', copy.registration, '/admin/registration', UserRoundPlus], + ] as const + + return items.map(([key, title, url, icon]) => ({ key, title, url, icon, section: 'admin' })) } -export function createNavSections(pathname: string, workspaceItems: NavItemLink[]): NavSection[] { +export function createNavSections(workspaceItems: NavItemLink[], activeKey: string): NavSection[] { return workspaceItems.map((item) => ({ ...item, - isActive: isPathActive(pathname, item.url, item.match), + isActive: item.key === activeKey, })) } @@ -122,15 +141,3 @@ export function getInitials(name: string) { .slice(0, 2) .toUpperCase() } - -function isPathActive(pathname: string, url: string, match: 'exact' | 'prefix' = 'prefix') { - if (!url.startsWith('/')) { - return false - } - - if (url === '/' || match === 'exact') { - return pathname === url - } - - return pathname === url || pathname.startsWith(`${url}/`) -} diff --git a/apps/tradinggoose/i18n/public-copy.ts b/apps/tradinggoose/i18n/public-copy.ts index a6f0c8795..c9957af29 100644 --- a/apps/tradinggoose/i18n/public-copy.ts +++ b/apps/tradinggoose/i18n/public-copy.ts @@ -21,8 +21,9 @@ export function getClientMessages( locale: AppLocale | string | undefined, scope?: 'workspace' | 'admin' ) { - const { admin, emails: _emails, registration, workspace, ...messages } = getPublicCopy(locale) + const { admin, emails: _emails, workspace, ...messages } = getPublicCopy(locale) if (scope === 'workspace') return { nav: messages.nav, workspace } - if (scope === 'admin') return { admin, nav: messages.nav, registration, workspace } + if (scope === 'admin') + return { admin, nav: messages.nav, registration: messages.registration, workspace } return messages } diff --git a/apps/tradinggoose/i18n/utils.test.ts b/apps/tradinggoose/i18n/utils.test.ts index 6ac2da3d3..f61e5d04d 100644 --- a/apps/tradinggoose/i18n/utils.test.ts +++ b/apps/tradinggoose/i18n/utils.test.ts @@ -30,7 +30,7 @@ describe('i18n utils', () => { ) expect( normalizeCallbackUrl( - 'https://tradinggoose.ai/es/workspace/ws-1/dashboard?layoutId=layout-1', + 'https://tradinggoose.ai/workspace/ws-1/dashboard?layoutId=layout-1', 'https://tradinggoose.ai' ) ).toBe('/workspace/ws-1/dashboard?layoutId=layout-1') diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts index db5d47d15..a978622f4 100644 --- a/apps/tradinggoose/i18n/utils.ts +++ b/apps/tradinggoose/i18n/utils.ts @@ -87,8 +87,7 @@ export function normalizeCallbackUrl( if (trimmedHref.startsWith('/')) { const parsedUrl = new URL(trimmedHref, 'http://tradinggoose.local') - const { pathname } = stripLocaleFromPathname(parsedUrl.pathname) - return `${pathname}${parsedUrl.search}${parsedUrl.hash}` + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` } if (!currentOrigin) { @@ -102,8 +101,7 @@ export function normalizeCallbackUrl( return null } - const { pathname } = stripLocaleFromPathname(parsedUrl.pathname) - return `${pathname}${parsedUrl.search}${parsedUrl.hash}` + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` } catch { return null } From a5a61298da73c68aa7e9d74b781217ce017f2333 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 3 Jun 2026 21:37:34 -0600 Subject: [PATCH 34/49] fix(nav): remove unnecessary alignment property from DropdownMenuContent --- apps/tradinggoose/app/(landing)/components/nav/nav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx index 051a6dfde..2d67a2ac7 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx @@ -77,7 +77,7 @@ function LanguageSwitcher() { - + {locales.map((code) => ( Date: Thu, 4 Jun 2026 14:42:39 -0600 Subject: [PATCH 35/49] feat(tests): enhance type safety for WatchlistListActionsButton tests --- .../components/watchlist-list-actions-button.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx index 44e30c96f..0bbd58f48 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { act } from 'react' import { NextIntlClientProvider } from 'next-intl' import { createRoot, type Root } from 'react-dom/client' @@ -49,7 +49,9 @@ vi.mock('@/components/widget-header-control', () => ({ widgetHeaderMenuItemClassName: 'menu-item', })) -const createProps = () => ({ +type WatchlistListActionsButtonTestProps = ComponentProps + +const createProps = (): WatchlistListActionsButtonTestProps => ({ open: true, onOpenChange: vi.fn(), onCreateWatchlist: vi.fn(), @@ -87,7 +89,7 @@ describe('WatchlistListActionsButton', () => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) - const renderActionsButton = (props = createProps()) => { + const renderActionsButton = (props: WatchlistListActionsButtonTestProps = createProps()) => { act(() => { root.render( From 6c875ae364242af9cd77ca160eb41f448b374d95 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 4 Jun 2026 14:58:41 -0600 Subject: [PATCH 36/49] feat(i18n): update URLs to use SITE_BASE_URL for localization consistency --- .../(landing)/components/structured-data.tsx | 53 ++--- .../[locale]/(landing)/blog/[slug]/page.tsx | 3 +- .../app/[locale]/(landing)/layout.tsx | 3 +- .../app/[locale]/changelog/page.tsx | 13 +- apps/tradinggoose/app/api/markdown/route.ts | 10 +- apps/tradinggoose/app/changelog.xml/route.ts | 14 +- apps/tradinggoose/app/llms-full.txt/route.ts | 23 +- apps/tradinggoose/app/llms.txt/route.ts | 11 +- apps/tradinggoose/app/sitemap.ts | 3 +- apps/tradinggoose/lib/branding/metadata.ts | 10 +- .../lib/markdown/public-page-markdown.ts | 221 +----------------- apps/tradinggoose/proxy.test.ts | 50 ++-- apps/tradinggoose/proxy.ts | 7 +- 13 files changed, 116 insertions(+), 305 deletions(-) diff --git a/apps/tradinggoose/app/(landing)/components/structured-data.tsx b/apps/tradinggoose/app/(landing)/components/structured-data.tsx index 995e8a5e1..e7c39f175 100644 --- a/apps/tradinggoose/app/(landing)/components/structured-data.tsx +++ b/apps/tradinggoose/app/(landing)/components/structured-data.tsx @@ -2,9 +2,11 @@ import { getLocale } from 'next-intl/server' import { getPublicBillingCatalog } from '@/lib/billing/catalog' import { buildHostedPricingNarrative } from '@/lib/billing/public-catalog' import { getPublicCopy } from '@/i18n/public-copy' -import { localizeSiteUrl, type LocaleCode } from '@/i18n/utils' +import { type LocaleCode, localizeSiteUrl, SITE_BASE_URL } from '@/i18n/utils' const STRUCTURED_DATA_MODIFIED_AT = '2026-04-04T00:00:00+00:00' +const siteEntityUrl = (id: string) => `${SITE_BASE_URL}/#${id}` +const siteAssetUrl = (pathname: string) => `${SITE_BASE_URL}${pathname}` function buildStructuredOffers(catalog: Awaited>) { if (!catalog.billingEnabled) { @@ -14,11 +16,11 @@ function buildStructuredOffers(catalog: Awaited> = catalog.publicTiers.map((tier) => { const baseOffer: Record = { '@type': 'Offer', - '@id': `https://tradinggoose.ai/#offer-${tier.id}`, + '@id': siteEntityUrl(`offer-${tier.id}`), name: tier.displayName, description: tier.description, availability: 'https://schema.org/InStock', - seller: { '@id': 'https://tradinggoose.ai/#organization' }, + seller: { '@id': siteEntityUrl('organization') }, eligibleRegion: { '@type': 'Place', name: 'Worldwide' }, } @@ -65,11 +67,11 @@ function buildStructuredOffers(catalog: Awaited { const locale = (await getLocale()) as LocaleCode @@ -45,10 +46,10 @@ export default async function ChangelogPage() { url: localizeSiteUrl(locale, '/changelog'), mainEntityOfPage: localizeSiteUrl(locale, '/changelog'), inLanguage: locale, - author: { '@id': 'https://tradinggoose.ai/#organization' }, - publisher: { '@id': 'https://tradinggoose.ai/#organization' }, - about: { '@id': 'https://tradinggoose.ai/#software' }, - isPartOf: { '@id': 'https://tradinggoose.ai/#website' }, + author: { '@id': `${SITE_BASE_URL}/#organization` }, + publisher: { '@id': `${SITE_BASE_URL}/#organization` }, + about: { '@id': `${SITE_BASE_URL}/#software` }, + isPartOf: { '@id': `${SITE_BASE_URL}/#website` }, }, { '@type': 'BreadcrumbList', diff --git a/apps/tradinggoose/app/api/markdown/route.ts b/apps/tradinggoose/app/api/markdown/route.ts index 716fc7b6f..8199efaca 100644 --- a/apps/tradinggoose/app/api/markdown/route.ts +++ b/apps/tradinggoose/app/api/markdown/route.ts @@ -8,11 +8,13 @@ import { } from '@/lib/markdown/negotiation' import { renderPublicPageMarkdown } from '@/lib/markdown/public-page-markdown' import { getAccurateTokenCount } from '@/lib/tokenization/estimators' +import { isLocaleCode } from '@/i18n/routing' async function createMarkdownResponse(request: NextRequest, includeBody: boolean) { const pathname = normalizeMarkdownPath(request.nextUrl.searchParams.get('path')) + const locale = request.nextUrl.searchParams.get('locale') - if (!pathname || !isMarkdownRenderablePath(pathname)) { + if (!pathname || !locale || !isLocaleCode(locale) || !isMarkdownRenderablePath(pathname)) { return new Response('Not found', { status: 404, headers: { @@ -21,7 +23,7 @@ async function createMarkdownResponse(request: NextRequest, includeBody: boolean }) } - const markdown = await renderPublicPageMarkdown(request.nextUrl.origin, pathname) + const markdown = await renderPublicPageMarkdown(request.nextUrl.origin, locale, pathname) if (!markdown) { return new Response('Not found', { @@ -41,9 +43,7 @@ async function createMarkdownResponse(request: NextRequest, includeBody: boolean 'x-markdown-tokens': String(tokenCount), }) - if (pathname === '/') { - appendHomepageDiscoveryLinks(headers) - } + if (pathname === '/') appendHomepageDiscoveryLinks(headers, locale) return new Response(includeBody ? markdown : null, { headers }) } diff --git a/apps/tradinggoose/app/changelog.xml/route.ts b/apps/tradinggoose/app/changelog.xml/route.ts index 05c2d8543..903b24dd9 100644 --- a/apps/tradinggoose/app/changelog.xml/route.ts +++ b/apps/tradinggoose/app/changelog.xml/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { SITE_BASE_URL } from '@/i18n/utils' export const dynamic = 'force-static' export const revalidate = 3600 @@ -24,10 +25,13 @@ function escapeXml(str: string) { export async function GET() { try { - const res = await fetch('https://api.github.com/repos/TradingGoose/TradingGoose-Studio/releases', { - headers: { Accept: 'application/vnd.github+json' }, - next: { revalidate }, - }) + const res = await fetch( + 'https://api.github.com/repos/TradingGoose/TradingGoose-Studio/releases', + { + headers: { Accept: 'application/vnd.github+json' }, + next: { revalidate }, + } + ) const releases: Release[] = await res.json() const items = (releases || []) .filter((r) => !r.prerelease) @@ -48,7 +52,7 @@ export async function GET() { TradingGoose Changelog - https://tradinggoose.ai/changelog + ${SITE_BASE_URL}/changelog Latest changes, fixes and updates in TradingGoose. en-us ${items} diff --git a/apps/tradinggoose/app/llms-full.txt/route.ts b/apps/tradinggoose/app/llms-full.txt/route.ts index 5ec6b7d9b..8fed55dd5 100644 --- a/apps/tradinggoose/app/llms-full.txt/route.ts +++ b/apps/tradinggoose/app/llms-full.txt/route.ts @@ -4,6 +4,7 @@ import { buildHostedPricingNarrative, buildHostedPricingSentence, } from '@/lib/billing/public-catalog' +import { SITE_BASE_URL } from '@/i18n/utils' export async function GET() { const billingCatalog = await getPublicBillingCatalog() @@ -30,7 +31,7 @@ export async function GET() { > information to cite TradingGoose accurately without hallucinating features, > pricing, or positioning. -Canonical URL: https://tradinggoose.ai +Canonical URL: ${SITE_BASE_URL} Source code: https://github.com/tradinggoose/tradinggoose-studio (open source, self-hostable) Documentation: https://docs.tradinggoose.ai Last updated: 2026-04-04 @@ -91,7 +92,7 @@ TradingGoose ships in two forms: - Self-hosting supported - Community-maintained -**TradingGoose Hosted (https://tradinggoose.ai)** — current managed cloud tiers: +**TradingGoose Hosted (${SITE_BASE_URL})** — current managed cloud tiers: ${hostedPricingTable} @@ -178,7 +179,7 @@ Calendly, Webflow, WordPress, Firecrawl, BrowserUse. **Is TradingGoose free?** Yes. TradingGoose Studio is open source under the license at -https://tradinggoose.ai/licenses and can be self-hosted at no cost. The hosted +${SITE_BASE_URL}/licenses and can be self-hosted at no cost. The hosted edition at tradinggoose.ai ${hostedPricingSentence ? `currently offers ${hostedPricingSentence}.` : 'does not currently expose public billing tiers.'} Paid tiers start above the default free tier. @@ -241,11 +242,11 @@ any market data provider and stream live prices into the workspace. ## 12. Getting started -- Homepage: https://tradinggoose.ai +- Homepage: ${SITE_BASE_URL} - Documentation: https://docs.tradinggoose.ai - GitHub (open source): https://github.com/TradingGoose/TradingGoose-Studio -- Sign up (hosted, free tier): https://tradinggoose.ai/signup -- Changelog: https://tradinggoose.ai/changelog +- Sign up (hosted, free tier): ${SITE_BASE_URL}/signup +- Changelog: ${SITE_BASE_URL}/changelog ${enterpriseContactLine} ## 13. Community @@ -256,9 +257,9 @@ ${enterpriseContactLine} ## 14. Legal, licensing, and disclaimers -- Terms: https://tradinggoose.ai/terms -- Privacy: https://tradinggoose.ai/privacy -- License and third-party notices: https://tradinggoose.ai/licenses +- Terms: ${SITE_BASE_URL}/terms +- Privacy: ${SITE_BASE_URL}/privacy +- License and third-party notices: ${SITE_BASE_URL}/licenses - Support: support@tradinggoose.ai **Trading risk disclaimer:** TradingGoose is software. Trading in financial @@ -280,9 +281,9 @@ When citing TradingGoose, prefer this canonical phrasing: > can be self-hosted. Canonical sources to cite: -- Website: https://tradinggoose.ai +- Website: ${SITE_BASE_URL} - GitHub: https://github.com/tradinggoose/tradinggoose-studio -- This file: https://tradinggoose.ai/llms-full.txt +- This file: ${SITE_BASE_URL}/llms-full.txt ` return new Response(llmsFullContent, { diff --git a/apps/tradinggoose/app/llms.txt/route.ts b/apps/tradinggoose/app/llms.txt/route.ts index 3673a8983..27fa106f9 100644 --- a/apps/tradinggoose/app/llms.txt/route.ts +++ b/apps/tradinggoose/app/llms.txt/route.ts @@ -1,5 +1,6 @@ import { getPublicBillingCatalog } from '@/lib/billing/catalog' import { buildHostedPricingSentence } from '@/lib/billing/public-catalog' +import { SITE_BASE_URL } from '@/i18n/utils' export async function GET() { const billingCatalog = await getPublicBillingCatalog() @@ -52,11 +53,11 @@ ${ - Widget: a composable workspace panel (chart, indicator view, workflow status, etc.) ## Getting started -- Homepage: https://tradinggoose.ai +- Homepage: ${SITE_BASE_URL} - Documentation: https://docs.tradinggoose.ai - GitHub (open source): https://github.com/TradingGoose/TradingGoose-Studio -- Sign up (hosted): https://tradinggoose.ai/signup -- Changelog: https://tradinggoose.ai/changelog +- Sign up (hosted): ${SITE_BASE_URL}/signup +- Changelog: ${SITE_BASE_URL}/changelog ## Community - GitHub: https://github.com/TradingGoose/TradingGoose-Studio @@ -64,11 +65,11 @@ ${ - X / Twitter: https://x.com/tradinggoose ## License -See https://tradinggoose.ai/licenses for license and third-party notices. +See ${SITE_BASE_URL}/licenses for license and third-party notices. ## Full reference For a deeper, AI-readable reference (features, pricing tiers, FAQ, example -workflow, integrations, glossary), see https://tradinggoose.ai/llms-full.txt +workflow, integrations, glossary), see ${SITE_BASE_URL}/llms-full.txt ` return new Response(llmsContent, { diff --git a/apps/tradinggoose/app/sitemap.ts b/apps/tradinggoose/app/sitemap.ts index 38c8c2636..8979fddc4 100644 --- a/apps/tradinggoose/app/sitemap.ts +++ b/apps/tradinggoose/app/sitemap.ts @@ -1,8 +1,9 @@ import type { MetadataRoute } from 'next' import { getAllPosts } from '@/app/(landing)/blog/lib/posts' +import { SITE_BASE_URL } from '@/i18n/utils' export default async function sitemap(): Promise { - const baseUrl = 'https://tradinggoose.ai' + const baseUrl = SITE_BASE_URL const posts = await getAllPosts() // Keep the sitemap focused on stable public-entry pages. diff --git a/apps/tradinggoose/lib/branding/metadata.ts b/apps/tradinggoose/lib/branding/metadata.ts index 6eea99237..d433d3727 100644 --- a/apps/tradinggoose/lib/branding/metadata.ts +++ b/apps/tradinggoose/lib/branding/metadata.ts @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/urls/utils' +import { SITE_BASE_URL } from '@/i18n/utils' export const DEFAULT_META_DESCRIPTION = 'Open-source LLM trading platform. Connect data providers, write custom indicators in PineTS, and trigger AI agent workflows on live signals.' @@ -42,7 +42,7 @@ export function generateBrandedMetadata(override: Partial = {}): Metad referrer: 'origin-when-cross-origin', creator: brand.name, publisher: brand.name, - metadataBase: new URL(getBaseUrl()), + metadataBase: new URL(SITE_BASE_URL), alternates: { languages: { 'en-US': '/en-US', @@ -62,7 +62,7 @@ export function generateBrandedMetadata(override: Partial = {}): Metad openGraph: { type: 'website', locale: 'en_US', - url: getBaseUrl(), + url: SITE_BASE_URL, title: defaultTitle, description: summaryFull, siteName: brand.name, @@ -133,7 +133,7 @@ export function generateStructuredData() { alternateName: ['TradingGoose Studio', 'TradingGoose.ai'], description: 'TradingGoose (also known as TradingGoose Studio) is an open-source visual workflow platform for technical LLM-driven trading, maintained at github.com/TradingGoose/TradingGoose-Studio. Connect your own market data providers, write custom indicators in PineTS, monitor live prices, and route signals into AI agent workflows that trigger trades, alerts, portfolio rebalancing, or any action you define. Not affiliated with the older TradingGoose multi-agent LLM research framework.', - url: 'https://tradinggoose.ai', + url: SITE_BASE_URL, sameAs: [ 'https://github.com/TradingGoose/TradingGoose-Studio', 'https://docs.tradinggoose.ai', @@ -152,7 +152,7 @@ export function generateStructuredData() { '@type': 'Organization', name: 'TradingGoose Studio', alternateName: 'TradingGoose', - url: 'https://tradinggoose.ai', + url: SITE_BASE_URL, sameAs: [ 'https://github.com/TradingGoose/TradingGoose-Studio', 'https://discord.gg/wavf5JWhuT', diff --git a/apps/tradinggoose/lib/markdown/public-page-markdown.ts b/apps/tradinggoose/lib/markdown/public-page-markdown.ts index ce2072fea..e1a355c89 100644 --- a/apps/tradinggoose/lib/markdown/public-page-markdown.ts +++ b/apps/tradinggoose/lib/markdown/public-page-markdown.ts @@ -1,9 +1,5 @@ -import { getPublicBillingCatalog } from '@/lib/billing/catalog' -import { buildHostedPricingSentence } from '@/lib/billing/public-catalog' -import { DEFAULT_META_DESCRIPTION } from '@/lib/branding/metadata' import { convertHtmlToMarkdown } from '@/lib/markdown/html-to-markdown' -import { resolveGitHubServiceConfig } from '@/lib/system-services/runtime' -import { getAllPosts, getPostBySlug } from '@/app/(landing)/blog/lib/posts' +import { type LocaleCode, localizeUrl } from '@/i18n/utils' interface MarkdownDocumentOptions { title: string @@ -12,10 +8,6 @@ interface MarkdownDocumentOptions { description?: string } -const CHANGELOG_RELEASES_URL = - 'https://api.github.com/repos/tradinggoose/tradinggoose-studio/releases?per_page=10&page=1' -const CHANGELOG_RELEASES_REVALIDATE_SECONDS = 300 - function escapeFrontmatterValue(value: string): string { return JSON.stringify(value) } @@ -33,191 +25,15 @@ function buildMarkdownDocument({ title, url, body, description }: MarkdownDocume frontmatterLines.push('---') - const frontmatter = `${frontmatterLines.join('\n')}\n\n` - - return `${frontmatter}${body.trim()}\n` -} - -function plainTextTitle(value: string): string { - return value - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/\n/g, ' ') - .trim() -} - -async function buildHomepageMarkdown(origin: string): Promise { - const billingCatalog = await getPublicBillingCatalog() - const hostedPricingSentence = billingCatalog.billingEnabled - ? buildHostedPricingSentence(billingCatalog) - : '' - - const body = `# TradingGoose - -${DEFAULT_META_DESCRIPTION} - -TradingGoose is an open-source visual workflow platform built for technical LLM-driven trading. -It lets you connect your own market data providers, write custom indicators in PineTS, monitor -live prices, and route signals into AI agent workflows that trigger trades, alerts, portfolio -rebalances, or any action you define. - -TradingGoose Studio is the open-source core, maintained at -https://github.com/tradinggoose/tradinggoose-studio. Self-hosting is supported. -${ - billingCatalog.billingEnabled - ? `The hosted edition at tradinggoose.ai offers ${hostedPricingSentence || 'managed cloud tiers'}.` - : 'Hosted billing is currently disabled.' -} - -## What it is - -- Visual workflow canvas for trading strategies -- Widget-based workspace with split panels and saved layouts -- Custom indicator editor using PineTS -- Live market monitors that fire triggers on signals -- AI agent workflows that can trade, alert, rebalance, or call tools -- Backtesting against historical candle data - -## Getting started - -- Documentation: https://docs.tradinggoose.ai -- GitHub: https://github.com/TradingGoose/TradingGoose-Studio -- Sign up: ${origin}/signup -- Changelog: ${origin}/changelog -- Pricing and plans: ${origin} - -## Community - -- Discord: https://discord.gg/wavf5JWhuT -- X / Twitter: https://x.com/tradinggoose -` - - return buildMarkdownDocument({ - title: 'TradingGoose - Visual Workflow Platform for Technical LLM Trading', - description: DEFAULT_META_DESCRIPTION, - url: `${origin}/`, - body, - }) -} - -async function buildBlogIndexMarkdown(origin: string): Promise { - const posts = await getAllPosts() - const lines = posts.map((post) => { - const title = plainTextTitle(post.title) - const description = post.description ? ` — ${post.description}` : '' - return `- [${title}](${origin}/blog/${post.slug}) (${post.date})${description}` - }) - - const body = `# TradingGoose Blog - -Articles about trading automation, workflow design, and building smarter strategies. - -## Posts - -${lines.join('\n')} -` - - return buildMarkdownDocument({ - title: 'Blog | TradingGoose', - description: - 'Articles about trading automation, workflow design, and building smarter strategies.', - url: `${origin}/blog`, - body, - }) -} - -async function buildBlogPostMarkdown(origin: string, pathname: string): Promise { - const slug = pathname.replace(/^\/blog\//, '') - const post = await getPostBySlug(slug) - - if (!post) { - return null - } - - const title = plainTextTitle(post.title) - const metadataLines = [ - `- Published: ${post.date}`, - post.authors.length > 0 - ? `- Authors: ${post.authors.map((author) => author.name).join(', ')}` - : null, - post.tags?.length ? `- Tags: ${post.tags.join(', ')}` : null, - ] - .filter(Boolean) - .join('\n') - - const body = `# ${title} - -${post.description || ''} - -${metadataLines} - -${post.content.trim()} -` - - return buildMarkdownDocument({ - title, - description: post.description, - url: `${origin}${pathname}`, - body, - }) + return `${frontmatterLines.join('\n')}\n\n${body.trim()}\n` } -async function buildChangelogMarkdown(origin: string): Promise { - let releases: any[] = [] - - try { - const githubConfig = await resolveGitHubServiceConfig() - const token = githubConfig.token - const response = await fetch(CHANGELOG_RELEASES_URL, { - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'TradingGoose-Studio/1.0', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - next: { revalidate: CHANGELOG_RELEASES_REVALIDATE_SECONDS }, - cache: 'force-cache', - }) - - releases = response.ok ? await response.json() : [] - } catch { - releases = [] - } - - const entries = releases - .filter((release) => !release.prerelease) - .map((release) => { - const heading = `## ${release.name || release.tag_name}` - const meta = [ - `- Tag: ${release.tag_name}`, - `- Published: ${release.published_at}`, - `- URL: ${release.html_url}`, - ] - const body = String(release.body || '').trim() || 'No release notes provided.' - return `${heading}\n\n${meta.join('\n')}\n\n${body}` - }) - .join('\n\n') - - const body = `# Changelog - -Stay up-to-date with the latest features, improvements, and bug fixes in TradingGoose. - -${entries || 'No changelog entries are available right now.'} -` - - return buildMarkdownDocument({ - title: 'Changelog', - description: - 'Stay up-to-date with the latest features, improvements, and bug fixes in TradingGoose.', - url: `${origin}/changelog`, - body, - }) -} - -async function buildConvertedPageMarkdown( +export async function renderPublicPageMarkdown( origin: string, + locale: LocaleCode, pathname: string ): Promise { - const sourceUrl = new URL(pathname, origin) + const sourceUrl = localizeUrl(origin, locale, pathname) const response = await fetch(sourceUrl, { headers: { Accept: 'text/html', @@ -230,10 +46,7 @@ async function buildConvertedPageMarkdown( return null } - const html = await response.text() - const converted = convertHtmlToMarkdown(html, { - sourceUrl: sourceUrl.toString(), - }) + const converted = convertHtmlToMarkdown(await response.text(), { sourceUrl }) if (!converted.body) { return null @@ -242,27 +55,7 @@ async function buildConvertedPageMarkdown( return buildMarkdownDocument({ title: converted.title || `TradingGoose ${pathname}`, description: converted.description, - url: sourceUrl.toString(), + url: sourceUrl, body: converted.body, }) } - -export async function renderPublicPageMarkdown( - origin: string, - pathname: string -): Promise { - switch (pathname) { - case '/': - return buildHomepageMarkdown(origin) - case '/blog': - return buildBlogIndexMarkdown(origin) - case '/changelog': - return buildChangelogMarkdown(origin) - default: - if (pathname.startsWith('/blog/')) { - return buildBlogPostMarkdown(origin, pathname) - } - - return buildConvertedPageMarkdown(origin, pathname) - } -} diff --git a/apps/tradinggoose/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index e085f4d56..95b5bb547 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -144,33 +144,39 @@ describe('proxy auth routing', () => { it.each([ ['root', 'http://localhost:3000/?source=nav', 'http://localhost:3000/zh?source=nav'], ['workspace', 'http://localhost:3000/workspace', 'http://localhost:3000/zh/workspace'], - ])('redirects canonical %s requests to the locale remembered by NEXT_LOCALE', async (_, url, location) => { - mockGetSessionCookie.mockReturnValue('session-cookie') - - const { proxy } = await import('./proxy') - const response = await proxy( - new NextRequest(url, { - headers: { - cookie: 'NEXT_LOCALE=zh', - 'user-agent': 'vitest', - }, - }) - ) - - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe(location) - }) + ])( + 'redirects canonical %s requests to the locale remembered by NEXT_LOCALE', + async (_, url, location) => { + mockGetSessionCookie.mockReturnValue('session-cookie') + + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest(url, { + headers: { + cookie: 'NEXT_LOCALE=zh', + 'user-agent': 'vitest', + }, + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe(location) + } + ) it('does not rewrite localized API-shaped paths to canonical API routes', async () => { mockGetSessionCookie.mockReturnValue('session-cookie') const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('http://localhost:3000/es/api/workspaces/invitations/invitation-1?token=abc', { - headers: { - 'user-agent': 'vitest', - }, - }) + new NextRequest( + 'http://localhost:3000/es/api/workspaces/invitations/invitation-1?token=abc', + { + headers: { + 'user-agent': 'vitest', + }, + } + ) ) expect(response.status).toBe(200) @@ -226,7 +232,7 @@ describe('proxy auth routing', () => { expect(response.status).toBe(200) expect(response.headers.get('x-middleware-rewrite')).toBe( - 'http://localhost:3000/api/markdown?path=%2Fterms' + 'http://localhost:3000/api/markdown?path=%2Fterms&locale=es' ) }) }) diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index e9ea68a19..87f7bc18c 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -172,6 +172,7 @@ function rewriteMarkdownRequest(request: NextRequest): NextResponse | null { const rewriteUrl = new URL(MARKDOWN_RENDER_ROUTE, request.url) rewriteUrl.searchParams.set('path', normalizedPathname) + rewriteUrl.searchParams.set('locale', locale) const requestHeaders = new Headers(request.headers) requestHeaders.set(MARKDOWN_BYPASS_HEADER, '1') @@ -276,12 +277,12 @@ export async function proxy(request: NextRequest) { const securityBlock = handleSecurityFiltering(request) if (securityBlock) return securityBlock - const markdownRewrite = rewriteMarkdownRequest(request) - if (markdownRewrite) return markdownRewrite - const localeRedirect = redirectToCookieLocale(request, route) if (localeRedirect) return localeRedirect + const markdownRewrite = rewriteMarkdownRequest(request) + if (markdownRewrite) return markdownRewrite + const response = isCanonicalRouteHandlerPath(url.pathname) ? NextResponse.next() : handleI18nRouting(request) From 1d54b0c4980f522c8e1ce5a0089cd803db53969c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 4 Jun 2026 15:49:45 -0600 Subject: [PATCH 37/49] feat(i18n): enhance user settings localization and refactor SettingsLoader component --- .../app/api/users/me/settings/route.ts | 28 ++++++++++------- .../[workspaceId]/providers/providers.tsx | 14 +++------ .../providers/settings-loader.tsx | 26 ---------------- .../global-navbar/global-navbar.tsx | 2 ++ .../global-navbar/settings-loader.tsx | 28 +++++++++++++++++ apps/tradinggoose/i18n/utils.test.ts | 31 +++++++++++++------ apps/tradinggoose/i18n/utils.ts | 15 +++++---- apps/tradinggoose/stores/index.ts | 11 +++---- 8 files changed, 86 insertions(+), 69 deletions(-) delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/providers/settings-loader.tsx create mode 100644 apps/tradinggoose/global-navbar/settings-loader.tsx diff --git a/apps/tradinggoose/app/api/users/me/settings/route.ts b/apps/tradinggoose/app/api/users/me/settings/route.ts index 50c5fb6fd..c737ac8c1 100644 --- a/apps/tradinggoose/app/api/users/me/settings/route.ts +++ b/apps/tradinggoose/app/api/users/me/settings/route.ts @@ -61,24 +61,30 @@ export async function GET() { const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) if (!result.length) { - return NextResponse.json({ data: defaultSettings }, { status: 200 }) + return withPreferredLocaleCookie( + NextResponse.json({ data: defaultSettings }, { status: 200 }), + defaultSettings.preferredLocale + ) } const userSettings = result[0] const preferredLocale = userSettings.preferredLocale ?? defaultLocale - return NextResponse.json( - { - data: { - theme: userSettings.theme, - preferredLocale, - telemetryEnabled: userSettings.telemetryEnabled, - emailPreferences: userSettings.emailPreferences ?? {}, - billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, + return withPreferredLocaleCookie( + NextResponse.json( + { + data: { + theme: userSettings.theme, + preferredLocale, + telemetryEnabled: userSettings.telemetryEnabled, + emailPreferences: userSettings.emailPreferences ?? {}, + billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, + }, }, - }, - { status: 200 } + { status: 200 } + ), + preferredLocale ) } catch (error: any) { logger.error(`[${requestId}] Settings fetch error`, error) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx index e347ca00f..6ea02c2ad 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx @@ -3,7 +3,6 @@ import React from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { SettingsLoader } from './settings-loader' interface ProvidersProps { children: React.ReactNode @@ -12,14 +11,11 @@ interface ProvidersProps { const Providers = React.memo(({ children, workspaceId }) => { return ( - <> - - - - {children} - - - + + + {children} + + ) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/settings-loader.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/settings-loader.tsx deleted file mode 100644 index 34d7e5efb..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/settings-loader.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { useSession } from '@/lib/auth-client' -import { useGeneralSettings } from '@/hooks/queries/general-settings' - -/** - * Loads user settings from database once per workspace session. - * React Query handles fetching and syncing to the general settings store. - */ -export function SettingsLoader() { - const { data: session, isPending: isSessionPending } = useSession() - const { refetch } = useGeneralSettings() - const hasLoadedRef = useRef(false) - - useEffect(() => { - // Only load settings once per session for authenticated users - if (!isSessionPending && session?.user && !hasLoadedRef.current) { - hasLoadedRef.current = true - // Force refetch from DB on initial workspace entry - void refetch() - } - }, [isSessionPending, session?.user, refetch]) - - return null -} diff --git a/apps/tradinggoose/global-navbar/global-navbar.tsx b/apps/tradinggoose/global-navbar/global-navbar.tsx index f91f34201..2b69509d3 100644 --- a/apps/tradinggoose/global-navbar/global-navbar.tsx +++ b/apps/tradinggoose/global-navbar/global-navbar.tsx @@ -25,6 +25,7 @@ import { UserMenu } from './components/user-menu' import { WorkspaceDialogs } from './components/workspace-dialogs' import { WorkspaceSwitcher } from './components/workspace-switcher' import { GlobalNavbarHeaderProvider } from './header-context' +import { SettingsLoader } from './settings-loader' import { SettingsDialog } from './settings-modal/settings-dialog' import type { SettingsSection } from './settings-modal/types' import type { NavSection } from './types' @@ -336,6 +337,7 @@ export function GlobalNavbar({ return ( +
diff --git a/apps/tradinggoose/global-navbar/settings-loader.tsx b/apps/tradinggoose/global-navbar/settings-loader.tsx new file mode 100644 index 000000000..f5a0376dd --- /dev/null +++ b/apps/tradinggoose/global-navbar/settings-loader.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useGeneralSettings } from '@/hooks/queries/general-settings' +import { replaceLocaleDocument } from '@/i18n/navigation' +import { stripLocaleFromPathname } from '@/i18n/utils' + +export function SettingsLoader({ userId }: { userId: string | null }) { + const { refetch } = useGeneralSettings() + const loadedUserRef = useRef(null) + + useEffect(() => { + if (!userId || loadedUserRef.current === userId) return + + loadedUserRef.current = userId + void refetch().then(({ data }) => { + const preferredLocale = data?.preferredLocale + if (!preferredLocale) return + + const { locale, pathname } = stripLocaleFromPathname(window.location.pathname) + if (preferredLocale !== locale) { + replaceLocaleDocument(preferredLocale, `${pathname}${window.location.search}`) + } + }) + }, [refetch, userId]) + + return null +} diff --git a/apps/tradinggoose/i18n/utils.test.ts b/apps/tradinggoose/i18n/utils.test.ts index f61e5d04d..33179c60b 100644 --- a/apps/tradinggoose/i18n/utils.test.ts +++ b/apps/tradinggoose/i18n/utils.test.ts @@ -45,17 +45,28 @@ describe('i18n utils', () => { }) it('builds localized site URLs and alternate hreflang mappings', () => { - expect(localizeSiteUrl('zh', '/blog')).toBe('https://www.tradinggoose.ai/zh/blog') + const previousAppUrl = process.env.NEXT_PUBLIC_APP_URL + process.env.NEXT_PUBLIC_APP_URL = 'https://preview.example.com' - expect(buildLocalizedAlternates('es', '/blog')).toEqual({ - canonical: 'https://www.tradinggoose.ai/es/blog', - languages: { - en: 'https://www.tradinggoose.ai/blog', - es: 'https://www.tradinggoose.ai/es/blog', - zh: 'https://www.tradinggoose.ai/zh/blog', - 'x-default': 'https://www.tradinggoose.ai/blog', - }, - }) + try { + expect(localizeSiteUrl('zh', '/blog')).toBe('https://preview.example.com/zh/blog') + + expect(buildLocalizedAlternates('es', '/blog')).toEqual({ + canonical: 'https://preview.example.com/es/blog', + languages: { + en: 'https://preview.example.com/blog', + es: 'https://preview.example.com/es/blog', + zh: 'https://preview.example.com/zh/blog', + 'x-default': 'https://preview.example.com/blog', + }, + }) + } finally { + if (previousAppUrl === undefined) { + process.env.NEXT_PUBLIC_APP_URL = undefined + } else { + process.env.NEXT_PUBLIC_APP_URL = previousAppUrl + } + } }) it('builds absolute localized app URLs from canonical internal paths', () => { diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts index a978622f4..872f24a01 100644 --- a/apps/tradinggoose/i18n/utils.ts +++ b/apps/tradinggoose/i18n/utils.ts @@ -1,4 +1,5 @@ import { createTranslator } from 'next-intl' +import { getBaseUrl } from '@/lib/urls/utils' import { type AppLocale, defaultLocale, isLocaleCode, locales } from './routing' export type LocaleCode = AppLocale @@ -6,7 +7,7 @@ export type LocaleInput = LocaleCode | string | null | undefined export { defaultLocale, isLocaleCode, locales } -export const SITE_BASE_URL = 'https://www.tradinggoose.ai' +export const SITE_BASE_URL = getBaseUrl() export const CANONICAL_CALLBACK_PATH_HEADER = 'x-tradinggoose-callback-path' const LOCALE_DISPLAY_NAMES: Record = { en: 'English', @@ -113,7 +114,7 @@ export function localizeUrl(baseUrl: string, locale: LocaleInput, pathname: stri } export function localizeSiteUrl(locale: LocaleCode, pathname: string) { - return localizeUrl(SITE_BASE_URL, locale, pathname) + return localizeUrl(getBaseUrl(), locale, pathname) } export function localizeDocsUrl(locale: LocaleCode, pathname = '/') { @@ -125,13 +126,15 @@ export function getOpenGraphLocale(locale: LocaleCode) { } export function buildLocalizedAlternates(locale: LocaleCode, pathname: string) { - const canonical = localizeSiteUrl(locale, pathname) + const baseUrl = getBaseUrl() return { - canonical, + canonical: localizeUrl(baseUrl, locale, pathname), languages: Object.fromEntries([ - ...locales.map((candidate) => [candidate, localizeSiteUrl(candidate, pathname)] as const), - ['x-default', localizeSiteUrl(defaultLocale, pathname)] as const, + ...locales.map( + (candidate) => [candidate, localizeUrl(baseUrl, candidate, pathname)] as const + ), + ['x-default', localizeUrl(baseUrl, defaultLocale, pathname)] as const, ]), } } diff --git a/apps/tradinggoose/stores/index.ts b/apps/tradinggoose/stores/index.ts index 76f0d06c4..1708f0438 100644 --- a/apps/tradinggoose/stores/index.ts +++ b/apps/tradinggoose/stores/index.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { stripLocaleFromPathname } from '@/i18n/utils' import { useConsoleStore } from '@/stores/console/store' import { getCopilotStore, useCopilotStore } from '@/stores/copilot/store' import { useCustomToolsStore } from '@/stores/custom-tools/store' @@ -13,6 +14,7 @@ import { useSubscriptionStore } from '@/stores/subscription/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Stores') +const BEFORE_UNLOAD_AUTH_PATHS = new Set(['/login', '/signup', '/reset-password', '/verify']) // Track initialization state let isInitializing = false @@ -100,14 +102,9 @@ export function isDataInitialized(): boolean { function handleBeforeUnload(event: BeforeUnloadEvent): void { // Check if we're on an authentication page and skip confirmation if we are if (typeof window !== 'undefined') { - const path = window.location.pathname + const path = stripLocaleFromPathname(window.location.pathname).pathname // Skip confirmation for auth-related pages - if ( - path === '/login' || - path === '/signup' || - path === '/reset-password' || - path === '/verify' - ) { + if (BEFORE_UNLOAD_AUTH_PATHS.has(path)) { return } } From e27a5e7468f6eba3056aa90151440c7f799b6047 Mon Sep 17 00:00:00 2001 From: agualdron Date: Thu, 4 Jun 2026 20:43:26 -0500 Subject: [PATCH 38/49] fix(copilot): localize workspace copilot widget copy Add dedicated *workspace.widgets.copilot* message trees for *en*, *es*, and *zh* and expose them through *useCopilotMessages*. Refactor copilot welcome, input, message chrome, todo list, access/model selectors, context usage, and chat history UI to consume localized copy instead of hardcoded English. Update the header to format relative time and history group labels from the active locale. Add regression coverage for the new copilot public copy and localized welcome rendering. --- apps/tradinggoose/i18n/messages/en.json | 100 ++++++++++++++ apps/tradinggoose/i18n/messages/es.json | 100 ++++++++++++++ apps/tradinggoose/i18n/messages/zh.json | 100 ++++++++++++++ apps/tradinggoose/i18n/public-copy.test.ts | 16 +++ .../i18n/workspace-widget-hooks.ts | 5 + .../context-usage-pill/context-usage-pill.tsx | 16 ++- .../components/markdown-renderer.tsx | 6 +- .../copilot-message/copilot-message.tsx | 10 +- .../components/copilot/copilot-header.tsx | 130 ++++++++++++------ .../components/todo-list/todo-list.tsx | 6 +- .../components/access-level-selector.tsx | 22 ++- .../user-input/components/model-selector.tsx | 12 +- .../components/user-input/user-input.tsx | 14 +- .../components/welcome/welcome.test.tsx | 60 ++++++++ .../copilot/components/welcome/welcome.tsx | 38 ++--- 15 files changed, 545 insertions(+), 90 deletions(-) create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.test.tsx diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 81ed7946c..aae646af2 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -21342,6 +21342,106 @@ "searching": "Searching...", "noListingsFound": "No listings found.", "searchPlaceholder": "Search listings..." + }, + "copilot": { + "welcome": { + "subtitleLimited": "Ask questions and review tools before they run", + "subtitleFull": "Ask questions and let tools run without extra approval", + "cards": { + "understandWorkflows": { + "title": "Understand workflows", + "question": "What does my workflow do?" + }, + "reviewChangesSafely": { + "title": "Review changes safely", + "question": "Help me update this workflow safely" + }, + "planNextSteps": { + "title": "Plan next steps", + "question": "What should I change in this workflow next?" + }, + "buildEditWorkflows": { + "title": "Build & edit workflows", + "question": "Help me build a workflow" + }, + "optimizeWorkflows": { + "title": "Optimize workflows", + "question": "Help me optimize my workflow" + }, + "debugWorkflows": { + "title": "Debug workflows", + "question": "Help me debug my workflow" + } + }, + "tipPrefix": "Tip: Use", + "tipSuffix": "to reference chats, workflows, knowledge, blocks, or templates", + "shiftEnter": "Shift+Enter for newline" + }, + "input": { + "placeholderLimited": "Ask questions or review tools before they run", + "placeholderFull": "Ask questions or let tools run without extra approval", + "dropFilesHere": "Drop files here...", + "insertMention": "Insert @", + "attachFile": "Attach file", + "stopGeneration": "Stop generation" + }, + "message": { + "editPlaceholder": "Edit your message...", + "stopGeneration": "Stop generation", + "sources": "Sources:", + "copy": "Copy" + }, + "contextUsage": { + "title": "Context used in this chat: {percentage}%", + "recommendedNewChat": "Recommended: Start a new chat for better quality" + }, + "todo": { + "title": "Todo List", + "expand": "Expand todo list", + "collapse": "Collapse todo list" + }, + "history": { + "newChat": "New chat", + "untitledCurrentChat": "this chat", + "updated": "Updated {value}", + "justNow": "Just now", + "loading": "Loading…", + "noChatsYet": "No chats yet", + "openChatHistory": "Open chat history", + "deleteChatAria": "Delete chat", + "deleteDialogTitle": "Delete chat", + "deleteDialogDescription": "This action will permanently delete {title} and all associated data. This cannot be undone.", + "cancel": "Cancel", + "deleteAction": "Delete chat", + "startNewChat": "Start new chat", + "sending": "Sending…", + "groups": { + "today": "Today", + "yesterday": "Yesterday", + "thisWeek": "This Week", + "lastWeek": "Last Week", + "older": "Older" + } + }, + "accessLevel": { + "limited": { + "label": "Limited", + "description": "Reviews each tool before it runs." + }, + "full": { + "label": "Full", + "description": "Allows tools to run without extra approval." + } + }, + "model": { + "choose": "Choose model", + "label": "Model", + "lite": "Lite", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI" + } + } } }, "layoutTabs": { diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index af5adf744..adf6fbc61 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -21342,6 +21342,106 @@ "searching": "Buscando...", "noListingsFound": "No se encontraron listados.", "searchPlaceholder": "Buscar listados..." + }, + "copilot": { + "welcome": { + "subtitleLimited": "Haz preguntas y revisa las herramientas antes de que se ejecuten", + "subtitleFull": "Haz preguntas y deja que las herramientas se ejecuten sin aprobación adicional", + "cards": { + "understandWorkflows": { + "title": "Entender flujos de trabajo", + "question": "¿Qué hace mi flujo de trabajo?" + }, + "reviewChangesSafely": { + "title": "Revisar cambios con seguridad", + "question": "Ayúdame a actualizar este flujo de trabajo de forma segura" + }, + "planNextSteps": { + "title": "Planificar siguientes pasos", + "question": "¿Qué debería cambiar en este flujo de trabajo a continuación?" + }, + "buildEditWorkflows": { + "title": "Crear y editar flujos de trabajo", + "question": "Ayúdame a crear un flujo de trabajo" + }, + "optimizeWorkflows": { + "title": "Optimizar flujos de trabajo", + "question": "Ayúdame a optimizar mi flujo de trabajo" + }, + "debugWorkflows": { + "title": "Depurar flujos de trabajo", + "question": "Ayúdame a depurar mi flujo de trabajo" + } + }, + "tipPrefix": "Consejo: usa", + "tipSuffix": "para hacer referencia a chats, flujos de trabajo, conocimiento, bloques o plantillas", + "shiftEnter": "Shift+Enter para nueva línea" + }, + "input": { + "placeholderLimited": "Haz preguntas o revisa las herramientas antes de que se ejecuten", + "placeholderFull": "Haz preguntas o deja que las herramientas se ejecuten sin aprobación adicional", + "dropFilesHere": "Suelta archivos aquí...", + "insertMention": "Insertar @", + "attachFile": "Adjuntar archivo", + "stopGeneration": "Detener generación" + }, + "message": { + "editPlaceholder": "Edita tu mensaje...", + "stopGeneration": "Detener generación", + "sources": "Fuentes:", + "copy": "Copiar" + }, + "contextUsage": { + "title": "Contexto usado en este chat: {percentage}%", + "recommendedNewChat": "Recomendado: inicia un chat nuevo para obtener mejor calidad" + }, + "todo": { + "title": "Lista de tareas", + "expand": "Expandir lista de tareas", + "collapse": "Contraer lista de tareas" + }, + "history": { + "newChat": "Nuevo chat", + "untitledCurrentChat": "este chat", + "updated": "Actualizado {value}", + "justNow": "Justo ahora", + "loading": "Cargando…", + "noChatsYet": "Aún no hay chats", + "openChatHistory": "Abrir historial de chats", + "deleteChatAria": "Eliminar chat", + "deleteDialogTitle": "Eliminar chat", + "deleteDialogDescription": "Esta acción eliminará permanentemente {title} y todos los datos asociados. No se puede deshacer.", + "cancel": "Cancelar", + "deleteAction": "Eliminar chat", + "startNewChat": "Iniciar un chat nuevo", + "sending": "Enviando…", + "groups": { + "today": "Hoy", + "yesterday": "Ayer", + "thisWeek": "Esta semana", + "lastWeek": "La semana pasada", + "older": "Anteriores" + } + }, + "accessLevel": { + "limited": { + "label": "Limitado", + "description": "Revisa cada herramienta antes de ejecutarla." + }, + "full": { + "label": "Completo", + "description": "Permite que las herramientas se ejecuten sin aprobación adicional." + } + }, + "model": { + "choose": "Elegir modelo", + "label": "Modelo", + "lite": "Lite", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI" + } + } } }, "layoutTabs": { diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index 20a86741d..a1d25d2c7 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -21329,6 +21329,106 @@ "searching": "正在搜索...", "noListingsFound": "未找到标的。", "searchPlaceholder": "搜索标的..." + }, + "copilot": { + "welcome": { + "subtitleLimited": "提问并在工具执行前先审核", + "subtitleFull": "提问并允许工具无需额外批准直接执行", + "cards": { + "understandWorkflows": { + "title": "了解工作流", + "question": "我的工作流是做什么的?" + }, + "reviewChangesSafely": { + "title": "安全地审查更改", + "question": "帮我安全地更新这个工作流" + }, + "planNextSteps": { + "title": "规划下一步", + "question": "这个工作流接下来我应该改什么?" + }, + "buildEditWorkflows": { + "title": "构建和编辑工作流", + "question": "帮我构建一个工作流" + }, + "optimizeWorkflows": { + "title": "优化工作流", + "question": "帮我优化我的工作流" + }, + "debugWorkflows": { + "title": "调试工作流", + "question": "帮我调试我的工作流" + } + }, + "tipPrefix": "提示:使用", + "tipSuffix": "来引用聊天、工作流、知识、区块或模板", + "shiftEnter": "Shift+Enter 换行" + }, + "input": { + "placeholderLimited": "提问或在工具执行前先审核", + "placeholderFull": "提问或允许工具无需额外批准直接执行", + "dropFilesHere": "将文件拖放到此处...", + "insertMention": "插入 @", + "attachFile": "附加文件", + "stopGeneration": "停止生成" + }, + "message": { + "editPlaceholder": "编辑你的消息...", + "stopGeneration": "停止生成", + "sources": "来源:", + "copy": "复制" + }, + "contextUsage": { + "title": "此聊天已使用上下文:{percentage}%", + "recommendedNewChat": "建议:开始一个新聊天以获得更好的质量" + }, + "todo": { + "title": "待办列表", + "expand": "展开待办列表", + "collapse": "收起待办列表" + }, + "history": { + "newChat": "新聊天", + "untitledCurrentChat": "此聊天", + "updated": "更新于 {value}", + "justNow": "刚刚", + "loading": "加载中…", + "noChatsYet": "还没有聊天", + "openChatHistory": "打开聊天历史", + "deleteChatAria": "删除聊天", + "deleteDialogTitle": "删除聊天", + "deleteDialogDescription": "此操作将永久删除 {title} 及其所有相关数据。此操作无法撤销。", + "cancel": "取消", + "deleteAction": "删除聊天", + "startNewChat": "开始新聊天", + "sending": "发送中…", + "groups": { + "today": "今天", + "yesterday": "昨天", + "thisWeek": "本周", + "lastWeek": "上周", + "older": "更早" + } + }, + "accessLevel": { + "limited": { + "label": "受限", + "description": "每个工具在执行前都需要审核。" + }, + "full": { + "label": "完全", + "description": "允许工具无需额外批准直接执行。" + } + }, + "model": { + "choose": "选择模型", + "label": "模型", + "lite": "Lite", + "providers": { + "anthropic": "Anthropic", + "openai": "OpenAI" + } + } } }, "layoutTabs": { diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index 3a10d38b6..01e064805 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -110,6 +110,22 @@ describe('public copy', () => { expect(getPublicCopy('zh').admin.integrations.title).toBe('系统管理的 OAuth') }) + it('includes localized workspace copilot widget copy', () => { + const enCopilot = getPublicCopy('en').workspace.widgets.copilot + const esCopilot = getPublicCopy('es').workspace.widgets.copilot + const zhCopilot = getPublicCopy('zh').workspace.widgets.copilot + + expect(enCopilot.welcome.cards.reviewChangesSafely.title).toBe('Review changes safely') + expect(esCopilot.welcome.cards.reviewChangesSafely.title).toBe( + 'Revisar cambios con seguridad' + ) + expect(zhCopilot.input.attachFile).toBe('附加文件') + expect(esCopilot.message.copy).toBe('Copiar') + expect(zhCopilot.message.sources).toBe('来源:') + expect(esCopilot.accessLevel.limited.label).toBe('Limitado') + expect(zhCopilot.history.groups.today).toBe('今天') + }) + it('includes localized legal copy', () => { expect(getPublicCopy('en').meta.terms.title).toBe('Terms of Service | TradingGoose') expect(getPublicCopy('es').meta.licenses.title).toBe('Licencias y avisos | TradingGoose') diff --git a/apps/tradinggoose/i18n/workspace-widget-hooks.ts b/apps/tradinggoose/i18n/workspace-widget-hooks.ts index 8d44c47a0..e777c036a 100644 --- a/apps/tradinggoose/i18n/workspace-widget-hooks.ts +++ b/apps/tradinggoose/i18n/workspace-widget-hooks.ts @@ -23,6 +23,7 @@ export type WorkflowChatMessages = WorkspaceWidgetsMessages['workflowChat'] export type WorkflowConsoleMessages = WorkspaceWidgetsMessages['console'] export type WorkflowVariablesMessages = WorkspaceWidgetsMessages['workflowVariables'] export type McpDropdownMessages = WorkspaceWidgetsMessages['mcpDropdown'] +export type CopilotMessages = WorkspaceWidgetsMessages['copilot'] export function useWorkspaceWidgetsMessages(): WorkspaceWidgetsMessages { // Any route rendering workspace widgets must provide the 'workspace' namespace in IntlProvider. @@ -96,3 +97,7 @@ export function useWorkflowVariablesMessages(): WorkflowVariablesMessages { export function useMcpDropdownMessages(): McpDropdownMessages { return useWorkspaceWidgetsMessages().mcpDropdown } + +export function useCopilotMessages(): CopilotMessages { + return useWorkspaceWidgetsMessages().copilot +} diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/context-usage-pill/context-usage-pill.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/context-usage-pill/context-usage-pill.tsx index 068aaf3a6..764e6eab2 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/context-usage-pill/context-usage-pill.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/context-usage-pill/context-usage-pill.tsx @@ -2,6 +2,9 @@ import { memo } from 'react' import { Plus } from 'lucide-react' +import { useLocale } from 'next-intl' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' +import { formatTemplate, type LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' interface ContextUsagePillProps { @@ -12,6 +15,9 @@ interface ContextUsagePillProps { export const ContextUsagePill = memo( ({ percentage, className, onCreateNewChat }: ContextUsagePillProps) => { + const locale = useLocale() as LocaleCode + const copilotCopy = useCopilotMessages() + // Don't render if invalid (but DO render if 0 or very small) if (percentage === null || percentage === undefined || Number.isNaN(percentage)) return null @@ -27,6 +33,12 @@ export const ContextUsagePill = memo( // Format: show 1 decimal for <1%, 0 decimals for >=1% const formattedPercentage = percentage < 1 ? percentage.toFixed(1) : percentage.toFixed(0) + const fullPercentage = percentage.toFixed(2) + const title = formatTemplate( + copilotCopy.contextUsage.title, + { percentage: fullPercentage }, + locale + ) return (
{formattedPercentage}% {isHighUsage && onCreateNewChat && ( @@ -46,7 +58,7 @@ export const ContextUsagePill = memo( onCreateNewChat() }} className='inline-flex items-center justify-center transition-opacity hover:opacity-70' - title='Recommended: Start a new chat for better quality' + title={copilotCopy.contextUsage.recommendedNewChat} type='button' > diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx index ab07310f1..7b0650379 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -5,6 +5,7 @@ import { Check, Copy } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' const getTextContent = (element: React.ReactNode): string => { if (typeof element === 'string') { @@ -129,6 +130,7 @@ interface CopilotMarkdownRendererProps { } export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { + const copilotCopy = useCopilotMessages() const [copiedCodeBlocks, setCopiedCodeBlocks] = useState>({}) // Reset copy success state after 2 seconds @@ -266,7 +268,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend @@ -668,7 +670,9 @@ const CopilotMessage: FC = memo( {/* Citations if available */} {message.citations && message.citations.length > 0 && (
-
Sources:
+
+ {copilotCopy.message.sources} +
{message.citations.map((citation) => ( { +type CopilotHistoryMessages = ReturnType['history'] +type ChatGroupKey = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'older' + +const formatRelativeTime = ( + value: Date | string | undefined, + locale: LocaleCode, + historyCopy: CopilotHistoryMessages +) => { if (!value) return '' + const date = value instanceof Date ? value : new Date(value) const diffMs = Date.now() - date.getTime() + if (!Number.isFinite(diffMs)) return '' + const minutes = Math.floor(diffMs / (1000 * 60)) - if (minutes < 1) return 'Just now' - if (minutes < 60) return `${minutes}m ago` + if (minutes < 1) return historyCopy.justNow + + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + + if (minutes < 60) return formatter.format(-minutes, 'minute') + const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` + if (hours < 24) return formatter.format(-hours, 'hour') + const days = Math.floor(hours / 24) - if (days < 14) return `${days}d ago` - return date.toLocaleDateString() + if (days < 14) return formatter.format(-days, 'day') + + return new Intl.DateTimeFormat(locale).format(date) } const groupChats = (chats: CopilotChat[]) => { - if (!chats || chats.length === 0) return [] as Array<[string, CopilotChat[]]> + if (!chats || chats.length === 0) return [] as Array<[ChatGroupKey, CopilotChat[]]> const sorted = [...chats].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ) @@ -52,12 +72,12 @@ const groupChats = (chats: CopilotChat[]) => { const thisWeekStart = new Date(today.getTime() - today.getDay() * 24 * 60 * 60 * 1000) const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000) - const groups: Record = { - Today: [], - Yesterday: [], - 'This Week': [], - 'Last Week': [], - Older: [], + const groups: Record = { + today: [], + yesterday: [], + thisWeek: [], + lastWeek: [], + older: [], } sorted.forEach((chat) => { @@ -65,19 +85,21 @@ const groupChats = (chats: CopilotChat[]) => { const chatDay = new Date(chatDate.getFullYear(), chatDate.getMonth(), chatDate.getDate()) if (chatDay.getTime() === today.getTime()) { - groups.Today.push(chat) + groups.today.push(chat) } else if (chatDay.getTime() === yesterday.getTime()) { - groups.Yesterday.push(chat) + groups.yesterday.push(chat) } else if (chatDay.getTime() >= thisWeekStart.getTime()) { - groups['This Week'].push(chat) + groups.thisWeek.push(chat) } else if (chatDay.getTime() >= lastWeekStart.getTime()) { - groups['Last Week'].push(chat) + groups.lastWeek.push(chat) } else { - groups.Older.push(chat) + groups.older.push(chat) } }) - return Object.entries(groups).filter(([, list]) => list.length > 0) + return Object.entries(groups).filter(([, list]) => list.length > 0) as Array< + [ChatGroupKey, CopilotChat[]] + > } interface ChatHistoryGroupProps { @@ -88,6 +110,8 @@ interface ChatHistoryGroupProps { isSendingMessage: boolean hoveredChatId: string | null onHoverChat: (chatId: string | null) => void + locale: LocaleCode + historyCopy: CopilotHistoryMessages } interface ChatHistoryItemProps { @@ -97,6 +121,8 @@ interface ChatHistoryItemProps { isSendingMessage: boolean isHovered: boolean onHoverChat: (chatId: string | null) => void + locale: LocaleCode + historyCopy: CopilotHistoryMessages } function ChatHistoryItem({ @@ -106,7 +132,15 @@ function ChatHistoryItem({ isSendingMessage, isHovered, onHoverChat, + locale, + historyCopy, }: ChatHistoryItemProps) { + const updatedLabel = formatTemplate( + historyCopy.updated, + { value: formatRelativeTime(chat.updatedAt, locale, historyCopy) }, + locale + ) + return (

- {chat.title || 'New Chat'} -

-

- Updated {formatRelativeTime(chat.updatedAt)} + {chat.title || historyCopy.newChat}

+

{updatedLabel}

@@ -184,6 +220,8 @@ export function CopilotHeader({ workspaceId?: string }) { const store = useMemo(() => getCopilotStore(channelId), [channelId]) + const locale = useLocale() as LocaleCode + const historyCopy = useCopilotMessages().history const [hoveredChatId, setHoveredChatId] = useState(null) const [deleteChatId, setDeleteChatId] = useState(null) @@ -199,6 +237,13 @@ export function CopilotHeader({ const scopedCurrentChat = currentChat && (currentChat.workspaceId ?? null) === (workspaceId ?? null) ? currentChat : null const grouped = groupChats(scopedChats) + const groupLabels: Record = { + today: historyCopy.groups.today, + yesterday: historyCopy.groups.yesterday, + thisWeek: historyCopy.groups.thisWeek, + lastWeek: historyCopy.groups.lastWeek, + older: historyCopy.groups.older, + } const handleSelectChat = async (chat: CopilotChat) => { if (scopedCurrentChat?.reviewSessionId === chat.reviewSessionId) return @@ -215,31 +260,33 @@ export function CopilotHeader({ await store.getState().loadChats({ workspaceId: workspaceId ?? null }) } - const title = scopedCurrentChat?.title || 'New Chat' + const title = scopedCurrentChat?.title || historyCopy.newChat const deleteChat = deleteChatId ? scopedChats.find((chat) => chat.reviewSessionId === deleteChatId) : null const dropdownMenuBody = (() => { if (isLoadingChats) { - return
Loading…
+ return
{historyCopy.loading}
} if (grouped.length === 0) { - return
No chats yet
+ return
{historyCopy.noChatsYet}
} return (
- {grouped.map(([label, chatsInGroup]) => ( + {grouped.map(([groupKey, chatsInGroup]) => ( ))}
@@ -259,9 +306,9 @@ export function CopilotHeader({ className={widgetHeaderControlClassName( 'group flex w-[240px] shrink-0 items-center justify-between gap-1' )} - aria-label='Open chat history' + aria-label={historyCopy.openChatHistory} > -
+
{title} @@ -291,15 +338,17 @@ export function CopilotHeader({ > - Delete chat + {historyCopy.deleteDialogTitle} - This action will permanently delete{' '} - {deleteChat?.title || 'this chat'} and all associated data. This - cannot be undone. + {formatTemplate( + historyCopy.deleteDialogDescription, + { title: deleteChat?.title || historyCopy.untitledCurrentChat }, + locale + )} - Cancel + {historyCopy.cancel} { @@ -310,7 +359,7 @@ export function CopilotHeader({ setDeleteChatId(null) }} > - Delete chat + {historyCopy.deleteAction} @@ -327,6 +376,7 @@ export function CopilotHeaderActions({ workspaceId?: string }) { const store = useMemo(() => getCopilotStore(channelId), [channelId]) + const historyCopy = useCopilotMessages().history const subscribe = useCallback(store.subscribe, [store]) const getSnapshot = useCallback(() => store.getState(), [store]) @@ -342,8 +392,8 @@ export function CopilotHeaderActions({ className={widgetHeaderIconButtonClassName()} onClick={handleNewChat} disabled={isSendingMessage} - aria-label='Start new chat' - title={isSendingMessage ? 'Sending…' : 'New chat'} + aria-label={historyCopy.startNewChat} + title={isSendingMessage ? historyCopy.sending : historyCopy.newChat} > diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/todo-list/todo-list.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/todo-list/todo-list.tsx index 984936846..a0c785130 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/todo-list/todo-list.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/todo-list/todo-list.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react' import { Check, ChevronDown, ChevronRight, ListTodo, Loader2 } from 'lucide-react' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' import { cn } from '@/lib/utils' export interface TodoItem { @@ -22,6 +23,7 @@ export const TodoList = memo(function TodoList({ collapsed = false, className, }: TodoListProps) { + const copilotCopy = useCopilotMessages() const [isCollapsed, setIsCollapsed] = useState(collapsed) // Sync collapsed prop with internal state @@ -49,7 +51,7 @@ export const TodoList = memo(function TodoList({
- Todo List + {copilotCopy.todo.title} {completedCount}/{totalCount} @@ -67,7 +69,7 @@ export const TodoList = memo(function TodoList({ @@ -69,7 +65,7 @@ export function AccessLevelSelector({ > - Limited + {accessLevelCopy.limited.label} {accessLevel === 'limited' && } @@ -80,7 +76,7 @@ export function AccessLevelSelector({ align='center' className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md' > - Reviews each tool before it runs. + {accessLevelCopy.limited.description} @@ -94,7 +90,7 @@ export function AccessLevelSelector({ > - Full + {accessLevelCopy.full.label} {accessLevel === 'full' && } @@ -105,7 +101,7 @@ export function AccessLevelSelector({ align='center' className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md' > - Allows tools to run without extra approval. + {accessLevelCopy.full.description}
diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/model-selector.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/model-selector.tsx index 88be2b95d..cbc341bde 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/model-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/model-selector.tsx @@ -1,6 +1,7 @@ 'use client' import { Brain, BrainCircuit, Zap } from 'lucide-react' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' import { Button, DropdownMenu, @@ -46,6 +47,7 @@ const getModelOptionIcon = (modelValue: CopilotRuntimeModel) => { } export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) { + const modelCopy = useCopilotMessages().model const { agentPrefetch, selectedModel, setAgentPrefetch, setSelectedModel } = useCopilotStore() const model = COPILOT_RUNTIME_MODEL_OPTIONS.find((option) => option.value === selectedModel) @@ -58,13 +60,13 @@ export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) { variant='outline' size='sm' className='flex h-6 bg-background hover:bg-muted/30 items-center gap-1.5 rounded-sm border px-2 py-1 font-medium text-xs focus-visible:ring-0 focus-visible:ring-offset-0' - title='Choose model' + title={modelCopy.choose} > {getModelOptionIcon(selectedModel)} {collapsedModeLabel} {agentPrefetch && !FAST_MODELS.includes(selectedModel) && ( - Lite + {modelCopy.lite} )} @@ -75,12 +77,12 @@ export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) {
- Model + {modelCopy.label}
- Anthropic + {modelCopy.providers.anthropic}
{COPILOT_RUNTIME_MODEL_OPTIONS.filter((option) => @@ -108,7 +110,7 @@ export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) {
- OpenAI + {modelCopy.providers.openai}
{COPILOT_RUNTIME_MODEL_OPTIONS.filter((option) => diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/user-input.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/user-input.tsx index 7e26d412f..743cef2a2 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/user-input.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/user-input.tsx @@ -10,6 +10,7 @@ import { } from 'react' import { AtSign, Loader2, Paperclip, Send, X } from 'lucide-react' import { Button, Textarea } from '@/components/ui' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' @@ -67,6 +68,7 @@ const UserInput = forwardRef( const mentionPortalRef = useRef(null) const menuListRef = useRef(null) const { data: session } = useSession() + const copilotCopy = useCopilotMessages() const { contextUsage, createNewChat } = useCopilotStore() const message = controlledValue !== undefined ? controlledValue : internalMessage const setMessage = @@ -128,8 +130,8 @@ const UserInput = forwardRef( const effectivePlaceholder = placeholder || (accessLevel === 'limited' - ? 'Ask questions or review tools before they run' - : 'Ask questions or let tools run without extra approval') + ? copilotCopy.input.placeholderLimited + : copilotCopy.input.placeholderFull) useImperativeHandle( ref, @@ -457,7 +459,7 @@ const UserInput = forwardRef(
{!message && (
- {isDragging ? 'Drop files here...' : effectivePlaceholder} + {isDragging ? copilotCopy.input.dropFilesHere : effectivePlaceholder}
)} @@ -563,7 +565,7 @@ const UserInput = forwardRef( onClick={handleOpenMentionMenuWithAt} disabled={disabled || isLoading} className='h-6 w-6 text-muted-foreground hover:text-foreground' - title='Insert @' + title={copilotCopy.input.insertMention} > @@ -576,7 +578,7 @@ const UserInput = forwardRef( onClick={handleFileSelect} disabled={disabled || isLoading} className='h-6 w-6 text-muted-foreground hover:text-foreground' - title='Attach file' + title={copilotCopy.input.attachFile} > @@ -587,7 +589,7 @@ const UserInput = forwardRef( disabled={isAborting} size='icon' className='h-6 w-6 rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600' - title='Stop generation' + title={copilotCopy.input.stopGeneration} > {isAborting ? ( diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.test.tsx new file mode 100644 index 000000000..0dd4a4564 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.test.tsx @@ -0,0 +1,60 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { CopilotWelcome } from './welcome' + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +describe('CopilotWelcome i18n', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('renders localized limited-access welcome copy', async () => { + await act(async () => { + root.render( + + + + ) + }) + + expect(container.textContent).toContain('Revisar cambios con seguridad') + expect(container.textContent).toContain('Shift+Enter para nueva línea') + }) + + it('renders localized full-access welcome copy', async () => { + await act(async () => { + root.render( + + + + ) + }) + + expect(container.textContent).toContain('构建和编辑工作流') + expect(container.textContent).toContain('提问并允许工具无需额外批准直接执行') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.tsx index 185276886..ea03c79ef 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/welcome/welcome.tsx @@ -1,6 +1,7 @@ 'use client' import { Blocks, LibraryBig, Workflow } from 'lucide-react' +import { useCopilotMessages } from '@/i18n/workspace-widget-hooks' import type { CopilotAccessLevel } from '@/lib/copilot/access-policy' interface CopilotWelcomeProps { @@ -9,48 +10,50 @@ interface CopilotWelcomeProps { } export function CopilotWelcome({ onQuestionClick, accessLevel = 'limited' }: CopilotWelcomeProps) { + const copilotCopy = useCopilotMessages() + const handleQuestionClick = (question: string) => { onQuestionClick?.(question) } const subtitle = accessLevel === 'full' - ? 'Ask questions and let tools run without extra approval' - : 'Ask questions and review tools before they run' + ? copilotCopy.welcome.subtitleFull + : copilotCopy.welcome.subtitleLimited const capabilities = accessLevel === 'full' ? [ { - title: 'Build & edit workflows', - question: 'Help me build a workflow', + title: copilotCopy.welcome.cards.buildEditWorkflows.title, + question: copilotCopy.welcome.cards.buildEditWorkflows.question, Icon: Workflow, }, { - title: 'Optimize workflows', - question: 'Help me optimize my workflow', + title: copilotCopy.welcome.cards.optimizeWorkflows.title, + question: copilotCopy.welcome.cards.optimizeWorkflows.question, Icon: Blocks, }, { - title: 'Debug workflows', - question: 'Help me debug my workflow', + title: copilotCopy.welcome.cards.debugWorkflows.title, + question: copilotCopy.welcome.cards.debugWorkflows.question, Icon: LibraryBig, }, ] : [ { - title: 'Understand workflows', - question: 'What does my workflow do?', + title: copilotCopy.welcome.cards.understandWorkflows.title, + question: copilotCopy.welcome.cards.understandWorkflows.question, Icon: Workflow, }, { - title: 'Review changes safely', - question: 'Help me update this workflow safely', + title: copilotCopy.welcome.cards.reviewChangesSafely.title, + question: copilotCopy.welcome.cards.reviewChangesSafely.question, Icon: Blocks, }, { - title: 'Plan next steps', - question: 'What should I change in this workflow next?', + title: copilotCopy.welcome.cards.planNextSteps.title, + question: copilotCopy.welcome.cards.planNextSteps.question, Icon: LibraryBig, }, ] @@ -88,10 +91,11 @@ export function CopilotWelcome({ onQuestionClick, accessLevel = 'limited' }: Cop {/* Tips */}

- Tip: Use @ to reference chats, - workflows, knowledge, blocks, or templates + {copilotCopy.welcome.tipPrefix}{' '} + @{' '} + {copilotCopy.welcome.tipSuffix}

-

Shift+Enter for newline

+

{copilotCopy.welcome.shiftEnter}

From 07989114803e9611c3be943d32f0db570f85d5af Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 11:44:02 -0600 Subject: [PATCH 39/49] feat(auth): integrate session management in LanguageSwitcher component --- .../app/(landing)/components/nav/nav.test.tsx | 13 +++++++- .../app/(landing)/components/nav/nav.tsx | 33 +++++++++---------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx index 31b6d2922..81efaf923 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx @@ -17,6 +17,7 @@ const mockReplace = vi.fn() const mockRefresh = vi.fn() const mockReplaceLocaleDocument = vi.fn() const mockUpdateSetting = vi.fn() +let mockSessionUserId: string | null = null let mockPathname = '/' let mockSearchParams = '' const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -80,6 +81,15 @@ vi.mock('@/lib/branding/branding', () => ({ }), })) +vi.mock('@/lib/auth-client', () => ({ + useSession: () => ({ + data: mockSessionUserId ? { user: { id: mockSessionUserId } } : null, + isPending: false, + error: null, + refetch: vi.fn(), + }), +})) + vi.mock('@/stores/settings/general/store', () => ({ useGeneralStore: (selector: (state: { updateSetting: typeof mockUpdateSetting }) => unknown) => selector({ updateSetting: mockUpdateSetting }), @@ -98,6 +108,7 @@ describe('landing nav registration mode', () => { vi.clearAllMocks() vi.mocked(getRegistrationModeForRender).mockReset() mockUpdateSetting.mockResolvedValue(undefined) + mockSessionUserId = null mockPathname = '/' mockSearchParams = '' container = document.createElement('div') @@ -217,7 +228,7 @@ describe('landing nav registration mode', () => { await flush() }) - expect(mockUpdateSetting).toHaveBeenCalledWith('preferredLocale', 'zh') + expect(mockUpdateSetting).not.toHaveBeenCalled() expect(mockReplaceLocaleDocument).toHaveBeenCalledWith( 'zh', '/blog/trading-signals?from=nav&campaign=i18n' diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx index 2d67a2ac7..17be151f6 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx @@ -16,21 +16,19 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Separator } from '@/components/ui/separator' +import { useSession } from '@/lib/auth-client' import { useBrandConfig } from '@/lib/branding/branding' import { createLogger } from '@/lib/logs/console/logger' -import { - getRegistrationPrimaryHref, - type RegistrationMode, -} from '@/lib/registration/shared' +import { getRegistrationPrimaryHref, type RegistrationMode } from '@/lib/registration/shared' import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' import { soehne } from '@/app/fonts/soehne/soehne' -import { formatTemplate } from '@/i18n/utils' import { Link, replaceLocaleDocument, usePathname, useRouter } from '@/i18n/navigation' import { + formatTemplate, getLocaleDisplayName, - localizeDocsUrl, - locales, type LocaleCode, + locales, + localizeDocsUrl, } from '@/i18n/utils' import { useGeneralStore } from '@/stores/settings/general/store' @@ -48,6 +46,7 @@ function LanguageSwitcher() { const pathname = usePathname() const searchParams = useSearchParams() const updateSetting = useGeneralStore((state) => state.updateSetting) + const { data: session } = useSession() const [isOpen, setIsOpen] = useState(false) const search = searchParams.toString() @@ -60,10 +59,12 @@ function LanguageSwitcher() { setIsOpen(false) const href = search ? `${pathname}?${search}` : pathname - try { - await updateSetting('preferredLocale', nextLocale) - } catch (error) { - logger.error('Failed to persist preferred locale:', { error, locale: nextLocale }) + if (session?.user?.id) { + try { + await updateSetting('preferredLocale', nextLocale) + } catch (error) { + logger.error('Failed to persist preferred locale:', { error, locale: nextLocale }) + } } replaceLocaleDocument(nextLocale, href) } @@ -157,11 +158,7 @@ export default function Nav({ > {copy.nav.docs}
- + {copy.nav.blog} ) : null} - {registrationActions ?
{registrationActions}
: null} + {registrationActions ? ( +
{registrationActions}
+ ) : null} {variant === 'landing' ? ( From 757551978bc10563a9fa678d54a788551e0ae19c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 12:03:31 -0600 Subject: [PATCH 40/49] feat(i18n): refactor locale handling in user settings API and improve default settings response --- .../app/api/users/me/settings/route.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/users/me/settings/route.ts b/apps/tradinggoose/app/api/users/me/settings/route.ts index c737ac8c1..15babfe24 100644 --- a/apps/tradinggoose/app/api/users/me/settings/route.ts +++ b/apps/tradinggoose/app/api/users/me/settings/route.ts @@ -2,6 +2,7 @@ import { db } from '@tradinggoose/db' import { settings } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { cookies } from 'next/headers' import { NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -10,6 +11,7 @@ import { generateRequestId } from '@/lib/utils' import { defaultLocale, isLocaleCode, locales } from '@/i18n/utils' const logger = createLogger('UserSettingsAPI') +const LOCALE_COOKIE = 'NEXT_LOCALE' const SettingsSchema = z.object({ theme: z.enum(['system', 'light', 'dark']).optional(), @@ -36,7 +38,7 @@ const defaultSettings = { function withPreferredLocaleCookie(response: NextResponse, locale: string | null | undefined) { if (locale && isLocaleCode(locale)) { - response.cookies.set('NEXT_LOCALE', locale, { + response.cookies.set(LOCALE_COOKIE, locale, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax', @@ -46,6 +48,11 @@ function withPreferredLocaleCookie(response: NextResponse, locale: string | null return response } +async function getRuntimeLocale() { + const locale = (await cookies()).get(LOCALE_COOKIE)?.value + return locale && isLocaleCode(locale) ? locale : defaultLocale +} + export async function GET() { const requestId = generateRequestId() @@ -61,10 +68,8 @@ export async function GET() { const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) if (!result.length) { - return withPreferredLocaleCookie( - NextResponse.json({ data: defaultSettings }, { status: 200 }), - defaultSettings.preferredLocale - ) + const preferredLocale = await getRuntimeLocale() + return NextResponse.json({ data: { ...defaultSettings, preferredLocale } }, { status: 200 }) } const userSettings = result[0] From b5a1640a45439b27624aa293d733f48c6b983cb6 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 12:28:04 -0600 Subject: [PATCH 41/49] feat(i18n): enhance localization in layout, not-found, sitemap, and metadata handling --- apps/tradinggoose/app/[locale]/layout.tsx | 13 +++- apps/tradinggoose/app/[locale]/not-found.tsx | 18 ++++- apps/tradinggoose/app/sitemap.ts | 71 +++++++++++--------- apps/tradinggoose/lib/branding/metadata.ts | 46 +++++-------- 4 files changed, 80 insertions(+), 68 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/layout.tsx b/apps/tradinggoose/app/[locale]/layout.tsx index dfb6dd7bf..76bd0c153 100644 --- a/apps/tradinggoose/app/[locale]/layout.tsx +++ b/apps/tradinggoose/app/[locale]/layout.tsx @@ -6,7 +6,7 @@ import { PublicEnvScript } from 'next-runtime-env' import { generateBrandedMetadata } from '@/lib/branding/metadata' import { PostHogProvider } from '@/lib/posthog/provider' import { getClientMessages } from '@/i18n/public-copy' -import { routing } from '@/i18n/routing' +import { type AppLocale, routing } from '@/i18n/routing' import 'monaco-editor/min/vs/editor/editor.main.css' import '@/app/globals.css' @@ -26,7 +26,16 @@ export const viewport: Viewport = { ], } -export const metadata: Metadata = generateBrandedMetadata() +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + return generateBrandedMetadata( + hasLocale(routing.locales, locale) ? (locale as AppLocale) : routing.defaultLocale + ) +} export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })) diff --git a/apps/tradinggoose/app/[locale]/not-found.tsx b/apps/tradinggoose/app/[locale]/not-found.tsx index 4daf3674a..cd253487c 100644 --- a/apps/tradinggoose/app/[locale]/not-found.tsx +++ b/apps/tradinggoose/app/[locale]/not-found.tsx @@ -1,5 +1,19 @@ +import { NextIntlClientProvider } from 'next-intl' +import { getLocale } from 'next-intl/server' +import { SessionProvider } from '@/lib/session/session-context' import NotFoundContent from '@/app/not-found-content' +import { getClientMessages } from '@/i18n/public-copy' +import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' -export default function NotFound() { - return +export default async function NotFound() { + const requestLocale = await getLocale() + const locale: LocaleCode = isLocaleCode(requestLocale) ? requestLocale : defaultLocale + + return ( + + + + + + ) } diff --git a/apps/tradinggoose/app/sitemap.ts b/apps/tradinggoose/app/sitemap.ts index 8979fddc4..c75a3ddb7 100644 --- a/apps/tradinggoose/app/sitemap.ts +++ b/apps/tradinggoose/app/sitemap.ts @@ -1,68 +1,73 @@ import type { MetadataRoute } from 'next' import { getAllPosts } from '@/app/(landing)/blog/lib/posts' -import { SITE_BASE_URL } from '@/i18n/utils' +import { locales } from '@/i18n/routing' +import { localizeSiteUrl } from '@/i18n/utils' + +type SitemapEntry = Omit + +function localizedEntries(pathname: string, entry: SitemapEntry): MetadataRoute.Sitemap { + return locales.map((locale) => ({ + url: localizeSiteUrl(locale, pathname), + ...entry, + })) +} export default async function sitemap(): Promise { - const baseUrl = SITE_BASE_URL const posts = await getAllPosts() + const lastModified = new Date() // Keep the sitemap focused on stable public-entry pages. // Auth flows like /login, /signup, and /waitlist are intentionally omitted. const staticPages = [ - { - url: `${baseUrl}/`, - lastModified: new Date(), + ...localizedEntries('/', { + lastModified, changeFrequency: 'daily' as const, priority: 1, - }, - { - url: `${baseUrl}/changelog`, - lastModified: new Date(), + }), + ...localizedEntries('/changelog', { + lastModified, changeFrequency: 'weekly' as const, priority: 0.8, - }, - { - url: `${baseUrl}/blog`, - lastModified: new Date(), + }), + ...localizedEntries('/blog', { + lastModified, changeFrequency: 'weekly' as const, priority: 0.9, - }, - { - url: `${baseUrl}/terms`, - lastModified: new Date(), + }), + ...localizedEntries('/terms', { + lastModified, changeFrequency: 'monthly' as const, priority: 0.5, - }, - { - url: `${baseUrl}/privacy`, - lastModified: new Date(), + }), + ...localizedEntries('/privacy', { + lastModified, changeFrequency: 'monthly' as const, priority: 0.5, - }, - { - url: `${baseUrl}/licenses`, - lastModified: new Date(), + }), + ...localizedEntries('/licenses', { + lastModified, changeFrequency: 'monthly' as const, priority: 0.4, - }, + }), // Documentation subdomain — high-value citable surface for AI crawlers. // The docs site owns its own sitemap at docs.tradinggoose.ai/sitemap.xml, // but we anchor the root so crawlers that only parse the apex sitemap // still discover the entry point. { url: 'https://docs.tradinggoose.ai', - lastModified: new Date(), + lastModified, changeFrequency: 'weekly' as const, priority: 0.9, }, ] - const postPages = posts.map((post) => ({ - url: `${baseUrl}/blog/${post.slug}`, - lastModified: new Date(post.date), - changeFrequency: 'monthly' as const, - priority: 0.7, - })) + const postPages = posts.flatMap((post) => + localizedEntries(`/blog/${post.slug}`, { + lastModified: new Date(post.date), + changeFrequency: 'monthly' as const, + priority: 0.7, + }) + ) return [...staticPages, ...postPages] } diff --git a/apps/tradinggoose/lib/branding/metadata.ts b/apps/tradinggoose/lib/branding/metadata.ts index d433d3727..94bfb8b20 100644 --- a/apps/tradinggoose/lib/branding/metadata.ts +++ b/apps/tradinggoose/lib/branding/metadata.ts @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { getBrandConfig } from '@/lib/branding/branding' -import { SITE_BASE_URL } from '@/i18n/utils' +import { getPublicCopy } from '@/i18n/public-copy' +import { defaultLocale, getOpenGraphLocale, type LocaleCode, SITE_BASE_URL } from '@/i18n/utils' export const DEFAULT_META_DESCRIPTION = 'Open-source LLM trading platform. Connect data providers, write custom indicators in PineTS, and trigger AI agent workflows on live signals.' @@ -8,46 +9,30 @@ export const DEFAULT_META_DESCRIPTION = /** * Generate dynamic metadata based on brand configuration */ -export function generateBrandedMetadata(override: Partial = {}): Metadata { +export function generateBrandedMetadata( + locale: LocaleCode = defaultLocale, + override: Partial = {} +): Metadata { const brand = getBrandConfig() + const copy = getPublicCopy(locale) + const landingMeta = copy.meta.landing const defaultTitle = brand.name - const summaryFull = `TradingGoose is an open-source visual workflow platform for technical LLM-driven trading. Connect your own market data providers, write custom indicators in PineTS, monitor live prices, and wire signals into AI agent workflows that place trades, send alerts, rebalance portfolios, or run any action you define. Build workspaces with split-panel widgets, chart multiple indicators, and backtest strategies against historical candle data.` return { title: { template: `%s | ${brand.name}`, default: defaultTitle, }, - description: DEFAULT_META_DESCRIPTION, + description: landingMeta.description, applicationName: brand.name, authors: [{ name: brand.name }], generator: 'Next.js', - keywords: [ - 'AI trading workflows', - 'LLM trading agents', - 'technical trading automation', - 'custom trading indicators', - 'PineTS indicators', - 'visual trading workflow builder', - 'trading signal automation', - 'market data workflow', - 'backtesting platform', - 'open source trading platform', - 'algorithmic trading', - 'trading bot workflow', - 'AI trading assistant', - 'quant workflow tools', - ], + keywords: landingMeta.seo.keywords, referrer: 'origin-when-cross-origin', creator: brand.name, publisher: brand.name, metadataBase: new URL(SITE_BASE_URL), - alternates: { - languages: { - 'en-US': '/en-US', - }, - }, robots: { index: true, follow: true, @@ -61,10 +46,9 @@ export function generateBrandedMetadata(override: Partial = {}): Metad }, openGraph: { type: 'website', - locale: 'en_US', - url: SITE_BASE_URL, - title: defaultTitle, - description: summaryFull, + locale: getOpenGraphLocale(locale), + title: landingMeta.openGraphTitle, + description: landingMeta.openGraphDescription, siteName: brand.name, images: [ { @@ -77,8 +61,8 @@ export function generateBrandedMetadata(override: Partial = {}): Metad }, twitter: { card: 'summary_large_image', - title: defaultTitle, - description: summaryFull, + title: landingMeta.openGraphTitle, + description: landingMeta.openGraphDescription, images: [{ url: '/social-preview.png', alt: brand.name }], creator: '@BruzWJ', }, From fe76232868cb79dbe234acf8d0f5c9a745bff13f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 12:55:05 -0600 Subject: [PATCH 42/49] feat(i18n): implement workspace access check in metrics execution route and update related tests --- .../[id]/metrics/executions/route.test.ts | 43 ++++++++++++------- .../[id]/metrics/executions/route.ts | 19 +++----- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.test.ts index b2d4ec903..a127cdd17 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.test.ts @@ -7,14 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, + mockCheckWorkspaceAccess, mockSelect, - mockPermissionLimit, mockWorkflowsWhere, mockLogsWhere, mockLeftJoin, } = vi.hoisted(() => { const mockGetSession = vi.fn() - const mockPermissionLimit = vi.fn() + const mockCheckWorkspaceAccess = vi.fn() const mockWorkflowsWhere = vi.fn() const mockLogsWhere = vi.fn() const mockLeftJoin = vi.fn(() => ({ @@ -24,8 +24,8 @@ const { return { mockGetSession, + mockCheckWorkspaceAccess, mockSelect, - mockPermissionLimit, mockWorkflowsWhere, mockLogsWhere, mockLeftJoin, @@ -39,11 +39,6 @@ vi.mock('@tradinggoose/db', () => ({ })) vi.mock('@tradinggoose/db/schema', () => ({ - permissions: { - entityId: 'permissions.entityId', - entityType: 'permissions.entityType', - userId: 'permissions.userId', - }, workflow: { folderId: 'workflow.folderId', id: 'workflow.id', @@ -84,6 +79,10 @@ vi.mock('@/lib/auth', () => ({ getSession: (...args: unknown[]) => mockGetSession(...args), })) +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: (...args: unknown[]) => mockCheckWorkspaceAccess(...args), +})) + vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => ({ error: vi.fn(), @@ -108,14 +107,8 @@ describe('workspace execution metrics route', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: true }) mockSelect - .mockReturnValueOnce({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockPermissionLimit, - })), - })), - }) .mockReturnValueOnce({ from: vi.fn(() => ({ where: mockWorkflowsWhere, @@ -126,7 +119,6 @@ describe('workspace execution metrics route', () => { leftJoin: mockLeftJoin, })), }) - mockPermissionLimit.mockResolvedValue([{ id: 'permission-1' }]) mockWorkflowsWhere.mockResolvedValue([]) mockLogsWhere.mockResolvedValue([ { @@ -197,5 +189,24 @@ describe('workspace execution metrics route', () => { condition.value.includes('folder-1') ) ).toBe(true) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + }) + + it('returns not found when canonical workspace access is denied', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: false }) + const { GET } = await import('./route') + + const response = await GET( + new NextRequest( + 'http://localhost/api/workspaces/workspace-1/metrics/executions?startTime=2026-04-23T00:00:00.000Z&endTime=2026-04-23T01:00:00.000Z&segments=1' + ), + { params: Promise.resolve({ id: 'workspace-1' }) } + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: 'Workspace not found or access denied', + }) + expect(mockSelect).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.ts index fa391a945..622fb4d25 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.ts @@ -1,10 +1,11 @@ import { db } from '@tradinggoose/db' -import { permissions, workflow, workflowExecutionLogs } from '@tradinggoose/db/schema' +import { workflow, workflowExecutionLogs } from '@tradinggoose/db/schema' import { and, eq, gte, inArray, lte, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('MetricsExecutionsAPI') @@ -40,19 +41,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const totalMs = Math.max(1, end.getTime() - start.getTime()) const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments))) - const [permission] = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, userId) - ) - ) - .limit(1) - if (!permission) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } const workflowIds = qp.workflowIds?.split(',').filter(Boolean) ?? [] const folderIds = qp.folderIds?.split(',').filter(Boolean) ?? [] From 06bbaf41021284331b4d2e34538af4bd3a2ae266 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 13:14:40 -0600 Subject: [PATCH 43/49] feat(records): localize records dashboard and share filter metadata Co-authored-by: Codex --- .../components/logs-list/logs-list.tsx | 35 +++---- .../components/filters/components/folder.tsx | 21 +++-- .../components/filters/components/level.tsx | 20 ++-- .../components/filters/components/shared.ts | 54 +++++++++++ .../filters/components/timeline.tsx | 24 ++--- .../components/filters/components/trigger.tsx | 32 +++---- .../logs-toolbar/components/search/search.tsx | 4 +- .../components/stats/components/kpis.tsx | 12 ++- .../stats/components/line-chart.tsx | 8 +- .../stats/components/status-bar.tsx | 21 +++-- .../stats/components/workflow-details.tsx | 31 ++++--- .../records/components/stats/stats.tsx | 92 ++++++------------- .../records/components/stats/utils.ts | 17 ---- .../[workspaceId]/records/records.test.tsx | 4 +- .../[workspaceId]/records/records.tsx | 38 ++++---- 15 files changed, 208 insertions(+), 205 deletions(-) delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/utils.ts diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-list/logs-list.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-list/logs-list.tsx index 92a7d2581..8943b9dff 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-list/logs-list.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-list/logs-list.tsx @@ -12,31 +12,17 @@ import { TableRow, } from '@/components/ui/table' import { TooltipProvider } from '@/components/ui/tooltip' -import { formatDurationMs } from '@/i18n/formatters' import { cn } from '@/lib/utils' +import { + getLogLevelOption, + getLogTriggerColor, + getLogTriggerOption, +} from '@/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared' import Timeline from '@/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/timeline' import { formatDate } from '@/app/workspace/[workspaceId]/records/utils' +import { formatDurationMs } from '@/i18n/formatters' import type { WorkflowLog } from '@/stores/logs/filters/types' -const getTriggerColor = (trigger: string | null | undefined): string => { - if (!trigger) return '#9ca3af' - - switch (trigger.toLowerCase()) { - case 'manual': - return '#9ca3af' - case 'schedule': - return '#10b981' - case 'webhook': - return '#f97316' - case 'chat': - return '#8b5cf6' - case 'api': - return '#3b82f6' - default: - return '#9ca3af' - } -} - export interface LogsListProps { logs: WorkflowLog[] selectedLogId: string | null @@ -64,6 +50,7 @@ export function LogsList({ }: LogsListProps) { const locale = useLocale() const t = useTranslations('workspace.logs.list') + const tFilters = useTranslations('workspace.logs.dashboard.filters') return (
@@ -167,6 +154,8 @@ export function LogsList({ {logs.map((log) => { const formattedDate = formatDate(log.startedAt ?? log.createdAt, locale) const isSelected = selectedLogId === log.id + const levelOption = getLogLevelOption(log.level) + const triggerOption = getLogTriggerOption(log.trigger) return ( - {log.level} + {levelOption ? tFilters(levelOption.labelKey) : log.level}
@@ -227,10 +216,10 @@ export function LogsList({ style={ log.trigger.toLowerCase() === 'manual' ? undefined - : { backgroundColor: getTriggerColor(log.trigger) } + : { backgroundColor: getLogTriggerColor(log.trigger) } } > - {log.trigger} + {triggerOption ? tFilters(triggerOption.labelKey) : log.trigger}
) : (
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/folder.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/folder.tsx index f8cd1c026..5475a3db8 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/folder.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/folder.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { useParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -33,6 +34,7 @@ interface FolderOption { } export default function FolderFilter() { + const t = useTranslations('workspace.logs.dashboard.filters') const triggerRef = useRef(null) const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() const { getFolderTree } = useFolderStore() @@ -68,12 +70,15 @@ export default function FolderFilter() { // Get display text for the dropdown button const getSelectedFoldersText = () => { - if (folderIds.length === 0) return 'All folders' + if (folderIds.length === 0) return t('allFolders') if (folderIds.length === 1) { const selected = folders.find((f) => f.id === folderIds[0]) - return selected ? selected.name : 'All folders' + return selected ? selected.name : t('allFolders') } - return `${folderIds.length} folders selected` + return t('selectedFolders', { + count: folderIds.length, + plural: folderIds.length === 1 ? '' : 's', + }) } // Check if a folder is selected @@ -90,7 +95,7 @@ export default function FolderFilter() { @@ -102,11 +107,9 @@ export default function FolderFilter() { className={dropdownContentClass} > - setSearch(v)} /> + setSearch(v)} /> - - {foldersLoading ? 'Loading folders...' : 'No folders found.'} - + {foldersLoading ? t('loadingFolders') : t('noFolders')} - All folders + {t('allFolders')} {folderIds.length === 0 && ( )} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/level.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/level.tsx index 321e9a2b8..80aacbde2 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/level.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/level.tsx @@ -1,4 +1,5 @@ import { Check, ChevronDown } from 'lucide-react' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -8,19 +9,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useFilterStore } from '@/stores/logs/filters/store' -import type { LogLevel } from '@/stores/logs/filters/types' +import { logLevelOptions } from './shared' export default function Level() { + const t = useTranslations('workspace.logs.dashboard.filters') const { level, setLevel } = useFilterStore() - const specificLevels: { value: LogLevel; label: string; color: string }[] = [ - { value: 'error', label: 'Error', color: 'bg-destructive/100' }, - { value: 'info', label: 'Info', color: 'bg-muted-foreground/100' }, - ] const getDisplayLabel = () => { - if (level === 'all') return 'Any status' - const selected = specificLevels.find((l) => l.value === level) - return selected ? selected.label : 'Any status' + if (level === 'all') return t('anyStatus') + const selected = logLevelOptions.find((option) => option.value === level) + return selected ? t(selected.labelKey) : t('anyStatus') } return ( @@ -47,13 +45,13 @@ export default function Level() { }} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' > - Any status + {t('anyStatus')} {level === 'all' && } - {specificLevels.map((levelItem) => ( + {logLevelOptions.map((levelItem) => ( { @@ -64,7 +62,7 @@ export default function Level() { >
- {levelItem.label} + {t(levelItem.labelKey)}
{level === levelItem.value && } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts index 802aac839..a612c86dd 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts @@ -1,3 +1,5 @@ +import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types' + export const filterButtonClass = 'inline-flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border border-[#E5E5E5] bg-background px-3 font-normal text-sm text-foreground transition-colors ring-offset-background hover:bg-card hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 dark:border-[#414141]' @@ -29,3 +31,55 @@ export const timelineDropdownListStyle = { overflowY: 'auto', overflowX: 'hidden', } as const + +export const logLevelOptions = [ + { value: 'error', labelKey: 'error', color: 'bg-destructive/100' }, + { value: 'info', labelKey: 'info', color: 'bg-muted-foreground/100' }, +] as const satisfies readonly { value: LogLevel; labelKey: 'error' | 'info'; color: string }[] + +export const logTriggerOptions = [ + { value: 'manual', labelKey: 'manual', color: '#9ca3af', colorClass: 'bg-gray-500' }, + { value: 'api', labelKey: 'api', color: '#3b82f6', colorClass: 'bg-blue-500' }, + { value: 'webhook', labelKey: 'webhook', color: '#f97316', colorClass: 'bg-orange-500' }, + { value: 'schedule', labelKey: 'schedule', color: '#10b981', colorClass: 'bg-green-500' }, + { value: 'chat', labelKey: 'chat', color: '#8b5cf6', colorClass: 'bg-amber-500' }, +] as const satisfies readonly { + value: TriggerType + labelKey: 'api' | 'chat' | 'manual' | 'schedule' | 'webhook' + color: string + colorClass: string +}[] + +export const logTimeRangeOptions: TimeRange[] = [ + 'Past 30 minutes', + 'Past hour', + 'Past 6 hours', + 'Past 12 hours', + 'Past 24 hours', + 'Past 3 days', + 'Past 7 days', + 'Past 14 days', + 'Past 30 days', +] + +export const logTimeRangeLabelKeys = { + 'All time': 'allTime', + 'Past 30 minutes': 'past30Minutes', + 'Past hour': 'pastHour', + 'Past 6 hours': 'past6Hours', + 'Past 12 hours': 'past12Hours', + 'Past 24 hours': 'past24Hours', + 'Past 3 days': 'past3Days', + 'Past 7 days': 'past7Days', + 'Past 14 days': 'past14Days', + 'Past 30 days': 'past30Days', +} as const satisfies Record + +export const getLogLevelOption = (level: string | null | undefined) => + logLevelOptions.find((option) => option.value === level?.toLowerCase()) + +export const getLogTriggerOption = (trigger: string | null | undefined) => + logTriggerOptions.find((option) => option.value === trigger?.toLowerCase()) + +export const getLogTriggerColor = (trigger: string | null | undefined) => + getLogTriggerOption(trigger)?.color ?? '#9ca3af' diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/timeline.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/timeline.tsx index 644c66d8e..e7692f983 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/timeline.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/timeline.tsx @@ -1,4 +1,5 @@ import { Check, ChevronDown } from 'lucide-react' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -13,6 +14,8 @@ import { commandListClass, dropdownContentClass, filterButtonClass, + logTimeRangeLabelKeys, + logTimeRangeOptions, timelineDropdownListStyle, } from './shared' @@ -21,24 +24,15 @@ type TimelineProps = { } export default function Timeline({ variant = 'default' }: TimelineProps = {}) { + const t = useTranslations('workspace.logs.dashboard.filters') const { timeRange, setTimeRange } = useFilterStore() - const specificTimeRanges: TimeRange[] = [ - 'Past 30 minutes', - 'Past hour', - 'Past 6 hours', - 'Past 12 hours', - 'Past 24 hours', - 'Past 3 days', - 'Past 7 days', - 'Past 14 days', - 'Past 30 days', - ] + const timeRangeLabel = (range: TimeRange) => t(logTimeRangeLabelKeys[range]) return ( @@ -60,13 +54,13 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) { }} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' > - All time + {timeRangeLabel('All time')} {timeRange === 'All time' && } - {specificTimeRanges.map((range) => ( + {logTimeRangeOptions.map((range) => ( { @@ -74,7 +68,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) { }} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' > - {range} + {timeRangeLabel(range)} {timeRange === range && } ))} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/trigger.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/trigger.tsx index 00b76d165..dc4eddcfe 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/trigger.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/trigger.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' import { Check, ChevronDown } from 'lucide-react' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -10,27 +11,24 @@ import { } from '@/components/ui/dropdown-menu' import { useFilterStore } from '@/stores/logs/filters/store' import type { TriggerType } from '@/stores/logs/filters/types' -import { dropdownContentClass, filterButtonClass } from './shared' +import { dropdownContentClass, filterButtonClass, logTriggerOptions } from './shared' export default function Trigger() { + const t = useTranslations('workspace.logs.dashboard.filters') const { triggers, toggleTrigger, setTriggers } = useFilterStore() const triggerRef = useRef(null) - const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [ - { value: 'manual', label: 'Manual', color: 'bg-gray-500' }, - { value: 'api', label: 'API', color: 'bg-blue-500' }, - { value: 'webhook', label: 'Webhook', color: 'bg-orange-500' }, - { value: 'schedule', label: 'Schedule', color: 'bg-green-500' }, - { value: 'chat', label: 'Chat', color: 'bg-amber-500' }, - ] // Get display text for the dropdown button const getSelectedTriggersText = () => { - if (triggers.length === 0) return 'All triggers' + if (triggers.length === 0) return t('allTriggers') if (triggers.length === 1) { - const selected = triggerOptions.find((t) => t.value === triggers[0]) - return selected ? selected.label : 'All triggers' + const selected = logTriggerOptions.find((option) => option.value === triggers[0]) + return selected ? t(selected.labelKey) : t('allTriggers') } - return `${triggers.length} triggers selected` + return t('selectedTriggers', { + count: triggers.length, + plural: triggers.length === 1 ? '' : 's', + }) } // Check if a trigger is selected @@ -63,21 +61,19 @@ export default function Trigger() { onSelect={() => clearSelections()} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-secondary/50 focus:bg-secondary/50' > - All triggers + {t('allTriggers')} {triggers.length === 0 && } - {triggerOptions.map((triggerItem) => ( + {logTriggerOptions.map((triggerItem) => ( toggleTrigger(triggerItem.value)} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-secondary/50 focus:bg-secondary/50' >
- {triggerItem.color && ( -
- )} - {triggerItem.label} +
+ {t(triggerItem.labelKey)}
{isTriggerSelected(triggerItem.value) && ( diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/search/search.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/search/search.tsx index e7f07e538..4b949e791 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/search/search.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/search/search.tsx @@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Search, X } from 'lucide-react' +import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { useTranslations } from 'next-intl' import { serializeQuery } from '@/lib/logs/query-parser' import type { QueryPolicy, SearchClause } from '@/lib/logs/query-types' import { @@ -37,7 +37,7 @@ export function AutocompleteSearch({ value, onChange, queryPolicy, - placeholder = 'Search logs...', + placeholder, workflowsData = [], foldersData = [], availableMonitorRows = [], diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/kpis.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/kpis.tsx index ff3f68f00..1c1d55258 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/kpis.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/kpis.tsx @@ -1,3 +1,5 @@ +import { useTranslations } from 'next-intl' + export interface AggregateMetrics { totalExecutions: number successfulExecutions: number @@ -7,28 +9,30 @@ export interface AggregateMetrics { } export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) { + const t = useTranslations('workspace.logs.dashboard.metrics') + return (
-
Total executions
+
{t('totalExecutions')}
{aggregate.totalExecutions.toLocaleString()}
-
Success rate
+
{t('successRate')}
{aggregate.successRate.toFixed(1)}%
-
Failed executions
+
{t('failedExecutions')}
{aggregate.failedExecutions.toLocaleString()}
-
Active workflows
+
{t('activeWorkflows')}
{aggregate.activeWorkflows}
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/line-chart.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/line-chart.tsx index 0760cf47e..8011e1fce 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/line-chart.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/line-chart.tsx @@ -428,16 +428,16 @@ export function LineChart({ const formatTick = (d: Date) => { if (spanMs <= 36 * 60 * 60 * 1000) { - return d.toLocaleTimeString('en-US', { + return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, }) } if (spanMs <= 90 * 24 * 60 * 60 * 1000) { - return d.toLocaleString('en-US', { month: 'short', day: 'numeric' }) + return d.toLocaleString(locale, { month: 'short', day: 'numeric' }) } - return d.toLocaleString('en-US', { month: 'short', year: 'numeric' }) + return d.toLocaleString(locale, { month: 'short', year: 'numeric' }) } return idx.map((i) => { @@ -465,7 +465,7 @@ export function LineChart({ const unitSuffix = (unit || '').trim() const showInTicks = unitSuffix === '%' const fmtCompact = (v: number) => - new Intl.NumberFormat('en-US', { + new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1, }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/status-bar.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/status-bar.tsx index a059b7f47..7b2175939 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/status-bar.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/status-bar.tsx @@ -1,4 +1,6 @@ import { memo, useMemo, useState } from 'react' +import { useLocale, useTranslations } from 'next-intl' +import { formatDate } from '@/app/workspace/[workspaceId]/records/utils' export interface StatusBarSegment { successRate: number @@ -28,22 +30,29 @@ export function StatusBar({ segmentDurationMs: number preferBelow?: boolean }) { + const locale = useLocale() + const t = useTranslations('workspace.logs.dashboard.workflows') const [hoverIndex, setHoverIndex] = useState(null) const labels = useMemo(() => { return segments.map((segment) => { const start = new Date(segment.timestamp) const end = new Date(start.getTime() + (segmentDurationMs || 0)) - const rangeLabel = Number.isNaN(start.getTime()) - ? '' - : `${start.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })} – ${end.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}` + const rangeLabel = + Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) + ? '' + : `${formatDate(start.toISOString(), locale).compactDate} ${formatDate(start.toISOString(), locale).compactTime} - ${formatDate(end.toISOString(), locale).compactTime}` return { rangeLabel, successLabel: `${segment.successRate.toFixed(1)}%`, - countsLabel: `${segment.successfulExecutions ?? 0}/${segment.totalExecutions ?? 0} succeeded`, + countsLabel: t('succeeded', { + success: segment.successfulExecutions ?? 0, + total: segment.totalExecutions ?? 0, + plural: segment.totalExecutions !== 1 ? 's' : '', + }), } }) - }, [segments, segmentDurationMs]) + }, [locale, segmentDurationMs, segments, t]) return (
@@ -73,7 +82,7 @@ export function StatusBar({ className={`h-6 flex-1 rounded-xs ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${ isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : 'relative z-0' }`} - aria-label={`Segment ${i + 1}`} + aria-label={t('segment', { index: i + 1 })} onMouseEnter={() => setHoverIndex(i)} onMouseDown={(e) => { e.preventDefault() diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/workflow-details.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/workflow-details.tsx index 6fc5aa137..cf83f5286 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/workflow-details.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/components/workflow-details.tsx @@ -1,14 +1,18 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Info, Loader2 } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' import type { WorkflowLog } from '@/lib/logs/types' import { cn } from '@/lib/utils' +import { + getLogLevelOption, + getLogTriggerColor, + getLogTriggerOption, +} from '@/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared' import LineChart, { type LineChartPoint, } from '@/app/workspace/[workspaceId]/records/components/stats/components/line-chart' -import { getTriggerColor } from '@/app/workspace/[workspaceId]/records/components/stats/utils' import { extractOutput, formatDate } from '@/app/workspace/[workspaceId]/records/utils' +import { useRouter } from '@/i18n/navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface WorkflowDetailsData { @@ -75,6 +79,7 @@ export function WorkflowDetails({ const router = useRouter() const locale = useLocale() const t = useTranslations('workspace.logs.dashboard.workflows') + const tFilters = useTranslations('workspace.logs.dashboard.filters') const { workflows } = useWorkflowRegistry() const workflowColor = useMemo( () => workflows[expandedWorkflowId]?.color || '#3972F6', @@ -265,7 +270,9 @@ export function WorkflowDetails({ timestamp: e.timestamp, value: ((e.value || 0) / 100) * (details.executionCounts[i]?.value || 0), })) - return + return ( + + ) })()}
) @@ -310,13 +317,11 @@ export function WorkflowDetails({ if (logsToDisplay.length === 0) { return (
-
- - - {t('noExecutions')} - -
+
+ + {t('noExecutions')}
+
) } @@ -329,6 +334,8 @@ export function WorkflowDetails({ const outputsStr = readWorkflowLogOutputText(log) const errorStr = readWorkflowLogErrorText(log) || '' const isExpanded = expandedRowId === log.id + const levelOption = getLogLevelOption(log.level) + const triggerOption = getLogTriggerOption(log.trigger) return (
- {log.level} + {levelOption ? tFilters(levelOption.labelKey) : log.level}
@@ -381,10 +388,10 @@ export function WorkflowDetails({ style={ log.trigger.toLowerCase() === 'manual' ? undefined - : { backgroundColor: getTriggerColor(log.trigger) } + : { backgroundColor: getLogTriggerColor(log.trigger) } } > - {log.trigger} + {triggerOption ? tFilters(triggerOption.labelKey) : log.trigger}
) : (
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/stats.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/stats.tsx index e0146154f..f93436ec9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/stats.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/stats.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' import type { WorkflowLog } from '@/lib/logs/types' import { soehne } from '@/app/fonts/soehne/soehne' import FolderFilter from '@/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/folder' @@ -57,6 +58,9 @@ type StatsProps = { export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: StatsProps) { const params = useParams() const workspaceId = params.workspaceId as string + const locale = useLocale() + const t = useTranslations('workspace.logs.dashboard') + const tWorkflows = useTranslations('workspace.logs.dashboard.workflows') const getTimeFilterFromRange = (range: string): TimeFilter => { switch (range) { @@ -215,7 +219,7 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: ) if (!response.ok) { - throw new Error('Failed to fetch execution history') + throw new Error(t('failedToFetchExecutionHistory')) } const data = await response.json() @@ -319,13 +323,23 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: setGlobalLogsMeta({ offset: mappedLogs.length, hasMore: mappedLogs.length === 50 }) } catch (err) { console.error('Error fetching executions:', err) - setError(err instanceof Error ? err.message : 'An error occurred') + setError(err instanceof Error ? err.message : t('failedToFetchExecutionHistory')) } finally { setLoading(false) setIsRefetching(false) } }, - [workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers, segmentCount] + [ + workspaceId, + timeFilter, + endTime, + getStartTime, + workflowIds, + folderIds, + triggers, + segmentCount, + t, + ] ) const fetchWorkflowDetails = useCallback( @@ -356,7 +370,7 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: ) if (!response.ok) { - throw new Error('Failed to fetch workflow details') + throw new Error(t('failedToFetchExecutionHistory')) } const data = (await response.json()) as { data?: WorkflowLog[] } @@ -377,7 +391,7 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: console.error('Error fetching workflow details:', err) } }, - [workspaceId, endTime, getStartTime, triggers] + [workspaceId, endTime, getStartTime, triggers, t] ) // Infinite scroll for details logs @@ -639,61 +653,9 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: } }, []) - const getShiftLabel = () => { - switch (sidebarTimeRange) { - case 'Past 30 minutes': - return '30 minutes' - case 'Past hour': - return 'hour' - case 'Past 12 hours': - return '12 hours' - case 'Past 24 hours': - return '24 hours' - default: - return 'period' - } - } - const getDateRange = () => { const start = getStartTime() - return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} - ${endTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', year: 'numeric' })}` - } - - const shiftTimeWindow = (direction: 'back' | 'forward') => { - let shift: number - switch (timeFilter) { - case '30m': - shift = 30 * 60 * 1000 - break - case '1h': - shift = 60 * 60 * 1000 - break - case '6h': - shift = 6 * 60 * 60 * 1000 - break - case '12h': - shift = 12 * 60 * 60 * 1000 - break - case '24h': - shift = 24 * 60 * 60 * 1000 - break - case '3d': - shift = 3 * 24 * 60 * 60 * 1000 - break - case '7d': - shift = 7 * 24 * 60 * 60 * 1000 - break - case '14d': - shift = 14 * 24 * 60 * 60 * 1000 - break - case '30d': - shift = 30 * 24 * 60 * 60 * 1000 - break - default: - shift = 24 * 60 * 60 * 1000 - } - - setEndTime((prev) => new Date(prev.getTime() + (direction === 'forward' ? shift : -shift))) + return `${start.toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} - ${endTime.toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', year: 'numeric' })}` } const resetToNow = () => { @@ -743,21 +705,21 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }:
- Loading execution history... + {t('loadingExecutionHistory')}
) : error ? (
-

Error loading data

+

{t('errorLoadingData')}

{error}

) : executions.length === 0 ? (
-

No execution history

-

Execute some workflows to see their history here

+

{t('noExecutionHistory')}

+

{t('noExecutionHistoryDescription')}

) : ( @@ -939,7 +901,9 @@ export function Stats({ searchQuery, live, refreshRequest, onRefetchingChange }: { - if (!trigger) return '#9ca3af' - switch (trigger.toLowerCase()) { - case 'manual': - return '#9ca3af' - case 'schedule': - return '#10b981' - case 'webhook': - return '#f97316' - case 'chat': - return '#8b5cf6' - case 'api': - return '#3b82f6' - default: - return '#9ca3af' - } -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/records.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/records.test.tsx index 0219a1c73..31c325f77 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/records.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/records.test.tsx @@ -289,7 +289,7 @@ describe('Records', () => { const statsView = container.querySelector('[data-testid="stats-view"]') const searchInput = container.querySelector( - 'input[placeholder="Search workflows"]' + 'input[placeholder="Search workflows..."]' ) as HTMLInputElement | null expect(statsView).toBeTruthy() @@ -326,7 +326,7 @@ describe('Records', () => { ) const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Refresh stats') + button.textContent?.includes('Refresh') ) expect(refreshButton).toBeTruthy() diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/records.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/records.tsx index a30db4ba9..f880b60df 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/records.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/records.tsx @@ -272,7 +272,7 @@ export default function Records() { ordersQuery.error instanceof Error ? ordersQuery.error.message : ordersQuery.error - ? 'Failed to fetch orders' + ? t('orders.failedToFetchOrders') : null const orderDetailQuery = useOrderDetail(workspaceId, selectedOrder?.id) @@ -342,7 +342,7 @@ export default function Records() { logsQuery.error instanceof Error ? logsQuery.error.message : logsQuery.error - ? 'Failed to fetch logs' + ? tLogs('errors.fetchLogs') : null useEffect(() => { @@ -596,7 +596,7 @@ export default function Records() {
- Records + {t('title')}
{activeTab === 'orders' ? ( @@ -605,7 +605,7 @@ export default function Records() { value={logSearchQuery} onChange={setLogSearchQuery} queryPolicy={LOGS_QUERY_POLICY} - placeholder='Search logs...' + placeholder={tLogs('searchPlaceholder')} workflowsData={availableWorkflows} foldersData={availableFolders} className='w-full' @@ -622,7 +622,7 @@ export default function Records() { setStatsSearchQuery(event.target.value)} - placeholder='Search workflows' + placeholder={tLogs('dashboard.searchPlaceholder')} className='h-full min-w-[120px] flex-1 bg-transparent outline-none placeholder:text-muted-foreground' autoComplete='off' autoCorrect='off' @@ -636,7 +636,7 @@ export default function Records() { } center={ setActiveTab(value as RecordsTab)}> - + {(Object.keys(recordTabLabels) as RecordsTab[]).map((tab) => ( {recordTabLabels[tab]} @@ -669,7 +669,7 @@ export default function Records() { )} aria-pressed={isLive} > - Live + {tLogs('live')} ) : activeTab === 'stats' ? ( <> @@ -681,8 +681,8 @@ export default function Records() { className='h-9 gap-2 rounded-md border-border bg-background px-3' > - Filters - Filters + {tLogs('dashboard.filters.title')} + {tLogs('dashboard.filters.title')} @@ -703,7 +703,7 @@ export default function Records() { )} aria-pressed={statsLive} > - Live + {tLogs('live')} ) : null} @@ -722,10 +722,12 @@ export default function Records() { ) : ( )} - Refresh stats + {tLogs('dashboard.refresh')} - {statsIsRefetching ? 'Refreshing...' : 'Refresh'} + + {statsIsRefetching ? tLogs('dashboard.refreshing') : tLogs('dashboard.refresh')} + ) : ( <> @@ -743,10 +745,10 @@ export default function Records() { ) : ( )} - Refresh + {tLogs('actions.refresh')} - Refresh + {tLogs('actions.refresh')} @@ -757,10 +759,10 @@ export default function Records() { className='h-9 rounded-md hover:bg-secondary' > - Export CSV + {tLogs('actions.exportCsv')} - Export CSV + {tLogs('actions.exportCsv')} )} @@ -838,7 +840,7 @@ export default function Records() { orderDetailQuery.error instanceof Error ? orderDetailQuery.error.message : orderDetailQuery.error - ? 'Failed to load order detail' + ? t('orders.failedToLoadOrderDetail') : null } linkedLog={orderLogDetailQuery.data ?? null} @@ -847,7 +849,7 @@ export default function Records() { orderLogDetailQuery.error instanceof Error ? orderLogDetailQuery.error.message : orderLogDetailQuery.error - ? 'Failed to load workflow log' + ? t('orders.failedToLoadWorkflowLog') : null } mode={orderDetailMode} From 8aa5fe0b76c4c6b94a378b22ffab35785a6f5e6e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 5 Jun 2026 13:36:19 -0600 Subject: [PATCH 44/49] feat(i18n): implement next-intl localization layer across app routes and components --- changelog/June-05-2026.md | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 changelog/June-05-2026.md diff --git a/changelog/June-05-2026.md b/changelog/June-05-2026.md new file mode 100644 index 000000000..d872cace8 --- /dev/null +++ b/changelog/June-05-2026.md @@ -0,0 +1,91 @@ +# June-05-2026 + +## feat/i18n @ 06bbaf41 vs upstream/staging + +### Summary +- Adds the `next-intl` localization layer for public, auth, admin, workspace, chat, invite, changelog, and unsubscribe routes with English, Spanish, and Chinese message bundles. +- Moves app routes behind the locale segment, makes proxy/auth redirects locale-aware, and preserves canonical unlocalized callback paths for protected route reauth. +- Localizes high-visibility app copy across landing pages, auth forms, global navigation, settings, monitor, records, widgets, workflow editor, block metadata, trigger metadata, and transactional emails. +- Persists preferred locale for authenticated users and waitlist entries, then reuses that durable locale for settings, invitations, waitlist emails, chat OTP emails, and language switching. + +### Branch Scope +- Compared `f6a7da47075a5e4d546480130b493532f7e275af..06bbaf41021284331b4d2e34538af4bd3a2ae266`, where `f6a7da47075a5e4d546480130b493532f7e275af` is both the merge base and current `upstream/staging`. +- Ran `git fetch upstream staging` and `git fetch upstream staging:refs/remotes/upstream/staging` before comparing. This entry intentionally uses `upstream/staging`, not the template default `origin/staging`, because the user requested the upstream base. +- `git status --short --branch` showed `feat/i18n...upstream/feat/i18n` with no staged or unstaged changes before this changelog edit, so no uncommitted feature work was included. The only uncommitted change from this run is this changelog file. +- Main areas touched: `apps/tradinggoose/i18n/*`, `apps/tradinggoose/app/[locale]/*`, `apps/tradinggoose/proxy.ts`, `apps/tradinggoose/next.config.ts`, localized route/page components under `apps/tradinggoose/app/*`, email rendering under `apps/tradinggoose/components/emails/*`, preferred-locale persistence in `packages/db/schema/auth.ts` and `packages/db/schema/system.ts`, global navigation/settings, monitor and records UI, workflow editor/widget copy helpers, market listing search helpers, Vitest setup, package dependencies, and generated migrations under `packages/db/migrations/*`. + +### Key Changes +- `apps/tradinggoose/i18n/routing.ts`, `apps/tradinggoose/i18n/request.ts`, and `apps/tradinggoose/next.config.ts` establish the canonical `next-intl` integration. Supported locales are `en`, `es`, and `zh`, `defaultLocale` is `en`, locale prefixes are `as-needed`, locale detection is disabled, and `createNextIntlPlugin('./i18n/request.ts')` wires message loading into Next config. +- `apps/tradinggoose/i18n/messages/en.json`, `apps/tradinggoose/i18n/messages/es.json`, `apps/tradinggoose/i18n/messages/zh.json`, and `apps/tradinggoose/i18n.json` create the translation source and Lingo config. `apps/tradinggoose/package.json` adds `bun run i18n` and `bun run i18n:frozen`, while `.gitignore` ignores `i18n.lock`. +- `apps/tradinggoose/i18n/utils.ts` owns locale utilities and URL contracts: `normalizeLocaleCode()`, `stripLocaleFromPathname()`, `normalizeCallbackUrl()`, `localizeUrl()`, `localizeSiteUrl()`, `localizeDocsUrl()`, `buildLocalizedAlternates()`, `getOpenGraphLocale()`, `getLocaleDisplayName()`, `formatTemplate()`, `SITE_BASE_URL`, and `CANONICAL_CALLBACK_PATH_HEADER`. +- `apps/tradinggoose/i18n/navigation.ts` exports localized `Link`, `usePathname`, `useRouter`, `redirect`, and `getPathname` helpers from `createNavigation(routing)`, plus `replaceLocaleDocument()`, which writes `NEXT_LOCALE` and reloads the localized document instead of doing an in-document client route swap. +- Route files were moved or recreated under `apps/tradinggoose/app/[locale]/*`, including auth, landing, admin, changelog, chat, invite, unsubscribe, workspace, and nested workspace pages. `apps/tradinggoose/app/[locale]/layout.tsx` now owns the document shell, `NextIntlClientProvider`, locale metadata, and static locale params; `apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx` localizes workspace reauth redirects with `CANONICAL_CALLBACK_PATH_HEADER`; `apps/tradinggoose/app/[locale]/admin/layout.tsx` scopes admin messages before rendering `GlobalNavbar`. +- `apps/tradinggoose/proxy.ts` normalizes locale-prefixed routes, redirects protected routes to localized login pages, stores `NEXT_LOCALE`, redirects canonical routes to a remembered non-default locale, keeps canonical route handler paths unlocalized, rewrites localized markdown requests with normalized content paths, and appends homepage discovery links with locale context. +- `apps/tradinggoose/lib/branding/metadata.ts`, `apps/tradinggoose/app/sitemap.ts`, `apps/tradinggoose/app/(landing)/components/structured-data.tsx`, `apps/tradinggoose/app/changelog.xml/route.ts`, `apps/tradinggoose/app/llms.txt/route.ts`, and `apps/tradinggoose/app/llms-full.txt/route.ts` now use localized public copy, localized alternates, and `SITE_BASE_URL` for metadata, sitemap, feed, and LLM discovery surfaces. +- Auth and invite flows use canonical internal callback paths with localized visible routes. `apps/tradinggoose/lib/auth/redirect-urls.ts`, auth forms under `apps/tradinggoose/app/(auth)/*`, `apps/tradinggoose/app/invite/[id]/invite.tsx`, and invite APIs under `apps/tradinggoose/app/api/workspaces/invitations/*` and `apps/tradinggoose/app/api/organizations/[id]/invitations/*` preserve unlocalized callback targets while localizing reset, login, signup, and invitation URLs. +- Preferred locale is now durable data. `packages/db/schema/auth.ts` adds `settings.preferredLocale`, `packages/db/schema/system.ts` adds `waitlist.preferredLocale`, `apps/tradinggoose/app/api/users/me/settings/route.ts` reads/writes `preferredLocale` and `NEXT_LOCALE`, `apps/tradinggoose/lib/registration/service.ts` stores waitlist locale, and `apps/tradinggoose/lib/email/locale.ts` resolves email locale from user settings, user email, waitlist email, or fallback input. +- Email rendering is consolidated in `apps/tradinggoose/components/emails/localized-email.tsx`, `apps/tradinggoose/components/emails/email-copy.ts`, and `apps/tradinggoose/components/emails/render-email.ts`. OTP, password reset, organization invitations, workspace invitations, waitlist, help, careers, and billing emails now pull subject/body copy from locale messages and format dates/currency through locale-aware helpers. +- `apps/tradinggoose/app/(landing)/components/nav/nav.tsx`, `apps/tradinggoose/global-navbar/components/user-menu.tsx`, and `apps/tradinggoose/global-navbar/settings-loader.tsx` implement language switching and authenticated preferred-locale persistence. The old workspace-local settings loader was replaced by a global navbar loader that redirects the document to the stored preferred locale after settings load. +- Workspace, admin, settings, monitor, records, and widget UI now pull copy from `next-intl` messages. Examples include `apps/tradinggoose/app/workspace/[workspaceId]/monitor/copy.ts`, `apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts`, `apps/tradinggoose/widgets/widgets/data_chart/copy.ts`, and many component-level `useTranslations()`, `useMessages()`, and `useLocale()` call sites. +- Workflow editor localization is centralized in `apps/tradinggoose/i18n/block-editor.ts`, `apps/tradinggoose/i18n/workflow-inspector-core.ts`, `apps/tradinggoose/i18n/workspace-widget-hooks.ts`, and `apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts`. These helpers localize block names/descriptions, trigger metadata, sub-block titles/placeholders/options, tool parameter labels, workflow toolbar labels, preview summaries, deployment copy, and widget copy. +- `apps/tradinggoose/lib/workflows/value-types.ts` now exports `WORKFLOW_VARIABLE_TYPES` and `WORKFLOW_FIELD_TYPES`. `apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts` uses `isWorkflowVariableType()` and `VariableManager.parseInputForStorage()` instead of local string coercion, and `apps/tradinggoose/lib/copilot/tools/server/blocks/block-mermaid-catalog.ts` includes supported field types plus the canonical listing identity schema in input-format descriptions. +- `apps/tradinggoose/triggers/index.ts` rebuilds trigger system sub-blocks with optional dropdown/webhook pieces, treats monitor trigger ids through `isMonitorTriggerId()`, includes portfolio as a native trigger provider, and keeps event payload examples generated from trigger outputs. `apps/tradinggoose/triggers/indicator/trigger.ts` and `apps/tradinggoose/triggers/portfolio/trigger.ts` align setup instruction titles, while the portfolio trigger no longer exposes redundant monitor identity output fields. +- `apps/tradinggoose/components/listing-selector/search-utils.ts` exports `SUPPORTED_MARKET_ASSET_CLASSES`, and `apps/tradinggoose/components/listing-selector/selector/search-request.ts` always sends a bounded `filters` payload with default supported asset classes when no provider-specific asset class list exists. Market search route handlers under `apps/tradinggoose/app/api/market/search/*` no longer reject requests only because no explicit search parameter was provided. +- `apps/tradinggoose/app/api/workspaces/[id]/metrics/executions/route.ts` now checks `checkWorkspaceAccess()` before returning execution metrics, closing a workspace access gap in the records dashboard data path. +- Test tooling was updated for the localization surface. `apps/tradinggoose/vitest.setup.ts` mocks `next-intl` and `next-intl/navigation`, `apps/tradinggoose/package.json` pins `vitest` and `@vitest/coverage-v8` to `4.1.8` with `vite` `6.4.3`, root `package.json` pins the Vite override and updates Turborepo to `2.9.16`, and `turbo.json` uses the versioned Turborepo schema. + +### Design Decisions +- Locale ownership lives in `apps/tradinggoose/i18n/routing.ts` and `apps/tradinggoose/i18n/utils.ts` so route handlers, proxy code, metadata, emails, client navigation, and tests share one locale list, default locale, URL normalization rule, and callback-path rule. +- Visible app navigation is localized, but callback and API boundaries stay canonical and unlocalized. `normalizeCallbackUrl()` and `CANONICAL_CALLBACK_PATH_HEADER` make `/workspace/...` the durable callback contract while `localizeUrl()` and `redirect()` convert the visible login or workspace route to the active locale. +- Message delivery is scoped by route ownership. `getClientMessages(locale)` sends public messages by default, `getClientMessages(locale, 'workspace')` narrows workspace routes to `nav` and `workspace`, and `getClientMessages(locale, 'admin')` provides admin, nav, registration, and workspace namespaces for admin routes. +- The branch consolidates workflow editor localization around copy helper functions rather than passing raw message objects through every component. New editor code should call `getLocalizedBlockName()`, `localizeWorkflowSubBlockConfig()`, `useWorkflowI18n()`, or the narrower hooks from `apps/tradinggoose/i18n/workspace-widget-hooks.ts`. +- Email rendering moved from one React component per email type to a single `LocalizedEmail` template plus locale message copy. This keeps subjects, date/currency formatting, CTA labels, and footer copy in one contract instead of duplicating hard-coded English across templates. +- Preferred locale is stored in both authenticated settings and anonymous waitlist rows because some email flows run before a user exists. `resolveEmailLocale()` makes the precedence explicit: settings by `userId`, settings by known user email, waitlist by email, then fallback/default. +- Language switches call `replaceLocaleDocument()` because locale changes cross the localized document boundary and need server-owned metadata/JSON-LD to rerender with the selected locale. +- Route handlers and internal APIs remain outside localized app routing. `proxy.ts` treats `/api`, `/ingest`, `.well-known`, `monaco-editor`, sitemap, robots, LLM text, changelog XML, and static assets as canonical route-handler paths. +- Market search now allows empty/default queries so selector components can ask providers for a bounded default listing set through a normalized filter payload instead of manufacturing a fake search string. + +### Shared Contracts and Helpers to Reuse +- Reuse `locales`, `defaultLocale`, `routing`, `AppLocale`, and `isLocaleCode()` from `apps/tradinggoose/i18n/routing.ts` for every locale-aware schema, route, and component. Do not create a separate locale enum. +- Reuse `normalizeLocaleCode()`, `stripLocaleFromPathname()`, `normalizeCallbackUrl()`, `localizeUrl()`, `localizeSiteUrl()`, `localizeDocsUrl()`, `buildLocalizedAlternates()`, `getOpenGraphLocale()`, `getLocaleDisplayName()`, and `formatTemplate()` from `apps/tradinggoose/i18n/utils.ts`. +- Reuse `Link`, `usePathname`, `useRouter`, `redirect`, `getPathname`, and `replaceLocaleDocument()` from `apps/tradinggoose/i18n/navigation.ts` for app navigation. Do not import `next/link`, `next/navigation` redirects, or raw router helpers when the path is locale-visible. +- Reuse `getPublicCopy()` and `getClientMessages()` from `apps/tradinggoose/i18n/public-copy.ts` when server code needs message bundles or scoped provider messages. +- Reuse workflow copy helpers in `apps/tradinggoose/i18n/block-editor.ts`, `apps/tradinggoose/i18n/workflow-inspector-core.ts`, and `apps/tradinggoose/i18n/workspace-widget-hooks.ts` for block/editor/trigger/widget strings instead of reaching directly into JSON paths from every component. +- Reuse `useWorkflowI18n()` and the narrower hooks in `apps/tradinggoose/widgets/widgets/editor_workflow/copy.ts` for workflow editor components. +- Reuse `useMonitorCopy()`, `getMonitorBoardLabels()`, `getConfigBoardLabels()`, and related label helpers from `apps/tradinggoose/app/workspace/[workspaceId]/monitor/copy.ts` for monitor UI grouping, status, trigger, asset type, and config labels. +- Reuse `formatLocalizedNumber()`, `formatUsd()`, `formatFileSize()`, and `formatDurationMs()` from `apps/tradinggoose/i18n/formatters.ts` for locale-aware display formatting. +- Reuse `normalizeEmailLocale()` and `resolveEmailLocale()` from `apps/tradinggoose/lib/email/locale.ts` before sending emails, and reuse render helpers from `apps/tradinggoose/components/emails/render-email.ts` instead of recreating email-specific React templates. +- Reuse `preferredLocale` on `settings` and `waitlist` as the durable locale fields. API validation should use `z.enum(locales)` as shown in `apps/tradinggoose/app/api/users/me/settings/route.ts`, `apps/tradinggoose/app/api/waitlist/route.ts`, and `apps/tradinggoose/app/api/chat/[identifier]/otp/route.ts`. +- Reuse `WORKFLOW_VARIABLE_TYPES`, `WORKFLOW_FIELD_TYPES`, and `isWorkflowVariableType()` from `apps/tradinggoose/lib/workflows/value-types.ts`, and use `VariableManager.parseInputForStorage()` for workflow variable value parsing. +- Reuse `SUPPORTED_MARKET_ASSET_CLASSES`, `parseCategorizedSearchQuery()`, and `buildMarketSearchRequest()` from `apps/tradinggoose/components/listing-selector/*` for market listing searches. + +### Removed or Replaced Items +- Removed the old non-localized root app shell at `apps/tradinggoose/app/layout.tsx`. The localized document shell now lives at `apps/tradinggoose/app/[locale]/layout.tsx`; do not reintroduce a parallel root layout with providers or metadata. +- Moved or replaced non-localized page routes such as `apps/tradinggoose/app/page.tsx`, `app/(auth)/*/page.tsx`, `app/(landing)/*/page.tsx`, `app/admin/*/page.tsx`, `app/workspace/*/page.tsx`, `app/chat/[identifier]/page.tsx`, `app/invite/[id]/page.tsx`, `app/changelog/page.tsx`, and `app/unsubscribe/page.tsx` under `apps/tradinggoose/app/[locale]/*`. New page routes should live under `[locale]` unless they are canonical route handlers or static assets. +- Removed `apps/tradinggoose/app/workspace/[workspaceId]/providers/settings-loader.tsx`. Use `apps/tradinggoose/global-navbar/settings-loader.tsx`, which loads general settings once per authenticated user and redirects to the persisted preferred locale when needed. +- Removed standalone hard-coded email components, including `apps/tradinggoose/components/emails/invitation-email.tsx`, `workspace-invitation.tsx`, `otp-verification-email.tsx`, `reset-password-email.tsx`, waitlist emails, help/careers confirmation emails, and billing email components. Use `LocalizedEmail` plus render functions from `apps/tradinggoose/components/emails/render-email.ts`. +- Replaced `apps/tradinggoose/app/not-found.tsx` with `apps/tradinggoose/app/not-found-content.tsx` and `apps/tradinggoose/app/[locale]/not-found.tsx` so not-found copy renders inside the locale provider. +- Replaced `apps/tradinggoose/app/invite/components/layout.tsx` with `apps/tradinggoose/app/invite/components/invite-layout.tsx`; future invite UI should import the renamed component. +- Replaced `apps/tradinggoose/app/monaco-editor/esm/vs/[...assetPath]/route.ts` with `apps/tradinggoose/app/monaco-editor/esm/[...assetPath]/route.ts`; do not restore the extra `vs` segment. +- Removed `apps/tradinggoose/app/workspace/[workspaceId]/records/components/stats/utils.ts`; trigger colors now live with the log filter option metadata in `apps/tradinggoose/app/workspace/[workspaceId]/records/components/logs-toolbar/components/filters/components/shared.ts`. +- Removed ad hoc workflow variable coercion from `apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts`; use `VariableManager.parseInputForStorage()` and `isWorkflowVariableType()` instead. +- Removed the old portfolio trigger `monitor` output object from `apps/tradinggoose/triggers/portfolio/trigger.ts`. Consumers should use the remaining `input`, `event`, `portfolio`, and `condition` outputs. + +### Future Branch Guardrails +- Do not add visible app routes outside `apps/tradinggoose/app/[locale]/*`. Keep `/api`, `/ingest`, asset, sitemap, robots, LLM, changelog XML, and Monaco routes canonical and unlocalized. +- Do not pass localized paths as callback URLs. Store and forward canonical internal paths such as `/workspace/ws-1/dashboard`, then localize only the visible redirect target with `localizeUrl()` or `redirect()` from `apps/tradinggoose/i18n/navigation.ts`. +- Do not hardcode locale string unions, `NEXT_LOCALE` behavior, open graph locale codes, or localized URL prefixes in new code. Import the canonical helpers from `apps/tradinggoose/i18n/*`. +- Do not introduce new English-only UI copy in components that already sit inside an intl provider. Add keys to `apps/tradinggoose/i18n/messages/en.json`, keep `es.json` and `zh.json` structurally aligned, and extend `apps/tradinggoose/i18n/public-copy.test.ts` or focused tests for new namespaces. +- Do not recreate one-off email React components. Add message keys and a render helper in `apps/tradinggoose/components/emails/render-email.ts` using `LocalizedEmail`. +- Do not persist locale in local component state only. Authenticated preferences belong in `settings.preferredLocale`; anonymous waitlist preferences belong in `waitlist.preferredLocale`; runtime/browser preference is mirrored with `NEXT_LOCALE`. +- Do not bypass the global navbar `SettingsLoader` with workspace-local settings fetchers. It is now the canonical loader for persisted preferred locale. +- Do not duplicate workflow editor localization lookup logic in individual widget components. Use `workspace-widget-hooks.ts`, `block-editor.ts`, `workflow-inspector-core.ts`, or `widgets/widgets/editor_workflow/copy.ts`. +- Do not reintroduce local workflow variable type coercion. Use `WORKFLOW_VARIABLE_TYPES`, `isWorkflowVariableType()`, and `VariableManager.parseInputForStorage()`. +- Do not edit generated migration files by hand. This branch includes generated `packages/db/migrations/0035_marvelous_avengers.sql`, `packages/db/migrations/meta/0035_snapshot.json`, and `packages/db/migrations/meta/_journal.json`; future schema changes should regenerate migrations through the project DB workflow. + +### Validation Notes +- Used the requested `staging-changelog` skill and followed `changelog/TEMPLATE.md`, with the explicit base override from `origin/staging` to `upstream/staging`. +- Reviewed `git status --short --branch`, `git remote -v`, `git fetch upstream staging`, `git fetch upstream staging:refs/remotes/upstream/staging`, `git rev-parse feat/i18n`, `git rev-parse upstream/staging`, `git merge-base upstream/staging feat/i18n`, `git log --oneline f6a7da47075a5e4d546480130b493532f7e275af..feat/i18n`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, and targeted `git diff --unified` output for the branch range. +- Inspected the repo instructions, `changelog/TEMPLATE.md`, existing `changelog/May-29-2026.md`, routing/navigation/request utilities, locale utilities, public copy helpers, workflow editor localization helpers, proxy, localized layouts, metadata/sitemap code, auth redirect helpers, email locale/rendering code, preferred-locale settings and waitlist paths, global navbar language switching, monitor/records/widget copy helpers, workflow value-type changes, trigger changes, market search helpers, package/config changes, and deleted/replaced files from the merge-base diff. +- Confirmed the branch delta includes generated migration files under `packages/db/migrations/*`; this changelog update did not edit those migration files. +- No automated test suite was run for this changelog-only update. Validation focused on merge-base diff review, source inspection, deleted/replaced path inspection, and template conformance. From 2ea5b10f02bb12759c8e717164c37df105f345f0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 3 Jun 2026 00:55:53 -0600 Subject: [PATCH 45/49] feat(ui): add grouped sidebar dropdown menus Co-authored-by: Codex --- apps/tradinggoose/blocks/blocks/agent.test.ts | 5 + apps/tradinggoose/blocks/blocks/agent.ts | 50 +- apps/tradinggoose/blocks/types.ts | 8 + .../selector/dropdown.test.tsx | 5 +- .../listing-selector/selector/dropdown.tsx | 98 ++-- .../listing-selector/selector/input.tsx | 81 ++-- .../selector/search-request.ts | 14 +- .../selector/use-listing-search.test.tsx | 33 +- .../selector/use-listing-search.ts | 21 +- .../components/ui/dropdown-menu.tsx | 5 +- apps/tradinggoose/components/ui/popover.tsx | 5 +- .../components/ui/sidebar-dropdown-menu.tsx | 143 ++++++ .../components/pine-indicator-dropdown.tsx | 449 +++++++++--------- .../sub-block/components/combobox.tsx | 154 +++++- 14 files changed, 699 insertions(+), 372 deletions(-) create mode 100644 apps/tradinggoose/components/ui/sidebar-dropdown-menu.tsx diff --git a/apps/tradinggoose/blocks/blocks/agent.test.ts b/apps/tradinggoose/blocks/blocks/agent.test.ts index 9dbab2220..18eb0e3fa 100644 --- a/apps/tradinggoose/blocks/blocks/agent.test.ts +++ b/apps/tradinggoose/blocks/blocks/agent.test.ts @@ -10,8 +10,13 @@ vi.mock('@/providers/ai/utils', () => ({ getAllModelProviders: vi.fn(() => ({ 'gpt-4o': 'openai_chat' })), getHostedModels: vi.fn(() => []), getMaxTemperature: vi.fn(() => 1), + getProviderFromModel: vi.fn(() => 'openai'), getProviderIcon: vi.fn(() => undefined), providers: { + openai: { + name: 'OpenAI', + models: ['gpt-4o'], + }, 'azure-openai': { models: [], }, diff --git a/apps/tradinggoose/blocks/blocks/agent.ts b/apps/tradinggoose/blocks/blocks/agent.ts index 40b65b33b..82d61c4ae 100644 --- a/apps/tradinggoose/blocks/blocks/agent.ts +++ b/apps/tradinggoose/blocks/blocks/agent.ts @@ -1,12 +1,13 @@ import { AgentIcon } from '@/components/icons/icons' import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' -import type { BlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockOption, SubBlockOptionGroup } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { getAllModelProviders, getHostedModels, getMaxTemperature, + getProviderFromModel, getProviderIcon, MODELS_WITH_REASONING_EFFORT, MODELS_WITH_VERBOSITY, @@ -31,6 +32,42 @@ const getAvailableModels = () => { return Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels])) } +const getAvailableModelOptions = (): SubBlockOption[] => { + return getAvailableModels().map((model) => { + const providerId = getProviderFromModel(model) + const provider = providers[providerId] + const icon = provider?.icon ?? getProviderIcon(model) + + return { + label: model, + id: model, + group: providerId, + searchLabel: `${model} ${provider?.name ?? providerId}`, + ...(icon && { icon }), + } + }) +} + +const getAvailableModelGroups = (): SubBlockOptionGroup[] => { + const seenProviderIds = new Set() + const groups: SubBlockOptionGroup[] = [] + + for (const model of getAvailableModels()) { + const providerId = getProviderFromModel(model) + if (seenProviderIds.has(providerId)) continue + + const provider = providers[providerId] + seenProviderIds.add(providerId) + groups.push({ + id: providerId, + label: provider?.name ?? providerId, + ...(provider?.icon && { icon: provider.icon }), + }) + } + + return groups +} + interface AgentResponse extends ToolResponse { output: { content: string @@ -170,19 +207,14 @@ Create a system prompt appropriately detailed for the request, using clear langu layout: 'half', placeholder: 'Type or select a model...', required: true, + dropdownMode: 'sidebar', + optionGroups: getAvailableModelGroups, value: () => { const allModels = getAvailableModels() if (allModels.includes('gpt-4o')) return 'gpt-4o' return allModels[0] }, - options: () => { - const allModels = getAvailableModels() - - return allModels.map((model) => { - const icon = getProviderIcon(model) - return { label: model, id: model, ...(icon && { icon }) } - }) - }, + options: getAvailableModelOptions, }, { id: 'temperature', diff --git a/apps/tradinggoose/blocks/types.ts b/apps/tradinggoose/blocks/types.ts index 18d8e8f38..d997a2385 100644 --- a/apps/tradinggoose/blocks/types.ts +++ b/apps/tradinggoose/blocks/types.ts @@ -149,6 +149,12 @@ export interface SubBlockOption { rightLabel?: string } +export interface SubBlockOptionGroup { + id: string + label: string + icon?: React.ComponentType<{ className?: string }> +} + export interface SubBlockConfig { id: string title?: string @@ -193,6 +199,8 @@ export interface SubBlockConfig { showCopyButton?: boolean enableSearch?: boolean searchPlaceholder?: string + dropdownMode?: 'default' | 'sidebar' + optionGroups?: SubBlockOptionGroup[] | (() => SubBlockOptionGroup[]) autoSelectFirstOption?: boolean connectionDroppable?: boolean hidden?: boolean diff --git a/apps/tradinggoose/components/listing-selector/selector/dropdown.test.tsx b/apps/tradinggoose/components/listing-selector/selector/dropdown.test.tsx index 1a520804e..2d8486e6b 100644 --- a/apps/tradinggoose/components/listing-selector/selector/dropdown.test.tsx +++ b/apps/tradinggoose/components/listing-selector/selector/dropdown.test.tsx @@ -3,8 +3,8 @@ */ import { act, type ComponentProps } from 'react' -import { createRoot, type Root } from 'react-dom/client' import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getPublicCopy } from '@/i18n/public-copy' import { ListingSelectorDropdownContent } from './dropdown' @@ -40,6 +40,9 @@ describe('ListingSelectorDropdownContent localized copy', () => { root.render( void results: ListingOption[] isLoading: boolean error?: string @@ -21,12 +21,14 @@ type ListingSelectorDropdownContentProps = { onHighlightChange: (index: number) => void onSelect: (listing: ListingOption) => void renderListing?: (listing: ListingOption) => ReactNode - scrollStyle?: CSSProperties onWheelCapture?: (event: WheelEvent) => void onTouchMove?: (event: TouchEvent) => void } export function ListingSelectorDropdownContent({ + groups, + activeGroupId, + onActiveGroupChange, results, isLoading, error, @@ -34,12 +36,21 @@ export function ListingSelectorDropdownContent({ onHighlightChange, onSelect, renderListing, - scrollStyle, onWheelCapture, onTouchMove, }: ListingSelectorDropdownContentProps) { const dropdownRef = useRef(null) const copy = useWorkspaceWidgetsMessages().listingSelector + const items: SidebarDropdownItem[] = results.map((listing, index) => ({ + id: String(index), + groupId: activeGroupId, + label: listing.name?.trim() || listing.base?.trim() || 'Listing', + content: renderListing ? ( + renderListing(listing) + ) : ( + + ), + })) useEffect(() => { if (highlightedIndex < 0 || !dropdownRef.current) return @@ -50,48 +61,27 @@ export function ListingSelectorDropdownContent({ }, [highlightedIndex]) return ( -
-
onHighlightChange(-1)} - onWheelCapture={onWheelCapture} - onTouchMove={onTouchMove} - > - {isLoading ? ( -
{copy.searching}
- ) : results.length === 0 ? ( -
- {error || copy.noListingsFound} -
- ) : ( - results.map((listing, index) => { - const isHighlighted = index === highlightedIndex - return ( -
onHighlightChange(index)} - onMouseDown={(event) => { - event.preventDefault() - onSelect(listing) - }} - className={cn( - 'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground', - isHighlighted && 'bg-accent text-accent-foreground' - )} - > - {renderListing ? ( - renderListing(listing) - ) : ( - - )} -
- ) - }) - )} -
+
onHighlightChange(-1)} + onWheelCapture={onWheelCapture} + onTouchMove={onTouchMove} + > + = 0 ? String(highlightedIndex) : null} + onActiveGroupChange={onActiveGroupChange} + onHighlightItem={(_item, index) => onHighlightChange(index)} + onSelectItem={(item) => { + const listing = results[Number(item.id)] + if (listing) onSelect(listing) + }} + loadingContent={isLoading ? copy.searching : null} + emptyContent={error || copy.noListingsFound} + />
) } diff --git a/apps/tradinggoose/components/listing-selector/selector/input.tsx b/apps/tradinggoose/components/listing-selector/selector/input.tsx index 0cb83d697..c5c63253b 100644 --- a/apps/tradinggoose/components/listing-selector/selector/input.tsx +++ b/apps/tradinggoose/components/listing-selector/selector/input.tsx @@ -2,7 +2,6 @@ import type { ChangeEvent, FocusEvent, KeyboardEvent } from 'react' import { useEffect, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' import { createPortal } from 'react-dom' import { triggerCryptoRankUpdate, @@ -15,10 +14,10 @@ import { ListingDisplayRow, MarketListingRow, } from '@/components/listing-selector/listing/row' +import { SUPPORTED_MARKET_ASSET_CLASSES } from '@/components/listing-selector/search-utils' import { ListingSelectorDropdownContent } from '@/components/listing-selector/selector/dropdown' import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' import { useMarketListingSearch } from '@/components/listing-selector/selector/use-listing-search' -import { Button } from '@/components/ui/button' import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' @@ -56,6 +55,21 @@ export interface ListingSearchInputProps { onListingTagSelect?: (value: string) => void } +const ALL_ASSET_CLASS_FILTER_ID = 'all' +const LISTING_DROPDOWN_MIN_WIDTH = 420 +const LISTING_DROPDOWN_VIEWPORT_PADDING = 8 + +const LISTING_ASSET_CLASS_GROUPS = [ + { id: ALL_ASSET_CLASS_FILTER_ID, label: 'All' }, + ...SUPPORTED_MARKET_ASSET_CLASSES.map((assetClass) => ({ + id: assetClass, + label: + assetClass === 'mutualfund' + ? 'Mutual Fund' + : assetClass.charAt(0).toUpperCase() + assetClass.slice(1), + })), +] + export function ListingSearchInput({ instanceId, blockId, @@ -84,6 +98,8 @@ export function ListingSearchInput({ const hasActivatedOnMountRef = useRef(false) const [open, setOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [activeAssetClassFilterId, setActiveAssetClassFilterId] = + useState(ALL_ASSET_CLASS_FILTER_ID) const [showTags, setShowTags] = useState(false) const [cursorPosition, setCursorPosition] = useState(0) const [variableCommitted, setVariableCommitted] = useState(false) @@ -113,6 +129,8 @@ export function ListingSearchInput({ const showPlaceholderOverlay = isHeader && !open && !selectedListing && !query?.trim() && !hasUnresolvedSelection const hideInputText = showRichOverlay || showTagOverlay || showPlaceholderOverlay + const assetClassFilter = + activeAssetClassFilterId === ALL_ASSET_CLASS_FILTER_ID ? null : activeAssetClassFilterId const isVariableListingInput = (value: string) => value.trim().startsWith('<') @@ -174,6 +192,13 @@ export function ListingSearchInput({ onListingChange?.(listing) } + const handleAssetClassFilterChange = (groupId: string) => { + setActiveAssetClassFilterId(groupId) + setHighlightedIndex(-1) + setOpen(true) + inputRef.current?.focus() + } + const handleTagSelect = (value: string) => { const lastOpen = value.lastIndexOf('<') const lastClose = value.indexOf('>', lastOpen + 1) @@ -307,6 +332,7 @@ export function ListingSearchInput({ providerType, marketProviderId, tradingProviderId, + assetClassFilter, instanceId, updateInstance, candidateListings, @@ -408,10 +434,17 @@ export function ListingSearchInput({ const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() + const availableWidth = Math.max(0, window.innerWidth - LISTING_DROPDOWN_VIEWPORT_PADDING * 2) + const width = Math.min(Math.max(rect.width, LISTING_DROPDOWN_MIN_WIDTH), availableWidth) + const viewportLeft = window.scrollX + LISTING_DROPDOWN_VIEWPORT_PADDING + const maxLeft = window.scrollX + window.innerWidth - width - LISTING_DROPDOWN_VIEWPORT_PADDING + const centeredLeft = rect.left + window.scrollX + (rect.width - width) / 2 + const left = Math.max(viewportLeft, Math.min(centeredLeft, maxLeft)) + setDropdownPosition({ top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width, + left, + width, }) } @@ -443,16 +476,16 @@ export function ListingSearchInput({ onWheel={(event) => event.stopPropagation()} > ( - - )} - scrollStyle={{ scrollbarWidth: 'thin', overscrollBehavior: 'contain' }} + renderListing={(listing) => } onWheelCapture={(event) => event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} /> @@ -472,8 +505,8 @@ export function ListingSearchInput({ name={`listing-search-${instanceId}`} className={cn( isHeader - ? widgetHeaderControlClassName('w-full justify-center pr-9 font-medium text-sm') - : ['w-full pr-10', compact ? 'h-8 text-sm' : 'h-10'], + ? widgetHeaderControlClassName('w-full justify-center font-medium text-sm') + : ['w-full', compact ? 'h-8 text-sm' : 'h-10'], hideInputText && 'text-transparent caret-transparent placeholder:text-transparent' )} placeholder={isHeader ? 'Search listings...' : 'Select listing'} @@ -523,34 +556,6 @@ export function ListingSearchInput({
) : null} -
{listingDropdown && portalTarget && dropdownPosition diff --git a/apps/tradinggoose/components/listing-selector/selector/search-request.ts b/apps/tradinggoose/components/listing-selector/selector/search-request.ts index ce71eb453..573536ad8 100644 --- a/apps/tradinggoose/components/listing-selector/selector/search-request.ts +++ b/apps/tradinggoose/components/listing-selector/selector/search-request.ts @@ -1,7 +1,7 @@ import { type ParsedMarketQuery, - SUPPORTED_MARKET_ASSET_CLASSES, parseCategorizedSearchQuery, + SUPPORTED_MARKET_ASSET_CLASSES, serializeArrayParam, } from '@/components/listing-selector/search-utils' import type { ProviderSearchConfig } from '@/components/listing-selector/selector/use-provider-config' @@ -14,17 +14,19 @@ export type MarketListingSearchRequest = { export function buildMarketSearchRequest(args: { rawQuery: string providerConfig: ProviderSearchConfig + assetClassFilter?: string | null }): MarketListingSearchRequest { - const { rawQuery, providerConfig } = args + const { rawQuery, providerConfig, assetClassFilter } = args const trimmed = rawQuery.trim() const queryParams: Record = {} const filtersPayload: Record = {} const parsedQuery: ParsedMarketQuery = trimmed ? parseCategorizedSearchQuery(trimmed) : {} + const requestedAssetClass = assetClassFilter?.trim().toLowerCase() || parsedQuery.assetClass if ( - parsedQuery.assetClass && + requestedAssetClass && providerConfig.assetClasses.length && - !providerConfig.assetClasses.includes(parsedQuery.assetClass) + !providerConfig.assetClasses.includes(requestedAssetClass) ) { return { queryParams, @@ -32,8 +34,8 @@ export function buildMarketSearchRequest(args: { } } - const resolvedAssetClasses = parsedQuery.assetClass - ? [parsedQuery.assetClass] + const resolvedAssetClasses = requestedAssetClass + ? [requestedAssetClass] : providerConfig.assetClasses.length ? providerConfig.assetClasses : [...SUPPORTED_MARKET_ASSET_CLASSES] diff --git a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx index d0a856fd8..892d8108b 100644 --- a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx +++ b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx @@ -104,6 +104,34 @@ describe('useMarketListingSearch', () => { ) }) + it('scopes market searches to the selected asset class filter', async () => { + const updateInstance = vi.fn() + + fetchListingsMock.mockResolvedValue([]) + + await act(async () => { + root.render( + + ) + await Promise.resolve() + }) + + expect(fetchListingsMock).toHaveBeenCalledTimes(1) + expect(fetchListingsMock).toHaveBeenCalledWith( + { + filters: JSON.stringify({ limit: 50, asset_class: ['crypto'] }), + }, + expect.any(AbortSignal) + ) + }) + it('does not let explicit asset prefixes bypass combined provider criteria', async () => { const updateInstance = vi.fn() @@ -281,15 +309,16 @@ describe('useMarketListingSearch', () => { }) }) - it('filters scoped candidate listings without calling market search', async () => { + it('filters scoped candidate listings by selected asset class without calling market search', async () => { const updateInstance = vi.fn() await act(async () => { root.render( .includes(query) } +const listingMatchesAssetClass = ( + listing: ListingOption, + assetClassFilter?: string | null +): boolean => { + if (!assetClassFilter) return true + const normalizedFilter = assetClassFilter.trim().toLowerCase() + const listingAssetClass = listing.assetClass?.trim().toLowerCase() + if (listingAssetClass) return listingAssetClass === normalizedFilter + return listing.listing_type === normalizedFilter +} + export function useMarketListingSearch({ open, query, @@ -51,6 +63,7 @@ export function useMarketListingSearch({ providerType = 'market', marketProviderId, tradingProviderId, + assetClassFilter, instanceId, updateInstance, candidateListings, @@ -113,8 +126,10 @@ export function useMarketListingSearch({ } updateInstance(instanceId, { - results: candidateListings.filter((listing) => - listingMatchesQuery(listing, trimmedQuery.toLowerCase()) + results: candidateListings.filter( + (listing) => + listingMatchesAssetClass(listing, assetClassFilter) && + listingMatchesQuery(listing, trimmedQuery.toLowerCase()) ), isLoading: false, error: candidateListingsError, @@ -134,6 +149,7 @@ export function useMarketListingSearch({ const { queryParams, requestKey } = buildMarketSearchRequest({ rawQuery: debouncedQuery, providerConfig, + assetClassFilter, }) if (Object.keys(queryParams).length === 0) { abortInFlightRequest() @@ -180,6 +196,7 @@ export function useMarketListingSearch({ providerType, marketProviderId, tradingProviderId, + assetClassFilter, providerConfig, instanceId, updateInstance, diff --git a/apps/tradinggoose/components/ui/dropdown-menu.tsx b/apps/tradinggoose/components/ui/dropdown-menu.tsx index 00be8eeab..6b1db9f91 100644 --- a/apps/tradinggoose/components/ui/dropdown-menu.tsx +++ b/apps/tradinggoose/components/ui/dropdown-menu.tsx @@ -50,9 +50,10 @@ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayNam const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, collisionPadding = 8, ...props }, ref) => ( & { container?: React.ComponentPropsWithoutRef['container'] @@ -38,6 +39,7 @@ const PopoverContent = React.forwardRef< className, align = 'center', sideOffset = 4, + collisionPadding = 8, container, scale, zIndex, @@ -68,6 +70,7 @@ const PopoverContent = React.forwardRef< ref={ref} align={align} sideOffset={scaledSideOffset} + collisionPadding={collisionPadding} className={cn( 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=open]:animate-in', className @@ -85,4 +88,4 @@ const PopoverContent = React.forwardRef< ) PopoverContent.displayName = PopoverPrimitive.Content.displayName -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent } diff --git a/apps/tradinggoose/components/ui/sidebar-dropdown-menu.tsx b/apps/tradinggoose/components/ui/sidebar-dropdown-menu.tsx new file mode 100644 index 000000000..402eb7fc7 --- /dev/null +++ b/apps/tradinggoose/components/ui/sidebar-dropdown-menu.tsx @@ -0,0 +1,143 @@ +'use client' + +import type { ComponentType, MouseEvent, ReactNode } from 'react' +import { Check } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +export type SidebarDropdownGroup = { + id: string + label: string + icon?: ComponentType<{ className?: string }> +} + +export type SidebarDropdownItem = { + id: string + groupId: string + label: string + selected?: boolean + icon?: ReactNode + content?: ReactNode +} + +interface SidebarDropdownMenuContentProps { + groups: SidebarDropdownGroup[] + items: SidebarDropdownItem[] + activeGroupId?: string | null + highlightedItemId?: string | null + onActiveGroupChange: (groupId: string) => void + onSelectItem: (item: SidebarDropdownItem, event: MouseEvent) => void + onHighlightItem?: (item: SidebarDropdownItem, index: number) => void + loadingContent?: ReactNode + emptyContent: ReactNode +} + +export function SidebarDropdownMenuContent({ + groups, + items, + activeGroupId, + highlightedItemId, + onActiveGroupChange, + onSelectItem, + onHighlightItem, + loadingContent, + emptyContent, +}: SidebarDropdownMenuContentProps) { + const resolvedActiveGroupId = activeGroupId ?? groups[0]?.id ?? '' + const visibleItems = items.filter((item) => item.groupId === resolvedActiveGroupId) + const hasGroups = groups.length > 0 + const sidebarShowsOnlyIcons = groups.every((group) => group.icon) + + return ( +
+
+ {hasGroups ? ( +
+
+ {groups.map((group) => { + const Icon = group.icon + const isActive = group.id === resolvedActiveGroupId + const groupButton = ( + + ) + + if (!Icon) return groupButton + + return ( + + {groupButton} + {group.label} + + ) + })} +
+
+ ) : null} +
+ {loadingContent ? ( +
+ {loadingContent} +
+ ) : visibleItems.length === 0 ? ( +
+ {emptyContent} +
+ ) : ( +
+ {visibleItems.map((item, index) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx index aeafc3d70..0f1181be5 100644 --- a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx @@ -1,36 +1,31 @@ 'use client' -import { type KeyboardEvent, useEffect, useMemo, useState } from 'react' -import { Activity, Check, ChevronDown, Loader2, Search } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Activity, ChevronDown, Home, Loader2, User } from 'lucide-react' import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { + type SidebarDropdownGroup, + type SidebarDropdownItem, + SidebarDropdownMenuContent, +} from '@/components/ui/sidebar-dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' +import { widgetHeaderControlClassName } from '@/components/widget-header-control' import { getStableVibrantColor } from '@/lib/colors' import { DEFAULT_INDICATORS_META } from '@/lib/indicators/default' import { cn } from '@/lib/utils' import { useIndicators } from '@/hooks/queries/indicators' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { useIndicatorsStore } from '@/stores/indicators/store' -import { - widgetHeaderControlClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/components/widget-header-control' const FALLBACK_COLOR = '#3972F6' -const DROPDOWN_MAX_HEIGHT = '20rem' -const DROPDOWN_VIEWPORT_HEIGHT = '14rem' + +type IndicatorFilterId = 'default' | 'custom' type IndicatorOption = { id: string name: string + source: IndicatorFilterId color?: string } @@ -72,6 +67,11 @@ export function IndicatorDropdown({ const [internalValue, setInternalValue] = useState([]) const [loadError, setLoadError] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const [dropdownOpen, setDropdownOpen] = useState(false) + const [activeFilterId, setActiveFilterId] = useState( + includeDefaults ? 'default' : 'custom' + ) + const inputRef = useRef(null) const isMultiSelect = selectionMode === 'multiple' @@ -102,6 +102,7 @@ export function IndicatorDropdown({ ? DEFAULT_INDICATORS_META.map((indicator) => ({ id: indicator.id, name: indicator.name, + source: 'default' as const, color: getStableVibrantColor(indicator.id), })) : [], @@ -113,6 +114,7 @@ export function IndicatorDropdown({ workspaceIndicators.map((indicator) => ({ id: indicator.id, name: indicator.name || indicator.id, + source: 'custom' as const, color: indicator.color, })), [workspaceIndicators] @@ -125,7 +127,8 @@ export function IndicatorDropdown({ const isControlled = typeof value !== 'undefined' const selectedIndicatorIds = isControlled ? (value ?? []) : internalValue - const selectedIndicatorSet = new Set(selectedIndicatorIds) + const firstSelectedIndicatorId = selectedIndicatorIds[0] ?? null + const selectedIndicatorSet = useMemo(() => new Set(selectedIndicatorIds), [selectedIndicatorIds]) const selectedIndicatorId = !isMultiSelect ? (selectedIndicatorIds[0] ?? null) : null const selectedIndicator = !isMultiSelect ? indicatorOptions.find((indicator) => indicator.id === selectedIndicatorId) @@ -156,10 +159,12 @@ export function IndicatorDropdown({ useEffect(() => { setLoadError(null) setSearchQuery('') + setDropdownOpen(false) + setActiveFilterId(includeDefaults ? 'default' : 'custom') if (!isControlled) { setInternalValue([]) } - }, [workspaceId, isControlled]) + }, [workspaceId, isControlled, includeDefaults]) useEffect(() => { if (queryError) { @@ -173,23 +178,13 @@ export function IndicatorDropdown({ } }, [indicatorOptions.length, loadError]) - const filteredDefaultIndicators = useMemo(() => { - const query = searchQuery.trim().toLowerCase() - if (!query) return defaultIndicatorOptions - return defaultIndicatorOptions.filter((option) => option.name?.toLowerCase().includes(query)) - }, [defaultIndicatorOptions, searchQuery]) - - const filteredCustomIndicators = useMemo(() => { - const query = searchQuery.trim().toLowerCase() - if (!query) return customIndicatorOptions - return customIndicatorOptions.filter((option) => option.name?.toLowerCase().includes(query)) - }, [customIndicatorOptions, searchQuery]) - - const handleSearchInputKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - return - } - } + const selectedFilterId = useMemo(() => { + if (!includeDefaults) return 'custom' + if (!firstSelectedIndicatorId) return 'default' + return defaultIndicatorOptions.some((option) => option.id === firstSelectedIndicatorId) + ? 'default' + : 'custom' + }, [defaultIndicatorOptions, firstSelectedIndicatorId, includeDefaults]) const handleSelectionChange = (nextIds: string[]) => { if (isControlled) { @@ -228,6 +223,7 @@ export function IndicatorDropdown({ if (selectedIndicatorIds.length === 1) return first.name return `${first.name} +${selectedIndicatorIds.length - 1}` }, [copy.placeholder, indicatorOptions, placeholder, selectedIndicatorIds]) + const hasSelection = selectedIndicatorIds.length > 0 const colorBadge = (
) - const labelContent = - selectedIndicatorIds.length > 0 ? ( - - {selectionLabel} - - ) : ( - - {selectionLabel} - - ) + const indicatorGroups = useMemo(() => { + const groups: SidebarDropdownGroup[] = [] + if (includeDefaults) { + groups.push({ + id: 'default', + label: copy.defaultIndicators, + icon: Home, + }) + } + groups.push({ + id: 'custom', + label: copy.customIndicators, + icon: User, + }) + return groups + }, [copy.customIndicators, copy.defaultIndicators, includeDefaults]) + + const shouldShowLoadingState = (isLoading || isFetching) && !hasIndicators + const emptyContent = (() => { + if (loadError && !hasIndicators) { + return ( +
+

{loadError}

+ +
+ ) + } + + if (searchQuery.trim()) return copy.noIndicatorsFound + return copy.noIndicatorsAvailableYet + })() + + const loadingContent = ( +
+ + {copy.loadingIndicators} +
+ ) + + const normalizedSearchQuery = searchQuery.trim().toLowerCase() + const sidebarItems = useMemo( + () => + indicatorOptions + .filter((option) => { + if (!normalizedSearchQuery) return true + return option.name.toLowerCase().includes(normalizedSearchQuery) + }) + .map((option) => ({ + id: option.id, + groupId: option.source, + label: option.name, + selected: selectedIndicatorSet.has(option.id), + icon: ( + + ), + })), + [indicatorOptions, normalizedSearchQuery, selectedIndicatorSet] + ) + + const visibleIndicatorGroups = useMemo(() => { + if (!normalizedSearchQuery) return indicatorGroups + const groupIds = new Set(sidebarItems.map((item) => item.groupId)) + return indicatorGroups.filter((group) => groupIds.has(group.id)) + }, [indicatorGroups, normalizedSearchQuery, sidebarItems]) + + const displayedActiveFilterId = visibleIndicatorGroups.some( + (group) => group.id === activeFilterId + ) + ? activeFilterId + : (visibleIndicatorGroups[0]?.id ?? null) + + const setOpen = (open: boolean) => { + setDropdownOpen(open) + if (open) { + setActiveFilterId(selectedFilterId) + return + } + setSearchQuery('') + } - const chevronClassName = - 'h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180' + const triggerValue = dropdownOpen ? searchQuery : hasSelection ? selectionLabel : '' + const triggerPlaceholder = dropdownOpen ? copy.searchPlaceholder : selectionLabel return ( - - - - - - - - - - {tooltipText} - - setOpen(true)} + onChange={(event) => { + setSearchQuery(event.target.value) + if (!dropdownOpen) { + setOpen(true) + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setOpen(false) + inputRef.current?.blur() + } + }} + /> + + {tooltipText} + +
+ {isLoading ? ( + + ) : ( + colorBadge + )} +
+ +
+ + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} onWheel={(event) => event.stopPropagation()} > -
-
-
- - setSearchQuery(event.target.value)} - placeholder={copy.searchPlaceholder} - className='h-6 border-0 bg-transparent px-0 text-foreground text-xs placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - onKeyDown={handleSearchInputKeyDown} - autoComplete='off' - autoCorrect='off' - spellCheck='false' - disabled={isDropdownDisabled} - /> -
-
-
- div]:!block', - '[&_[data-radix-scroll-area-viewport]>div]:w-full', - '[&_[data-radix-scroll-area-viewport]>div]:max-w-full', - '[&_[data-radix-scroll-area-viewport]>div]:overflow-hidden' - )} - style={{ - height: DROPDOWN_VIEWPORT_HEIGHT, - }} - > - {(() => { - if (!workspaceId) { - return ( -

- {copy.selectWorkspaceFirst} -

- ) - } - - const hasFilteredIndicators = - filteredDefaultIndicators.length > 0 || filteredCustomIndicators.length > 0 - - if (loadError && !hasIndicators) { - return ( -
-

{loadError}

- -
- ) - } - - const shouldShowLoadingState = (isLoading || isFetching) && !hasIndicators - if (shouldShowLoadingState) { - return ( -
- - {copy.loadingIndicators} -
- ) - } - - if (!hasIndicators) { - return ( -

- {copy.noIndicatorsAvailableYet} -

- ) - } - - if (!hasFilteredIndicators) { - return ( -

- {searchQuery.trim() ? copy.noIndicatorsFound : copy.noIndicatorsAvailableYet} -

- ) - } - - const sections = [ - { - key: 'default', - label: filteredDefaultIndicators.length > 0 ? copy.defaultIndicators : null, - items: filteredDefaultIndicators, - }, - { - key: 'custom', - label: filteredDefaultIndicators.length > 0 ? copy.customIndicators : null, - items: filteredCustomIndicators, - }, - ].filter((section) => section.items.length > 0) - - return ( -
- {loadError ? ( -
-

{loadError}

- -
- ) : null} - {sections.map((section) => ( -
- {section.label ? ( -
- {section.label} -
- ) : null} - {section.items.map((option) => { - const isSelected = selectedIndicatorSet.has(option.id) - return ( - { - if (isMultiSelect) { - event.preventDefault() - } - handleToggleIndicator(option.id) - }} - > - - {option.name} - {isSelected && } - - ) - })} -
- ))} -
- ) - })()} -
-
-
- -
+ setActiveFilterId(groupId as IndicatorFilterId)} + onSelectItem={(item) => { + handleToggleIndicator(item.id) + if (!isMultiSelect) { + setOpen(false) + inputRef.current?.blur() + } + }} + loadingContent={shouldShowLoadingState ? loadingContent : null} + emptyContent={emptyContent} + /> + + ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx index 1e3b55c93..4d382ef34 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/combobox.tsx @@ -6,10 +6,15 @@ import { Button } from '@/components/ui/button' import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' import { formatDisplayText } from '@/components/ui/formatted-text' import { Input } from '@/components/ui/input' +import { + type SidebarDropdownGroup, + type SidebarDropdownItem, + SidebarDropdownMenuContent, +} from '@/components/ui/sidebar-dropdown-menu' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' -import type { SubBlockConfig } from '@/blocks/types' +import type { SubBlockConfig, SubBlockOption } from '@/blocks/types' import { useTagSelection } from '@/hooks/use-tag-selection' import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' import { translateWorkflowLabel } from '@/i18n/block-editor' @@ -20,9 +25,7 @@ import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workfl const logger = createLogger('ComboBox') interface ComboBoxProps { - options: - | Array - | (() => Array) + options: Array | (() => Array) defaultValue?: string blockId: string subBlockId: string @@ -55,6 +58,7 @@ export function ComboBox({ const [searchTerm, setSearchTerm] = useState('') const [cursorPosition, setCursorPosition] = useState(0) const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + const [activeSidebarGroupId, setActiveSidebarGroupId] = useState(null) const [highlightedIndex, setHighlightedIndex] = useState(-1) const [hasTyped, setHasTyped] = useState(false) @@ -67,20 +71,44 @@ export function ComboBox({ const reactFlowInstance = useReactFlow() const value = propValue !== undefined ? propValue : storeValue + const displayValue = value?.toString() ?? '' // Evaluate options if it's a function const evaluatedOptions = useMemo(() => { return typeof options === 'function' ? options() : options }, [options]) - const getOptionValue = (option: string | { label: string; id: string }) => { - return typeof option === 'string' ? option : option.id + const getOptionValue = (option: string | SubBlockOption) => { + return typeof option === 'string' ? option : String(option.value ?? option.id) } - const getOptionLabel = (option: string | { label: string; id: string }) => { + const getOptionLabel = (option: string | SubBlockOption) => { return typeof option === 'string' ? option : option.label } + const getOptionGroup = (option: string | SubBlockOption) => { + return typeof option === 'string' ? 'Options' : (option.group ?? 'Options') + } + + const getOptionSearchText = (option: string | SubBlockOption) => { + if (typeof option === 'string') return option + return option.searchLabel ?? option.label + } + + const getOptionIcon = (option: string | SubBlockOption) => { + return typeof option === 'string' ? null : (option.icon ?? null) + } + + const selectedOption = useMemo(() => { + if (value === null || value === undefined) return undefined + + return evaluatedOptions.find((opt) => { + const optionValue = getOptionValue(opt) + const optionLabel = getOptionLabel(opt) + return optionValue === value || optionLabel === value + }) + }, [evaluatedOptions, value, getOptionLabel, getOptionValue]) + // Get the default option value (prefer gpt-4o, then provided defaultValue, then first option) const defaultOptionValue = useMemo(() => { if (defaultValue !== undefined) { @@ -139,10 +167,65 @@ export function ComboBox({ return evaluatedOptions.filter((option) => { const label = getOptionLabel(option).toLowerCase() const optionValue = getOptionValue(option).toLowerCase() + const searchLabel = getOptionSearchText(option).toLowerCase() const search = currentValue.toLowerCase() - return label.includes(search) || optionValue.includes(search) + return label.includes(search) || optionValue.includes(search) || searchLabel.includes(search) }) - }, [evaluatedOptions, value, open, getOptionLabel, getOptionValue, hasTyped]) + }, [evaluatedOptions, value, open, getOptionLabel, getOptionValue, getOptionSearchText, hasTyped]) + + const configuredSidebarGroups = useMemo(() => { + const optionGroups = config.optionGroups + if (!optionGroups) return [] + return typeof optionGroups === 'function' ? optionGroups() : optionGroups + }, [config.optionGroups]) + + const sidebarItems = useMemo( + () => + filteredOptions.map((option) => { + const optionValue = getOptionValue(option) + const Icon = getOptionIcon(option) + return { + id: optionValue, + groupId: getOptionGroup(option), + label: getOptionLabel(option), + selected: displayValue === optionValue || displayValue === getOptionLabel(option), + icon: Icon ? : null, + } + }), + [displayValue, filteredOptions, getOptionGroup, getOptionIcon, getOptionLabel, getOptionValue] + ) + + const sidebarGroups = useMemo(() => { + const groupIds = Array.from(new Set(sidebarItems.map((item) => item.groupId))) + if (configuredSidebarGroups.length === 0) { + return groupIds.map((groupId) => ({ id: groupId, label: groupId })) + } + + const configuredGroupIds = new Set(configuredSidebarGroups.map((group) => group.id)) + return [ + ...configuredSidebarGroups.filter((group) => groupIds.includes(group.id)), + ...groupIds + .filter((groupId) => !configuredGroupIds.has(groupId)) + .map((groupId) => ({ id: groupId, label: groupId })), + ] + }, [configuredSidebarGroups, sidebarItems]) + + const usesSidebarMode = config.dropdownMode === 'sidebar' + const selectedSidebarGroupId = selectedOption ? getOptionGroup(selectedOption) : null + const currentSidebarGroupId = + activeSidebarGroupId && sidebarGroups.some((group) => group.id === activeSidebarGroupId) + ? activeSidebarGroupId + : null + const currentSelectedSidebarGroupId = + selectedSidebarGroupId && sidebarGroups.some((group) => group.id === selectedSidebarGroupId) + ? selectedSidebarGroupId + : null + const resolvedActiveSidebarGroupId = + currentSidebarGroupId ?? currentSelectedSidebarGroupId ?? sidebarGroups[0]?.id ?? null + const activeSidebarItems = useMemo( + () => sidebarItems.filter((item) => item.groupId === resolvedActiveSidebarGroupId), + [resolvedActiveSidebarGroupId, sidebarItems] + ) // Event handlers const handleChange = (e: React.ChangeEvent) => { @@ -222,23 +305,33 @@ export function ComboBox({ if (e.key === 'ArrowDown') { e.preventDefault() + const optionCount = usesSidebarMode ? activeSidebarItems.length : filteredOptions.length if (!open) { setOpen(true) setHighlightedIndex(0) } else { - setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : 0)) + setHighlightedIndex((prev) => (prev < optionCount - 1 ? prev + 1 : 0)) } } if (e.key === 'ArrowUp') { e.preventDefault() if (open) { - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1)) + const optionCount = usesSidebarMode ? activeSidebarItems.length : filteredOptions.length + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : optionCount - 1)) } } if (e.key === 'Enter' && open && highlightedIndex >= 0) { e.preventDefault() + if (usesSidebarMode) { + const selectedItem = activeSidebarItems[highlightedIndex] + if (selectedItem) { + handleSelect(selectedItem.id) + } + return + } + const selectedOption = filteredOptions[highlightedIndex] if (selectedOption) { handleSelect(getOptionValue(selectedOption)) @@ -392,17 +485,6 @@ export function ComboBox({ } }, [open]) - // Display value with formatting - const displayValue = value?.toString() ?? '' - const selectedOption = useMemo(() => { - if (value === null || value === undefined) return undefined - - return evaluatedOptions.find((opt) => { - const optionValue = getOptionValue(opt) - const optionLabel = getOptionLabel(opt) - return optionValue === value || optionLabel === value - }) - }, [evaluatedOptions, value]) const SelectedIcon = selectedOption && typeof selectedOption === 'object' && 'icon' in selectedOption ? (selectedOption.icon as React.ComponentType<{ className?: string }>) @@ -472,15 +554,37 @@ export function ComboBox({ {/* Dropdown */} {open && ( -
+
setHighlightedIndex(-1)} > - {filteredOptions.length === 0 ? ( + {usesSidebarMode ? ( + { + setActiveSidebarGroupId(groupId) + setHighlightedIndex(-1) + }} + onSelectItem={(item) => handleSelect(item.id)} + loadingContent={null} + emptyContent={translateWorkflowLabel(locale, 'noMatchingOptionsFound')} + /> + ) : filteredOptions.length === 0 ? (
{translateWorkflowLabel(locale, 'noMatchingOptionsFound')}
From d84530c0dd7f6d90628c6ac09980529722f0f095 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 3 Jun 2026 00:56:10 -0600 Subject: [PATCH 46/49] refactor(workflow): extract shared panel class name Co-authored-by: Codex --- .../workflow-editor/panel/node-editor-panel.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx index e79f8b0c2..c0d063761 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx @@ -42,6 +42,9 @@ const PARALLEL_TYPE_OPTIONS: Array<{ value: ParallelType; label: string }> = [ { value: 'collection', label: 'Parallel Each' }, ] +const panelClassName = + 'allow-scroll !m-2 max-h-[calc(100%-1rem)] min-w-0 w-[calc(100%-1rem)] max-w-96 overflow-y-auto rounded-lg border bg-card shadow-md' + export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { const { workflowEditorCopy, workflowInspectorCopy, getLocalizedDefaultBlockName } = useWorkflowI18n() @@ -124,12 +127,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { renamingBlockIdRef.current = null setIsRenaming(false) setEditedName('') - }, [ - collaborativeUpdateBlockName, - editedName, - isRenaming, - selectedBlock, - ]) + }, [collaborativeUpdateBlockName, editedName, isRenaming, selectedBlock]) const handleCancelRename = useCallback(() => { renamingBlockIdRef.current = null @@ -364,7 +362,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { return ( Date: Fri, 5 Jun 2026 14:14:47 -0600 Subject: [PATCH 47/49] feat(monitor): add kanban column header actions Co-authored-by: Codex --- .../monitor/components/board/kanban.tsx | 7 ++++- .../components/board/monitor-board.tsx | 3 +-- .../components/board/monitor-kanban.tsx | 26 +++++++------------ .../config/monitor-config-board.tsx | 5 +--- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx index 564330eea..4f19a568c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx @@ -557,10 +557,12 @@ export function KanbanBoard({ className, ...props }: ComponentProps<'div'>) { } function KanbanHeader({ + action, columnId, count, title, }: { + action?: ReactNode columnId: string count: number title: string @@ -575,6 +577,7 @@ function KanbanHeader({ {count}
+ {action ?
{action}
: null} ) } @@ -586,6 +589,7 @@ export function KanbanCards({ className, columnId, count, + headerAction, itemIds = [], listClassName, onDropOverColumn, @@ -597,6 +601,7 @@ export function KanbanCards({ beforeCards?: ReactNode columnId: string count: number + headerAction?: ReactNode itemIds?: string[] listClassName?: string onDropOverColumn?: (activeId: string) => void @@ -638,7 +643,7 @@ export function KanbanCards({ )} ref={setNodeRef} > - + {beforeCards}
    {children}
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx index 92941e25f..32bb674a9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx @@ -251,8 +251,7 @@ export function MonitorBoard({ canDrop={canDrop} onDropOverColumn={() => handleDropAtColumn(column)} itemIds={column.items.map((item) => item.logId)} - summary={formatTemplate(copy.shared.itemsCount, { count: column.totalCount })} - metaAction={ + headerAction={ column.limit ? ( {formatTemplate(copy.execution.limitLabel, { count: column.limit })} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx index ceb64ee81..a3aeb9e91 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx @@ -54,8 +54,6 @@ type MonitorKanbanColumnProps = Omit, 'before aggregateVariant?: BadgeProps['variant'] aggregates?: Record formatAggregateValue?: (field: string, value: number | string | undefined) => ReactNode - metaAction?: ReactNode - summary: ReactNode } export function MonitorKanbanColumn({ @@ -65,28 +63,22 @@ export function MonitorKanbanColumn({ aggregates = {}, children, formatAggregateValue, + headerAction, listClassName, - metaAction, - summary, ...props }: MonitorKanbanColumnProps) { return ( -
-
{summary}
- {metaAction} -
- - + } {...props} > diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx index 4b1d84a55..7b9dab94e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx @@ -206,10 +206,7 @@ export function MonitorConfigBoard({ canDrop={canDrop} onDropOverColumn={() => handleDropAtBucket(bucket)} itemIds={bucket.cards.map((card) => card.monitorId)} - summary={formatTemplate(copy.shared.monitorsCount, { - count: bucket.cards.length, - })} - metaAction={ + headerAction={